Tighten subagent prompt file workflow

This commit is contained in:
lpf
2026-03-06 14:12:01 +08:00
parent 49352612ea
commit 5e421bb730
15 changed files with 542 additions and 58 deletions

19
agents/coder/AGENT.md Normal file
View 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
View 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
View 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.

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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))
}

View File

@@ -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")
}
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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: '收件箱',

View File

@@ -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>