From 5e421bb73054f5ae0b60e70907cd168d2c08ff6e Mon Sep 17 00:00:00 2001 From: lpf Date: Fri, 6 Mar 2026 14:12:01 +0800 Subject: [PATCH] Tighten subagent prompt file workflow --- agents/coder/AGENT.md | 19 +++ agents/main/AGENT.md | 19 +++ agents/tester/AGENT.md | 19 +++ pkg/agent/router_dispatch.go | 4 + pkg/agent/router_dispatch_test.go | 24 +++- pkg/agent/runtime_admin.go | 169 +++++++++++++++++++++++ pkg/agent/runtime_admin_test.go | 144 ++++++++++++++++--- pkg/agent/subagent_config_intent_test.go | 14 +- pkg/config/validate.go | 3 + pkg/config/validate_test.go | 39 ++++-- pkg/tools/subagent_config_manager.go | 17 +++ pkg/tools/subagent_config_tool_test.go | 24 ++-- pkg/tools/subagent_profile_test.go | 5 +- webui/src/i18n/index.ts | 16 +++ webui/src/pages/Subagents.tsx | 84 ++++++++++- 15 files changed, 542 insertions(+), 58 deletions(-) create mode 100644 agents/coder/AGENT.md create mode 100644 agents/main/AGENT.md create mode 100644 agents/tester/AGENT.md diff --git a/agents/coder/AGENT.md b/agents/coder/AGENT.md new file mode 100644 index 0000000..6fe2a22 --- /dev/null +++ b/agents/coder/AGENT.md @@ -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. diff --git a/agents/main/AGENT.md b/agents/main/AGENT.md new file mode 100644 index 0000000..01e9a69 --- /dev/null +++ b/agents/main/AGENT.md @@ -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.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. diff --git a/agents/tester/AGENT.md b/agents/tester/AGENT.md new file mode 100644 index 0000000..0185c28 --- /dev/null +++ b/agents/tester/AGENT.md @@ -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. diff --git a/pkg/agent/router_dispatch.go b/pkg/agent/router_dispatch.go index 493f5e8..76d58c7 100644 --- a/pkg/agent/router_dispatch.go +++ b/pkg/agent/router_dispatch.go @@ -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 { diff --git a/pkg/agent/router_dispatch_test.go b/pkg/agent/router_dispatch_test.go index 61f3243..12c7539 100644 --- a/pkg/agent/router_dispatch_test.go +++ b/pkg/agent/router_dispatch_test.go @@ -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) + } +} diff --git a/pkg/agent/runtime_admin.go b/pkg/agent/runtime_admin.go index d72c419..ef8cf1d 100644 --- a/pkg/agent/runtime_admin.go +++ b/pkg/agent/runtime_admin.go @@ -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)) +} diff --git a/pkg/agent/runtime_admin_test.go b/pkg/agent/runtime_admin_test.go index 257bf5f..22f6075 100644 --- a/pkg/agent/runtime_admin_test.go +++ b/pkg/agent/runtime_admin_test.go @@ -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") + } +} diff --git a/pkg/agent/subagent_config_intent_test.go b/pkg/agent/subagent_config_intent_test.go index b760b9d..baec572 100644 --- a/pkg/agent/subagent_config_intent_test.go +++ b/pkg/agent/subagent_config_intent_test.go @@ -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) diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 65b2c8b..fed0805 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -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)) diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index 4958bf6..40a5530 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -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") + } +} diff --git a/pkg/tools/subagent_config_manager.go b/pkg/tools/subagent_config_manager.go index f337fde..d4004b6 100644 --- a/pkg/tools/subagent_config_manager.go +++ b/pkg/tools/subagent_config_manager.go @@ -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 } diff --git a/pkg/tools/subagent_config_tool_test.go b/pkg/tools/subagent_config_tool_test.go index 68c6424..80a5e2b 100644 --- a/pkg/tools/subagent_config_tool_test.go +++ b/pkg/tools/subagent_config_tool_test.go @@ -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) diff --git a/pkg/tools/subagent_profile_test.go b/pkg/tools/subagent_profile_test.go index f67fd10..792fd2e 100644 --- a/pkg/tools/subagent_profile_test.go +++ b/pkg/tools/subagent_profile_test.go @@ -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) diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 9d5e3be..dea9dc5 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -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: '收件箱', diff --git a/webui/src/pages/Subagents.tsx b/webui/src/pages/Subagents.tsx index f6cad0e..2580532 100644 --- a/webui/src/pages/Subagents.tsx +++ b/webui/src/pages/Subagents.tsx @@ -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([]); const [registryItems, setRegistryItems] = useState([]); + 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 (
@@ -470,6 +527,8 @@ const Subagents: React.FC = () => {
{item.agent_id || '-'} · {item.role || '-'} · {item.enabled ? t('active') : t('paused')}
{item.type || '-'} · {item.display_name || '-'}
+
{item.system_prompt_file || '-'}
+
{item.prompt_file_found ? t('promptFileReady') : t('promptFileMissing')}
{item.system_prompt || item.description || '-'}
{(item.routing_keywords || []).join(', ') || '-'}
@@ -513,6 +572,29 @@ const Subagents: React.FC = () => {
+
+
+
{t('promptFileEditor')}
+
{promptFileFound ? t('promptFileReady') : t('promptFileMissing')}
+
+ setConfigSystemPromptFile(e.target.value)} + placeholder="agents//AGENT.md" + className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" + /> +