mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-12 23:27:30 +08:00
Tighten subagent prompt file workflow
This commit is contained in:
19
agents/coder/AGENT.md
Normal file
19
agents/coder/AGENT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Coder Agent
|
||||
|
||||
## Role
|
||||
You are the implementation-focused subagent. Make concrete code changes, keep them minimal and correct, and verify what you changed.
|
||||
|
||||
## Priorities
|
||||
- Read the relevant code before editing.
|
||||
- Prefer direct fixes over speculative refactors.
|
||||
- Preserve established project patterns unless the task requires a broader change.
|
||||
|
||||
## Execution
|
||||
- Explain assumptions briefly when they matter.
|
||||
- Run targeted verification after changes when possible.
|
||||
- Report changed areas, verification status, and residual risks.
|
||||
|
||||
## Output Format
|
||||
- Summary: what changed.
|
||||
- Verification: tests, builds, or checks run.
|
||||
- Risks: what remains uncertain.
|
||||
19
agents/main/AGENT.md
Normal file
19
agents/main/AGENT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Main Agent
|
||||
|
||||
## Role
|
||||
You are the main agent and router for this workspace. Coordinate work, choose whether to handle it directly or dispatch to a subagent, and keep the overall execution coherent.
|
||||
|
||||
## Responsibilities
|
||||
- Interpret the user's goal and decide the next concrete step.
|
||||
- Route implementation, testing, or research tasks to the right subagent when delegation is useful.
|
||||
- Keep control flow main-mediated by default.
|
||||
- Review subagent results before replying to the user.
|
||||
|
||||
## Subagent Management
|
||||
- When creating a new subagent, update config and create the matching `agents/<agent_id>/AGENT.md` in the same task.
|
||||
- Treat `system_prompt_file` as the primary prompt source for configured subagents.
|
||||
- Do not leave newly created agents with only a one-line inline prompt.
|
||||
|
||||
## Output Style
|
||||
- Be concise.
|
||||
- Report decisions, outcomes, and remaining risks clearly.
|
||||
19
agents/tester/AGENT.md
Normal file
19
agents/tester/AGENT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Tester Agent
|
||||
|
||||
## Role
|
||||
You are the verification-focused subagent. Validate behavior, look for regressions, and report evidence clearly.
|
||||
|
||||
## Priorities
|
||||
- Prefer reproducible checks over opinion.
|
||||
- Focus on behavioral regressions, missing coverage, and unclear assumptions.
|
||||
- Escalate the most important failures first.
|
||||
|
||||
## Execution
|
||||
- Use the smallest set of checks that can prove or disprove the target behavior.
|
||||
- Distinguish confirmed failures from unverified risk.
|
||||
- If you cannot run a check, say so explicitly.
|
||||
|
||||
## Output Format
|
||||
- Findings: concrete issues or confirmation that none were found.
|
||||
- Verification: commands or scenarios checked.
|
||||
- Gaps: what was not covered.
|
||||
@@ -61,6 +61,10 @@ func resolveAutoRouteTarget(cfg *config.Config, raw string) (string, string) {
|
||||
if content == "" || len(cfg.Agents.Subagents) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
maxChars := cfg.Agents.Router.Policy.IntentMaxInputChars
|
||||
if maxChars > 0 && len([]rune(content)) > maxChars {
|
||||
return "", ""
|
||||
}
|
||||
lower := strings.ToLower(content)
|
||||
for agentID, subcfg := range cfg.Agents.Subagents {
|
||||
if !subcfg.Enabled {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func TestResolveAutoRouteTarget(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true}
|
||||
cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, SystemPromptFile: "agents/coder/AGENT.md"}
|
||||
|
||||
agentID, task := resolveAutoRouteTarget(cfg, "@coder fix login")
|
||||
if agentID != "coder" || task != "fix login" {
|
||||
@@ -25,8 +25,8 @@ func TestResolveAutoRouteTargetRulesFirst(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Router.Strategy = "rules_first"
|
||||
cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, Role: "coding"}
|
||||
cfg.Agents.Subagents["tester"] = config.SubagentConfig{Enabled: true, Role: "testing"}
|
||||
cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, Role: "coding", SystemPromptFile: "agents/coder/AGENT.md"}
|
||||
cfg.Agents.Subagents["tester"] = config.SubagentConfig{Enabled: true, Role: "testing", SystemPromptFile: "agents/tester/AGENT.md"}
|
||||
cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "coder", Keywords: []string{"登录", "bug"}}}
|
||||
|
||||
agentID, task := resolveAutoRouteTarget(cfg, "请帮我修复登录接口的 bug 并改代码")
|
||||
@@ -39,7 +39,7 @@ func TestMaybeAutoRouteDispatchesExplicitAgentMention(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Router.DefaultTimeoutSec = 5
|
||||
cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true}
|
||||
cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, SystemPromptFile: "agents/coder/AGENT.md"}
|
||||
runtimecfg.Set(cfg)
|
||||
t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) })
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestMaybeAutoRouteDispatchesExplicitAgentMention(t *testing.T) {
|
||||
func TestMaybeAutoRouteSkipsNormalMessages(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true}
|
||||
cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, SystemPromptFile: "agents/coder/AGENT.md"}
|
||||
runtimecfg.Set(cfg)
|
||||
t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) })
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestMaybeAutoRouteDispatchesRulesFirstMatch(t *testing.T) {
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Router.Strategy = "rules_first"
|
||||
cfg.Agents.Router.DefaultTimeoutSec = 5
|
||||
cfg.Agents.Subagents["tester"] = config.SubagentConfig{Enabled: true, Role: "testing"}
|
||||
cfg.Agents.Subagents["tester"] = config.SubagentConfig{Enabled: true, Role: "testing", SystemPromptFile: "agents/tester/AGENT.md"}
|
||||
runtimecfg.Set(cfg)
|
||||
t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) })
|
||||
|
||||
@@ -125,3 +125,15 @@ func TestMaybeAutoRouteDispatchesRulesFirstMatch(t *testing.T) {
|
||||
t.Fatalf("expected merged output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAutoRouteTargetSkipsOversizedIntent(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Router.Policy.IntentMaxInputChars = 5
|
||||
cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true, SystemPromptFile: "agents/coder/AGENT.md"}
|
||||
|
||||
agentID, task := resolveAutoRouteTarget(cfg, "@coder implement auth")
|
||||
if agentID != "" || task != "" {
|
||||
t.Fatalf("expected oversized intent to skip routing, got %s / %s", agentID, task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -124,6 +126,14 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
|
||||
}
|
||||
items := make([]map[string]interface{}, 0, len(cfg.Agents.Subagents))
|
||||
for agentID, subcfg := range cfg.Agents.Subagents {
|
||||
promptFileFound := false
|
||||
if strings.TrimSpace(subcfg.SystemPromptFile) != "" {
|
||||
if absPath, err := al.resolvePromptFilePath(subcfg.SystemPromptFile); err == nil {
|
||||
if info, statErr := os.Stat(absPath); statErr == nil && !info.IsDir() {
|
||||
promptFileFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"enabled": subcfg.Enabled,
|
||||
@@ -133,6 +143,7 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
|
||||
"description": subcfg.Description,
|
||||
"system_prompt": subcfg.SystemPrompt,
|
||||
"system_prompt_file": subcfg.SystemPromptFile,
|
||||
"prompt_file_found": promptFileFound,
|
||||
"memory_namespace": subcfg.MemoryNamespace,
|
||||
"tool_allowlist": append([]string(nil), subcfg.Tools.Allowlist...),
|
||||
"routing_keywords": routeKeywordsForRegistry(cfg.Agents.Router.Rules, agentID),
|
||||
@@ -177,6 +188,9 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
if al.isProtectedMainAgent(agentID) {
|
||||
return nil, fmt.Errorf("main agent %q cannot be disabled", agentID)
|
||||
}
|
||||
enabled, ok := args["enabled"].(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("enabled is required")
|
||||
@@ -190,9 +204,85 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
if al.isProtectedMainAgent(agentID) {
|
||||
return nil, fmt.Errorf("main agent %q cannot be deleted", agentID)
|
||||
}
|
||||
return tools.DeleteConfigSubagent(al.configPath, agentID)
|
||||
case "upsert_config_subagent":
|
||||
return tools.UpsertConfigSubagent(al.configPath, args)
|
||||
case "prompt_file_get":
|
||||
relPath := runtimeStringArg(args, "path")
|
||||
if relPath == "" {
|
||||
return nil, fmt.Errorf("path is required")
|
||||
}
|
||||
absPath, err := al.resolvePromptFilePath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]interface{}{"found": false, "path": relPath, "content": ""}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"found": true, "path": relPath, "content": string(data)}, nil
|
||||
case "prompt_file_set":
|
||||
relPath := runtimeStringArg(args, "path")
|
||||
if relPath == "" {
|
||||
return nil, fmt.Errorf("path is required")
|
||||
}
|
||||
content := runtimeRawStringArg(args, "content")
|
||||
absPath, err := al.resolvePromptFilePath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "path": relPath, "bytes": len(content)}, nil
|
||||
case "prompt_file_bootstrap":
|
||||
agentID := runtimeStringArg(args, "agent_id")
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
relPath := runtimeStringArg(args, "path")
|
||||
if relPath == "" {
|
||||
relPath = filepath.ToSlash(filepath.Join("agents", agentID, "AGENT.md"))
|
||||
}
|
||||
absPath, err := al.resolvePromptFilePath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overwrite, _ := args["overwrite"].(bool)
|
||||
if _, err := os.Stat(absPath); err == nil && !overwrite {
|
||||
data, readErr := os.ReadFile(absPath)
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ok": true,
|
||||
"created": false,
|
||||
"path": relPath,
|
||||
"content": string(data),
|
||||
}, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content := buildPromptTemplate(agentID, runtimeStringArg(args, "role"), runtimeStringArg(args, "display_name"))
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ok": true,
|
||||
"created": true,
|
||||
"path": relPath,
|
||||
"content": content,
|
||||
}, nil
|
||||
case "kill":
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
@@ -476,6 +566,14 @@ func runtimeStringArg(args map[string]interface{}, key string) string {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func runtimeRawStringArg(args map[string]interface{}, key string) string {
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := args[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func runtimeIntArg(args map[string]interface{}, key string, fallback int) int {
|
||||
if args == nil {
|
||||
return fallback
|
||||
@@ -513,3 +611,74 @@ func routeKeywordsForRegistry(rules []config.AgentRouteRule, agentID string) []s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (al *AgentLoop) isProtectedMainAgent(agentID string) bool {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID == "" {
|
||||
return false
|
||||
}
|
||||
cfg := runtimecfg.Get()
|
||||
if cfg == nil {
|
||||
return agentID == "main"
|
||||
}
|
||||
mainID := strings.TrimSpace(cfg.Agents.Router.MainAgentID)
|
||||
if mainID == "" {
|
||||
mainID = "main"
|
||||
}
|
||||
return agentID == mainID
|
||||
}
|
||||
|
||||
func (al *AgentLoop) resolvePromptFilePath(relPath string) (string, error) {
|
||||
relPath = strings.TrimSpace(relPath)
|
||||
if relPath == "" {
|
||||
return "", fmt.Errorf("path is required")
|
||||
}
|
||||
if filepath.IsAbs(relPath) {
|
||||
return "", fmt.Errorf("path must be relative")
|
||||
}
|
||||
cleaned := filepath.Clean(relPath)
|
||||
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
|
||||
return "", fmt.Errorf("path must stay within workspace")
|
||||
}
|
||||
workspace := "."
|
||||
if al != nil && strings.TrimSpace(al.workspace) != "" {
|
||||
workspace = al.workspace
|
||||
}
|
||||
return filepath.Join(workspace, cleaned), nil
|
||||
}
|
||||
|
||||
func buildPromptTemplate(agentID, role, displayName string) string {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
role = strings.TrimSpace(role)
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
title := displayName
|
||||
if title == "" {
|
||||
title = agentID
|
||||
}
|
||||
if title == "" {
|
||||
title = "subagent"
|
||||
}
|
||||
if role == "" {
|
||||
role = "worker"
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf(`# %s
|
||||
|
||||
## Role
|
||||
You are the %s subagent. Work within your role boundary and report concrete outcomes.
|
||||
|
||||
## Priorities
|
||||
- Follow workspace-level policy from workspace/AGENTS.md.
|
||||
- Complete the assigned task directly. Do not redefine the objective.
|
||||
- Prefer concrete edits, verification, and concise reporting over long analysis.
|
||||
|
||||
## Collaboration
|
||||
- Treat the main agent as the coordinator unless the task explicitly says otherwise.
|
||||
- Surface blockers, assumptions, and verification status in your reply.
|
||||
- Keep outputs short and execution-focused.
|
||||
|
||||
## Output Format
|
||||
- Summary: what you changed or checked.
|
||||
- Risks: anything not verified or still uncertain.
|
||||
- Next: the most useful immediate follow-up, if any.
|
||||
`, title, role))
|
||||
}
|
||||
|
||||
@@ -55,9 +55,10 @@ func TestHandleSubagentRuntimeUpsertConfigSubagent(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Subagents["main"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
SystemPromptFile: "agents/main/AGENT.md",
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("save config failed: %v", err)
|
||||
@@ -172,9 +173,10 @@ func TestHandleSubagentRuntimeConfirmPendingDraft(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Subagents["main"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
SystemPromptFile: "agents/main/AGENT.md",
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("save config failed: %v", err)
|
||||
@@ -187,7 +189,7 @@ func TestHandleSubagentRuntimeConfirmPendingDraft(t *testing.T) {
|
||||
configPath: configPath,
|
||||
subagentManager: manager,
|
||||
subagentRouter: tools.NewSubagentRouter(manager),
|
||||
pendingSubagentDraft: map[string]map[string]interface{}{"main": {"agent_id": "tester", "role": "testing", "type": "worker"}},
|
||||
pendingSubagentDraft: map[string]map[string]interface{}{"main": {"agent_id": "tester", "role": "testing", "type": "worker", "system_prompt_file": "agents/tester/AGENT.md"}},
|
||||
}
|
||||
out, err := loop.HandleSubagentRuntime(context.Background(), "confirm_pending_draft", map[string]interface{}{"session_key": "main"})
|
||||
if err != nil {
|
||||
@@ -212,17 +214,19 @@ func TestHandleSubagentRuntimeRegistryAndToggleEnabled(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Subagents["main"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
SystemPromptFile: "agents/main/AGENT.md",
|
||||
}
|
||||
cfg.Agents.Subagents["tester"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "worker",
|
||||
Role: "testing",
|
||||
DisplayName: "Test Agent",
|
||||
SystemPrompt: "run tests",
|
||||
MemoryNamespace: "tester",
|
||||
Enabled: true,
|
||||
Type: "worker",
|
||||
Role: "testing",
|
||||
DisplayName: "Test Agent",
|
||||
SystemPrompt: "run tests",
|
||||
SystemPromptFile: "agents/tester/AGENT.md",
|
||||
MemoryNamespace: "tester",
|
||||
Tools: config.SubagentToolsConfig{
|
||||
Allowlist: []string{"shell", "sessions"},
|
||||
},
|
||||
@@ -275,14 +279,16 @@ func TestHandleSubagentRuntimeDeleteConfigSubagent(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Subagents["main"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
SystemPromptFile: "agents/main/AGENT.md",
|
||||
}
|
||||
cfg.Agents.Subagents["tester"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "worker",
|
||||
Role: "testing",
|
||||
Enabled: true,
|
||||
Type: "worker",
|
||||
Role: "testing",
|
||||
SystemPromptFile: "agents/tester/AGENT.md",
|
||||
}
|
||||
cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "tester", Keywords: []string{"test"}}}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
@@ -316,3 +322,97 @@ func TestHandleSubagentRuntimeDeleteConfigSubagent(t *testing.T) {
|
||||
t.Fatalf("expected tester route rule to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubagentRuntimePromptFileGetSetBootstrap(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
manager := tools.NewSubagentManager(nil, workspace, nil, nil)
|
||||
loop := &AgentLoop{
|
||||
workspace: workspace,
|
||||
subagentManager: manager,
|
||||
subagentRouter: tools.NewSubagentRouter(manager),
|
||||
}
|
||||
|
||||
out, err := loop.HandleSubagentRuntime(context.Background(), "prompt_file_get", map[string]interface{}{
|
||||
"path": "agents/coder/AGENT.md",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prompt_file_get failed: %v", err)
|
||||
}
|
||||
payload, ok := out.(map[string]interface{})
|
||||
if !ok || payload["found"] != false {
|
||||
t.Fatalf("expected missing prompt file, got %#v", out)
|
||||
}
|
||||
|
||||
out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_bootstrap", map[string]interface{}{
|
||||
"agent_id": "coder",
|
||||
"role": "coding",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prompt_file_bootstrap failed: %v", err)
|
||||
}
|
||||
payload, ok = out.(map[string]interface{})
|
||||
if !ok || payload["created"] != true {
|
||||
t.Fatalf("expected prompt file bootstrap to create file, got %#v", out)
|
||||
}
|
||||
|
||||
out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_set", map[string]interface{}{
|
||||
"path": "agents/coder/AGENT.md",
|
||||
"content": "# coder\nupdated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prompt_file_set failed: %v", err)
|
||||
}
|
||||
payload, ok = out.(map[string]interface{})
|
||||
if !ok || payload["ok"] != true {
|
||||
t.Fatalf("expected prompt_file_set ok, got %#v", out)
|
||||
}
|
||||
|
||||
out, err = loop.HandleSubagentRuntime(context.Background(), "prompt_file_get", map[string]interface{}{
|
||||
"path": "agents/coder/AGENT.md",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("prompt_file_get after set failed: %v", err)
|
||||
}
|
||||
payload, ok = out.(map[string]interface{})
|
||||
if !ok || payload["found"] != true || payload["content"] != "# coder\nupdated" {
|
||||
t.Fatalf("unexpected prompt file payload: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubagentRuntimeProtectsMainAgent(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
configPath := filepath.Join(workspace, "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Router.MainAgentID = "main"
|
||||
cfg.Agents.Subagents["main"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
SystemPromptFile: "agents/main/AGENT.md",
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("save config failed: %v", err)
|
||||
}
|
||||
runtimecfg.Set(cfg)
|
||||
t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) })
|
||||
|
||||
manager := tools.NewSubagentManager(nil, workspace, nil, nil)
|
||||
loop := &AgentLoop{
|
||||
configPath: configPath,
|
||||
workspace: workspace,
|
||||
subagentManager: manager,
|
||||
subagentRouter: tools.NewSubagentRouter(manager),
|
||||
}
|
||||
if _, err := loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{
|
||||
"agent_id": "main",
|
||||
"enabled": false,
|
||||
}); err == nil {
|
||||
t.Fatalf("expected disabling main agent to fail")
|
||||
}
|
||||
if _, err := loop.HandleSubagentRuntime(context.Background(), "delete_config_subagent", map[string]interface{}{
|
||||
"agent_id": "main",
|
||||
}); err == nil {
|
||||
t.Fatalf("expected deleting main agent to fail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ func TestMaybeHandleSubagentConfigIntentCreateAndConfirm(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Subagents["main"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
SystemPromptFile: "agents/main/AGENT.md",
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("save config failed: %v", err)
|
||||
@@ -103,9 +104,10 @@ func TestPendingSubagentDraftPersistsAcrossLoopRestart(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Subagents["main"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
SystemPromptFile: "agents/main/AGENT.md",
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("save config failed: %v", err)
|
||||
|
||||
@@ -322,6 +322,9 @@ func validateSubagents(cfg *Config) []error {
|
||||
if raw.Tools.MaxParallelCalls < 0 {
|
||||
errs = append(errs, fmt.Errorf("agents.subagents.%s.tools.max_parallel_calls must be >= 0", id))
|
||||
}
|
||||
if raw.Enabled && strings.TrimSpace(raw.SystemPromptFile) == "" {
|
||||
errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file is required when enabled=true", id))
|
||||
}
|
||||
if promptFile := strings.TrimSpace(raw.SystemPromptFile); promptFile != "" {
|
||||
if filepath.IsAbs(promptFile) {
|
||||
errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file must be relative", id))
|
||||
|
||||
@@ -9,16 +9,18 @@ func TestValidateSubagentsAllowsKnownPeers(t *testing.T) {
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Router.MainAgentID = "main"
|
||||
cfg.Agents.Subagents["main"] = SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
AcceptFrom: []string{"user", "coder"},
|
||||
CanTalkTo: []string{"coder"},
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
SystemPromptFile: "agents/main/AGENT.md",
|
||||
AcceptFrom: []string{"user", "coder"},
|
||||
CanTalkTo: []string{"coder"},
|
||||
}
|
||||
cfg.Agents.Subagents["coder"] = SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "worker",
|
||||
AcceptFrom: []string{"main"},
|
||||
CanTalkTo: []string{"main"},
|
||||
Enabled: true,
|
||||
Type: "worker",
|
||||
SystemPromptFile: "agents/coder/AGENT.md",
|
||||
AcceptFrom: []string{"main"},
|
||||
CanTalkTo: []string{"main"},
|
||||
Runtime: SubagentRuntimeConfig{
|
||||
Proxy: "proxy",
|
||||
},
|
||||
@@ -34,8 +36,9 @@ func TestValidateSubagentsRejectsUnknownPeer(t *testing.T) {
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Agents.Subagents["coder"] = SubagentConfig{
|
||||
Enabled: true,
|
||||
AcceptFrom: []string{"main"},
|
||||
Enabled: true,
|
||||
SystemPromptFile: "agents/coder/AGENT.md",
|
||||
AcceptFrom: []string{"main"},
|
||||
}
|
||||
|
||||
if errs := Validate(cfg); len(errs) == 0 {
|
||||
@@ -59,3 +62,19 @@ func TestValidateSubagentsRejectsAbsolutePromptFile(t *testing.T) {
|
||||
t.Fatalf("expected validation errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSubagentsRequiresPromptFileWhenEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Agents.Subagents["coder"] = SubagentConfig{
|
||||
Enabled: true,
|
||||
Runtime: SubagentRuntimeConfig{
|
||||
Proxy: "proxy",
|
||||
},
|
||||
}
|
||||
|
||||
if errs := Validate(cfg); len(errs) == 0 {
|
||||
t.Fatalf("expected validation errors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +49,18 @@ func UpsertConfigSubagent(configPath string, args map[string]interface{}) (map[s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mainID := strings.TrimSpace(cfg.Agents.Router.MainAgentID)
|
||||
if mainID == "" {
|
||||
mainID = "main"
|
||||
}
|
||||
if cfg.Agents.Subagents == nil {
|
||||
cfg.Agents.Subagents = map[string]config.SubagentConfig{}
|
||||
}
|
||||
subcfg := cfg.Agents.Subagents[agentID]
|
||||
if enabled, ok := boolArgFromMap(args, "enabled"); ok {
|
||||
if agentID == mainID && !enabled {
|
||||
return nil, fmt.Errorf("main agent %q cannot be disabled", agentID)
|
||||
}
|
||||
subcfg.Enabled = enabled
|
||||
} else if !subcfg.Enabled {
|
||||
subcfg.Enabled = true
|
||||
@@ -84,6 +91,9 @@ func UpsertConfigSubagent(configPath string, args map[string]interface{}) (map[s
|
||||
} else if strings.TrimSpace(subcfg.Type) == "" {
|
||||
subcfg.Type = "worker"
|
||||
}
|
||||
if subcfg.Enabled && strings.TrimSpace(subcfg.SystemPromptFile) == "" {
|
||||
return nil, fmt.Errorf("system_prompt_file is required for enabled agent %q", agentID)
|
||||
}
|
||||
cfg.Agents.Subagents[agentID] = subcfg
|
||||
if kws := stringListArgFromMap(args, "routing_keywords"); len(kws) > 0 {
|
||||
cfg.Agents.Router.Rules = upsertRouteRuleConfig(cfg.Agents.Router.Rules, config.AgentRouteRule{
|
||||
@@ -123,6 +133,13 @@ func DeleteConfigSubagent(configPath, agentID string) (map[string]interface{}, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mainID := strings.TrimSpace(cfg.Agents.Router.MainAgentID)
|
||||
if mainID == "" {
|
||||
mainID = "main"
|
||||
}
|
||||
if agentID == mainID {
|
||||
return nil, fmt.Errorf("main agent %q cannot be deleted", agentID)
|
||||
}
|
||||
if cfg.Agents.Subagents == nil {
|
||||
return map[string]interface{}{"ok": false, "found": false, "agent_id": agentID}, nil
|
||||
}
|
||||
|
||||
@@ -38,9 +38,10 @@ func TestSubagentConfigToolUpsert(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Router.Enabled = true
|
||||
cfg.Agents.Subagents["main"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
Enabled: true,
|
||||
Type: "router",
|
||||
Role: "orchestrator",
|
||||
SystemPromptFile: "agents/main/AGENT.md",
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("save config failed: %v", err)
|
||||
@@ -50,14 +51,15 @@ func TestSubagentConfigToolUpsert(t *testing.T) {
|
||||
|
||||
tool := NewSubagentConfigTool(configPath)
|
||||
out, err := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "upsert",
|
||||
"agent_id": "reviewer",
|
||||
"role": "testing",
|
||||
"display_name": "Review Agent",
|
||||
"description": "负责回归与评审",
|
||||
"system_prompt": "review changes",
|
||||
"routing_keywords": []interface{}{"review", "regression"},
|
||||
"tool_allowlist": []interface{}{"shell", "sessions"},
|
||||
"action": "upsert",
|
||||
"agent_id": "reviewer",
|
||||
"role": "testing",
|
||||
"display_name": "Review Agent",
|
||||
"description": "负责回归与评审",
|
||||
"system_prompt": "review changes",
|
||||
"system_prompt_file": "agents/reviewer/AGENT.md",
|
||||
"routing_keywords": []interface{}{"review", "regression"},
|
||||
"tool_allowlist": []interface{}{"shell", "sessions"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert failed: %v", err)
|
||||
|
||||
@@ -171,8 +171,9 @@ func TestSubagentProfileStoreRejectsWritesForConfigManagedProfiles(t *testing.T)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Subagents["tester"] = config.SubagentConfig{
|
||||
Enabled: true,
|
||||
Role: "test",
|
||||
Enabled: true,
|
||||
Role: "test",
|
||||
SystemPromptFile: "agents/tester/AGENT.md",
|
||||
}
|
||||
runtimecfg.Set(cfg)
|
||||
|
||||
|
||||
@@ -39,6 +39,14 @@ const resources = {
|
||||
noPendingSubagentDrafts: 'No pending subagent drafts.',
|
||||
saveToConfig: 'Save To Config',
|
||||
configSubagentSaved: 'Subagent config saved and runtime updated.',
|
||||
promptFileEditor: 'Prompt File Editor',
|
||||
promptFileEditorPlaceholder: 'Edit the AGENT.md content for this subagent.',
|
||||
bootstrapPromptFile: 'Bootstrap AGENT.md',
|
||||
savePromptFile: 'Save AGENT.md',
|
||||
promptFileSaved: 'Prompt file saved.',
|
||||
promptFileBootstrapped: 'Prompt file template created.',
|
||||
promptFileReady: 'AGENT.md ready',
|
||||
promptFileMissing: 'AGENT.md missing',
|
||||
threadTrace: 'Thread Trace',
|
||||
threadMessages: 'Thread Messages',
|
||||
inbox: 'Inbox',
|
||||
@@ -465,6 +473,14 @@ const resources = {
|
||||
noPendingSubagentDrafts: '当前没有待确认的子代理草案。',
|
||||
saveToConfig: '写入配置',
|
||||
configSubagentSaved: '子代理配置已写入并刷新运行态。',
|
||||
promptFileEditor: '提示词文件编辑器',
|
||||
promptFileEditorPlaceholder: '编辑该子代理对应的 AGENT.md 内容。',
|
||||
bootstrapPromptFile: '生成 AGENT.md 模板',
|
||||
savePromptFile: '保存 AGENT.md',
|
||||
promptFileSaved: '提示词文件已保存。',
|
||||
promptFileBootstrapped: '提示词模板已创建。',
|
||||
promptFileReady: 'AGENT.md 已就绪',
|
||||
promptFileMissing: 'AGENT.md 缺失',
|
||||
threadTrace: '线程追踪',
|
||||
threadMessages: '线程消息',
|
||||
inbox: '收件箱',
|
||||
|
||||
@@ -81,6 +81,7 @@ type RegistrySubagent = {
|
||||
description?: string;
|
||||
system_prompt?: string;
|
||||
system_prompt_file?: string;
|
||||
prompt_file_found?: boolean;
|
||||
memory_namespace?: string;
|
||||
tool_allowlist?: string[];
|
||||
routing_keywords?: string[];
|
||||
@@ -119,6 +120,8 @@ const Subagents: React.FC = () => {
|
||||
const [draftDescription, setDraftDescription] = useState('');
|
||||
const [pendingDrafts, setPendingDrafts] = useState<PendingSubagentDraft[]>([]);
|
||||
const [registryItems, setRegistryItems] = useState<RegistrySubagent[]>([]);
|
||||
const [promptFileContent, setPromptFileContent] = useState('');
|
||||
const [promptFileFound, setPromptFileFound] = useState(false);
|
||||
|
||||
const apiPath = '/webui/api/subagents_runtime';
|
||||
const withAction = (action: string) => `${apiPath}${q}${q ? '&' : '?'}action=${encodeURIComponent(action)}`;
|
||||
@@ -187,6 +190,22 @@ const Subagents: React.FC = () => {
|
||||
loadThreadAndInbox(selected).catch(() => {});
|
||||
}, [selectedId, q, items]);
|
||||
|
||||
useEffect(() => {
|
||||
const path = configSystemPromptFile.trim();
|
||||
if (!path) {
|
||||
setPromptFileContent('');
|
||||
setPromptFileFound(false);
|
||||
return;
|
||||
}
|
||||
callAction({ action: 'prompt_file_get', path })
|
||||
.then((data) => {
|
||||
const found = data?.result?.found === true;
|
||||
setPromptFileFound(found);
|
||||
setPromptFileContent(found ? data?.result?.content || '' : '');
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [configSystemPromptFile, q]);
|
||||
|
||||
const spawn = async () => {
|
||||
if (!spawnTask.trim()) {
|
||||
await ui.notify({ title: t('requestFailed'), message: 'task is required' });
|
||||
@@ -331,6 +350,7 @@ const Subagents: React.FC = () => {
|
||||
setConfigRole(draft.role || '');
|
||||
setConfigDisplayName(draft.display_name || '');
|
||||
setConfigSystemPrompt(draft.system_prompt || '');
|
||||
setConfigSystemPromptFile(draft.system_prompt_file || '');
|
||||
setConfigToolAllowlist(Array.isArray(draft.tool_allowlist) ? draft.tool_allowlist.join(', ') : '');
|
||||
setConfigRoutingKeywords(Array.isArray(draft.routing_keywords) ? draft.routing_keywords.join(', ') : '');
|
||||
};
|
||||
@@ -353,7 +373,7 @@ const Subagents: React.FC = () => {
|
||||
setConfigRole(item.role || '');
|
||||
setConfigDisplayName(item.display_name || '');
|
||||
setConfigSystemPrompt(item.system_prompt || '');
|
||||
setConfigSystemPromptFile((item as any).system_prompt_file || '');
|
||||
setConfigSystemPromptFile(item.system_prompt_file || '');
|
||||
setConfigToolAllowlist(Array.isArray(item.tool_allowlist) ? item.tool_allowlist.join(', ') : '');
|
||||
setConfigRoutingKeywords(Array.isArray(item.routing_keywords) ? item.routing_keywords.join(', ') : '');
|
||||
};
|
||||
@@ -379,6 +399,43 @@ const Subagents: React.FC = () => {
|
||||
await load();
|
||||
};
|
||||
|
||||
const savePromptFile = async () => {
|
||||
if (!configSystemPromptFile.trim()) {
|
||||
await ui.notify({ title: t('requestFailed'), message: 'system_prompt_file is required' });
|
||||
return;
|
||||
}
|
||||
const data = await callAction({
|
||||
action: 'prompt_file_set',
|
||||
path: configSystemPromptFile,
|
||||
content: promptFileContent,
|
||||
});
|
||||
if (!data) return;
|
||||
setPromptFileFound(true);
|
||||
await ui.notify({ title: t('saved'), message: t('promptFileSaved') });
|
||||
await load();
|
||||
};
|
||||
|
||||
const bootstrapPromptFile = async () => {
|
||||
if (!configAgentID.trim()) {
|
||||
await ui.notify({ title: t('requestFailed'), message: 'agent_id is required' });
|
||||
return;
|
||||
}
|
||||
const data = await callAction({
|
||||
action: 'prompt_file_bootstrap',
|
||||
agent_id: configAgentID,
|
||||
role: configRole,
|
||||
display_name: configDisplayName,
|
||||
path: configSystemPromptFile,
|
||||
});
|
||||
if (!data) return;
|
||||
const path = data?.result?.path || configSystemPromptFile || `agents/${configAgentID}/AGENT.md`;
|
||||
setConfigSystemPromptFile(path);
|
||||
setPromptFileFound(true);
|
||||
setPromptFileContent(data?.result?.content || '');
|
||||
await ui.notify({ title: t('saved'), message: t('promptFileBootstrapped') });
|
||||
await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full p-4 md:p-6 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -470,6 +527,8 @@ const Subagents: React.FC = () => {
|
||||
<div key={item.agent_id || 'unknown'} className="px-3 py-2 border-b last:border-b-0 border-zinc-800/60 text-xs space-y-2">
|
||||
<div className="text-zinc-100">{item.agent_id || '-'} · {item.role || '-'} · {item.enabled ? t('active') : t('paused')}</div>
|
||||
<div className="text-zinc-400">{item.type || '-'} · {item.display_name || '-'}</div>
|
||||
<div className="text-zinc-500 break-words">{item.system_prompt_file || '-'}</div>
|
||||
<div className="text-zinc-500">{item.prompt_file_found ? t('promptFileReady') : t('promptFileMissing')}</div>
|
||||
<div className="text-zinc-300 whitespace-pre-wrap break-words">{item.system_prompt || item.description || '-'}</div>
|
||||
<div className="text-zinc-500 break-words">{(item.routing_keywords || []).join(', ') || '-'}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -513,6 +572,29 @@ const Subagents: React.FC = () => {
|
||||
<button onClick={upsertConfigSubagent} className="px-3 py-1.5 text-xs rounded bg-amber-700/80 hover:bg-amber-600">{t('saveToConfig')}</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-zinc-800 rounded-xl bg-zinc-900/40 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('promptFileEditor')}</div>
|
||||
<div className="text-[11px] text-zinc-500">{promptFileFound ? t('promptFileReady') : t('promptFileMissing')}</div>
|
||||
</div>
|
||||
<input
|
||||
value={configSystemPromptFile}
|
||||
onChange={(e) => setConfigSystemPromptFile(e.target.value)}
|
||||
placeholder="agents/<agent_id>/AGENT.md"
|
||||
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded"
|
||||
/>
|
||||
<textarea
|
||||
value={promptFileContent}
|
||||
onChange={(e) => setPromptFileContent(e.target.value)}
|
||||
placeholder={t('promptFileEditorPlaceholder')}
|
||||
className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded min-h-[220px]"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={bootstrapPromptFile} className="px-3 py-1.5 text-xs rounded bg-zinc-700 hover:bg-zinc-600">{t('bootstrapPromptFile')}</button>
|
||||
<button onClick={savePromptFile} className="px-3 py-1.5 text-xs rounded bg-emerald-700/80 hover:bg-emerald-600">{t('savePromptFile')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-zinc-800 rounded-xl bg-zinc-900/40 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('pendingSubagentDrafts')}</div>
|
||||
|
||||
Reference in New Issue
Block a user