diff --git a/cmd/clawgo/cmd_agent.go b/cmd/clawgo/cmd_agent.go index 5920a97..d02c78d 100644 --- a/cmd/clawgo/cmd_agent.go +++ b/cmd/clawgo/cmd_agent.go @@ -60,6 +60,7 @@ func agentCmd() { configureCronServiceRuntime(cronService, cfg) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, cronService) + agentLoop.SetConfigPath(getConfigPath()) startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("agent", logger.C0098, diff --git a/cmd/clawgo/cmd_gateway.go b/cmd/clawgo/cmd_gateway.go index 66b6a4e..d54ab4f 100644 --- a/cmd/clawgo/cmd_gateway.go +++ b/cmd/clawgo/cmd_gateway.go @@ -665,6 +665,7 @@ func buildGatewayRuntime(ctx context.Context, cfg *config.Config, msgBus *bus.Me } agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, cronService) + agentLoop.SetConfigPath(getConfigPath()) startupInfo := agentLoop.GetStartupInfo() toolsInfo := startupInfo["tools"].(map[string]interface{}) diff --git a/config.example.json b/config.example.json index e6fe4f5..975d6fe 100644 --- a/config.example.json +++ b/config.example.json @@ -38,6 +38,101 @@ "outcomes_title": "Execution Outcomes" } } + }, + "router": { + "enabled": true, + "main_agent_id": "main", + "strategy": "rules_first", + "rules": [ + { + "agent_id": "coder", + "keywords": ["代码", "实现", "修复", "重构", "bug", "debug", "implement", "refactor"] + }, + { + "agent_id": "tester", + "keywords": ["测试", "回归", "验证", "test", "regression", "verify"] + } + ], + "allow_direct_agent_chat": false, + "max_hops": 6, + "default_timeout_sec": 600, + "default_wait_reply": true, + "sticky_thread_owner": true + }, + "communication": { + "mode": "mediated", + "persist_threads": true, + "persist_messages": true, + "max_messages_per_thread": 100, + "dead_letter_queue": true, + "default_message_ttl_sec": 86400 + }, + "subagents": { + "main": { + "enabled": true, + "type": "router", + "display_name": "Main Agent", + "role": "orchestrator", + "system_prompt": "你负责消息路由、任务拆解、仲裁与结果汇总。", + "memory_namespace": "main", + "accept_from": ["user", "coder", "tester"], + "can_talk_to": ["coder", "tester"], + "tools": { + "allowlist": ["sessions", "subagents", "memory_search", "repo_map"] + }, + "runtime": { + "proxy": "proxy", + "temperature": 0.2, + "timeout_sec": 900, + "max_retries": 1, + "retry_backoff_ms": 1000, + "max_parallel_runs": 4 + } + }, + "coder": { + "enabled": true, + "type": "worker", + "display_name": "Code Agent", + "role": "code", + "system_prompt": "你负责代码实现与重构,输出具体修改建议和变更结果。", + "memory_namespace": "coder", + "accept_from": ["main", "tester"], + "can_talk_to": ["main", "tester"], + "tools": { + "allowlist": ["filesystem", "shell", "repo_map", "sessions"] + }, + "runtime": { + "proxy": "proxy", + "temperature": 0.2, + "timeout_sec": 1200, + "max_retries": 1, + "retry_backoff_ms": 1000, + "max_task_chars": 20000, + "max_result_chars": 12000, + "max_parallel_runs": 2 + } + }, + "tester": { + "enabled": true, + "type": "worker", + "display_name": "Test Agent", + "role": "test", + "system_prompt": "你负责测试、验证、回归检查与风险反馈。", + "memory_namespace": "tester", + "accept_from": ["main", "coder"], + "can_talk_to": ["main", "coder"], + "tools": { + "allowlist": ["shell", "filesystem", "process_manager", "sessions"] + }, + "runtime": { + "proxy": "proxy", + "temperature": 0.1, + "timeout_sec": 1200, + "max_retries": 1, + "retry_backoff_ms": 1000, + "max_parallel_runs": 2 + } + } } }, "channels": { diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 8cc2fea..caefc87 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -88,6 +88,7 @@ func (cb *ContextBuilder) buildToolsSection() string { sb.WriteString(s) sb.WriteString("\n") } + sb.WriteString("\nWhen creating a new subagent or changing config.json agent definitions, draft first and wait for explicit user confirmation before persisting changes.\n") return sb.String() } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bd4ad1f..a73a93a 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -61,6 +61,21 @@ type AgentLoop struct { sessionStreamed map[string]bool subagentManager *tools.SubagentManager orchestrator *tools.Orchestrator + subagentRouter *tools.SubagentRouter + subagentConfigTool *tools.SubagentConfigTool + pendingSubagentDraft map[string]map[string]interface{} + pendingDraftStore *PendingSubagentDraftStore + configPath string +} + +func (al *AgentLoop) SetConfigPath(path string) { + if al == nil { + return + } + al.configPath = strings.TrimSpace(path) + if al.subagentConfigTool != nil { + al.subagentConfigTool.SetConfigPath(al.configPath) + } } // StartupCompactionReport provides startup memory/session maintenance stats. @@ -171,9 +186,13 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers // Register spawn tool orchestrator := tools.NewOrchestrator() subagentManager := tools.NewSubagentManager(provider, workspace, msgBus, orchestrator) + subagentRouter := tools.NewSubagentRouter(subagentManager) + subagentConfigTool := tools.NewSubagentConfigTool("") + pendingDraftStore := NewPendingSubagentDraftStore(workspace) spawnTool := tools.NewSpawnTool(subagentManager) toolsRegistry.Register(spawnTool) toolsRegistry.Register(tools.NewSubagentsTool(subagentManager)) + toolsRegistry.Register(subagentConfigTool) if store := subagentManager.ProfileStore(); store != nil { toolsRegistry.Register(tools.NewSubagentProfileTool(store)) } @@ -240,6 +259,13 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers telegramStreaming: cfg.Channels.Telegram.Streaming, subagentManager: subagentManager, orchestrator: orchestrator, + subagentRouter: subagentRouter, + subagentConfigTool: subagentConfigTool, + pendingSubagentDraft: map[string]map[string]interface{}{}, + pendingDraftStore: pendingDraftStore, + } + if pendingDraftStore != nil { + loop.pendingSubagentDraft = pendingDraftStore.All() } // Initialize provider fallback chain (primary + proxy_fallbacks). loop.providerPool = map[string]providers.LLMProvider{} @@ -770,6 +796,12 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) if msg.Channel == "system" { return al.processSystemMessage(ctx, msg) } + if configAction, handled, configErr := al.maybeHandleSubagentConfigIntent(ctx, msg); handled { + return configAction, configErr + } + if routed, ok, routeErr := al.maybeAutoRoute(ctx, msg); ok { + return routed, routeErr + } history := al.sessions.GetHistory(msg.SessionKey) summary := al.sessions.GetSummary(msg.SessionKey) diff --git a/pkg/agent/router_dispatch.go b/pkg/agent/router_dispatch.go new file mode 100644 index 0000000..493f5e8 --- /dev/null +++ b/pkg/agent/router_dispatch.go @@ -0,0 +1,220 @@ +package agent + +import ( + "context" + "strings" + "time" + + "clawgo/pkg/bus" + "clawgo/pkg/config" + "clawgo/pkg/runtimecfg" + "clawgo/pkg/tools" +) + +func (al *AgentLoop) maybeAutoRoute(ctx context.Context, msg bus.InboundMessage) (string, bool, error) { + if al == nil || al.subagentRouter == nil { + return "", false, nil + } + if msg.Channel == "system" || msg.Channel == "internal" { + return "", false, nil + } + if msg.Metadata != nil { + if trigger := strings.ToLower(strings.TrimSpace(msg.Metadata["trigger"])); trigger != "" && trigger != "user" { + return "", false, nil + } + } + cfg := runtimecfg.Get() + if cfg == nil || !cfg.Agents.Router.Enabled { + return "", false, nil + } + agentID, taskText := resolveAutoRouteTarget(cfg, msg.Content) + if agentID == "" || strings.TrimSpace(taskText) == "" { + return "", false, nil + } + waitTimeout := cfg.Agents.Router.DefaultTimeoutSec + if waitTimeout <= 0 { + waitTimeout = 120 + } + waitCtx, cancel := context.WithTimeout(ctx, time.Duration(waitTimeout)*time.Second) + defer cancel() + task, err := al.subagentRouter.DispatchTask(waitCtx, tools.RouterDispatchRequest{ + Task: taskText, + AgentID: agentID, + OriginChannel: msg.Channel, + OriginChatID: msg.ChatID, + }) + if err != nil { + return "", true, err + } + reply, err := al.subagentRouter.WaitReply(waitCtx, task.ID, 100*time.Millisecond) + if err != nil { + return "", true, err + } + return al.subagentRouter.MergeResults([]*tools.RouterReply{reply}), true, nil +} + +func resolveAutoRouteTarget(cfg *config.Config, raw string) (string, string) { + if cfg == nil { + return "", "" + } + content := strings.TrimSpace(raw) + if content == "" || len(cfg.Agents.Subagents) == 0 { + return "", "" + } + lower := strings.ToLower(content) + for agentID, subcfg := range cfg.Agents.Subagents { + if !subcfg.Enabled { + continue + } + marker := "@" + strings.ToLower(strings.TrimSpace(agentID)) + if strings.HasPrefix(lower, marker+" ") || lower == marker { + return agentID, strings.TrimSpace(content[len(marker):]) + } + prefix := "agent:" + strings.ToLower(strings.TrimSpace(agentID)) + if strings.HasPrefix(lower, prefix+" ") || lower == prefix { + return agentID, strings.TrimSpace(content[len(prefix):]) + } + } + if strings.EqualFold(strings.TrimSpace(cfg.Agents.Router.Strategy), "rules_first") { + if agentID := selectAgentByRules(cfg, content); agentID != "" { + return agentID, content + } + } + return "", "" +} + +func selectAgentByRules(cfg *config.Config, content string) string { + if cfg == nil { + return "" + } + content = strings.TrimSpace(content) + if content == "" { + return "" + } + lower := strings.ToLower(content) + bestID := "" + bestScore := 0 + tied := false + if agentID := selectAgentByConfiguredRules(cfg, lower); agentID != "" { + return agentID + } + for agentID, subcfg := range cfg.Agents.Subagents { + if !subcfg.Enabled { + continue + } + score := scoreRouteCandidate(agentID, subcfg, lower) + if score <= 0 { + continue + } + if score > bestScore { + bestID = agentID + bestScore = score + tied = false + continue + } + if score == bestScore { + tied = true + } + } + if tied || bestScore < 2 { + return "" + } + return bestID +} + +func selectAgentByConfiguredRules(cfg *config.Config, content string) string { + if cfg == nil { + return "" + } + bestID := "" + bestScore := 0 + tied := false + for _, rule := range cfg.Agents.Router.Rules { + agentID := strings.TrimSpace(rule.AgentID) + if agentID == "" { + continue + } + subcfg, ok := cfg.Agents.Subagents[agentID] + if !ok || !subcfg.Enabled { + continue + } + score := 0 + for _, kw := range rule.Keywords { + kw = strings.ToLower(strings.TrimSpace(kw)) + if kw != "" && strings.Contains(content, kw) { + score++ + } + } + if score <= 0 { + continue + } + if score > bestScore { + bestID = agentID + bestScore = score + tied = false + continue + } + if score == bestScore { + tied = true + } + } + if tied || bestScore < 1 { + return "" + } + return bestID +} + +func scoreRouteCandidate(agentID string, subcfg config.SubagentConfig, content string) int { + score := 0 + for _, token := range routeKeywords(agentID, subcfg) { + token = strings.ToLower(strings.TrimSpace(token)) + if token == "" { + continue + } + if strings.Contains(content, token) { + score++ + } + } + return score +} + +func routeKeywords(agentID string, subcfg config.SubagentConfig) []string { + set := map[string]struct{}{} + add := func(items ...string) { + for _, item := range items { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } + set[item] = struct{}{} + } + } + add(agentID, subcfg.Role, subcfg.DisplayName, subcfg.Type) + role := strings.ToLower(strings.TrimSpace(subcfg.Role)) + switch role { + case "code", "coding", "coder", "dev", "developer": + add("code", "coding", "implement", "refactor", "fix bug", "bugfix", "debug", "写代码", "实现", "重构", "修复", "改代码") + case "test", "tester", "testing", "qa": + add("test", "testing", "regression", "verify", "validate", "qa", "回归", "测试", "验证", "检查") + case "docs", "doc", "writer", "documentation": + add("docs", "documentation", "write docs", "document", "readme", "文档", "说明", "README") + case "research", "researcher": + add("research", "investigate", "analyze", "compare", "调研", "分析", "研究", "比较") + } + agentLower := strings.ToLower(strings.TrimSpace(agentID)) + switch agentLower { + case "coder": + add("code", "implement", "fix", "debug", "写代码", "实现", "修复") + case "tester": + add("test", "regression", "verify", "回归", "测试", "验证") + case "researcher": + add("research", "analyze", "investigate", "调研", "分析") + case "doc_writer", "writer", "docs": + add("docs", "readme", "document", "文档", "说明") + } + out := make([]string, 0, len(set)) + for item := range set { + out = append(out, item) + } + return out +} diff --git a/pkg/agent/router_dispatch_test.go b/pkg/agent/router_dispatch_test.go new file mode 100644 index 0000000..61f3243 --- /dev/null +++ b/pkg/agent/router_dispatch_test.go @@ -0,0 +1,127 @@ +package agent + +import ( + "context" + "testing" + + "clawgo/pkg/bus" + "clawgo/pkg/config" + "clawgo/pkg/runtimecfg" + "clawgo/pkg/tools" +) + +func TestResolveAutoRouteTarget(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true} + + agentID, task := resolveAutoRouteTarget(cfg, "@coder fix login") + if agentID != "coder" || task != "fix login" { + t.Fatalf("unexpected route target: %s / %s", agentID, task) + } +} + +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.Router.Rules = []config.AgentRouteRule{{AgentID: "coder", Keywords: []string{"登录", "bug"}}} + + agentID, task := resolveAutoRouteTarget(cfg, "请帮我修复登录接口的 bug 并改代码") + if agentID != "coder" || task == "" { + t.Fatalf("expected coder route, got %s / %s", agentID, task) + } +} + +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} + runtimecfg.Set(cfg) + t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) + + workspace := t.TempDir() + manager := tools.NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { + return "auto-routed", nil + }) + loop := &AgentLoop{ + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + out, ok, err := loop.maybeAutoRoute(context.Background(), bus.InboundMessage{ + Channel: "cli", + ChatID: "direct", + SessionKey: "main", + Content: "@coder implement auth", + }) + if err != nil { + t.Fatalf("auto route failed: %v", err) + } + if !ok { + t.Fatalf("expected auto route to trigger") + } + if out == "" { + t.Fatalf("expected merged output") + } +} + +func TestMaybeAutoRouteSkipsNormalMessages(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["coder"] = config.SubagentConfig{Enabled: true} + runtimecfg.Set(cfg) + t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) + + loop := &AgentLoop{} + out, ok, err := loop.maybeAutoRoute(context.Background(), bus.InboundMessage{ + Channel: "cli", + ChatID: "direct", + SessionKey: "main", + Content: "please help with auth", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok || out != "" { + t.Fatalf("expected normal message to skip auto route, got ok=%v out=%q", ok, out) + } +} + +func TestMaybeAutoRouteDispatchesRulesFirstMatch(t *testing.T) { + cfg := config.DefaultConfig() + 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"} + runtimecfg.Set(cfg) + t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) + + workspace := t.TempDir() + manager := tools.NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { + return "tested", nil + }) + loop := &AgentLoop{ + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + out, ok, err := loop.maybeAutoRoute(context.Background(), bus.InboundMessage{ + Channel: "cli", + ChatID: "direct", + SessionKey: "main", + Content: "请做一次回归测试并验证这个修复", + }) + if err != nil { + t.Fatalf("rules-first auto route failed: %v", err) + } + if !ok { + t.Fatalf("expected rules-first auto route to trigger") + } + if out == "" { + t.Fatalf("expected merged output") + } +} diff --git a/pkg/agent/runtime_admin.go b/pkg/agent/runtime_admin.go index b5937d4..cc03239 100644 --- a/pkg/agent/runtime_admin.go +++ b/pkg/agent/runtime_admin.go @@ -6,7 +6,10 @@ import ( "sort" "strconv" "strings" + "time" + "clawgo/pkg/config" + "clawgo/pkg/runtimecfg" "clawgo/pkg/tools" ) @@ -14,12 +17,16 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a if al == nil || al.subagentManager == nil { return nil, fmt.Errorf("subagent runtime is not configured") } + if al.subagentRouter == nil { + return nil, fmt.Errorf("subagent router is not configured") + } action = strings.ToLower(strings.TrimSpace(action)) if action == "" { action = "list" } sm := al.subagentManager + router := al.subagentRouter switch action { case "list": tasks := sm.ListTasks() @@ -63,6 +70,128 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a return nil, err } return map[string]interface{}{"message": msg}, nil + case "dispatch_and_wait": + taskInput := runtimeStringArg(args, "task") + if taskInput == "" { + return nil, fmt.Errorf("task is required") + } + task, err := router.DispatchTask(ctx, tools.RouterDispatchRequest{ + Task: taskInput, + Label: runtimeStringArg(args, "label"), + Role: runtimeStringArg(args, "role"), + AgentID: runtimeStringArg(args, "agent_id"), + ThreadID: runtimeStringArg(args, "thread_id"), + CorrelationID: runtimeStringArg(args, "correlation_id"), + ParentRunID: runtimeStringArg(args, "parent_run_id"), + OriginChannel: fallbackString(runtimeStringArg(args, "channel"), "webui"), + OriginChatID: fallbackString(runtimeStringArg(args, "chat_id"), "webui"), + MaxRetries: runtimeIntArg(args, "max_retries", 0), + RetryBackoff: runtimeIntArg(args, "retry_backoff_ms", 0), + TimeoutSec: runtimeIntArg(args, "timeout_sec", 0), + MaxTaskChars: runtimeIntArg(args, "max_task_chars", 0), + MaxResultChars: runtimeIntArg(args, "max_result_chars", 0), + }) + if err != nil { + return nil, err + } + waitTimeoutSec := runtimeIntArg(args, "wait_timeout_sec", 120) + waitCtx := ctx + var cancel context.CancelFunc + if waitTimeoutSec > 0 { + waitCtx, cancel = context.WithTimeout(ctx, time.Duration(waitTimeoutSec)*time.Second) + defer cancel() + } + reply, err := router.WaitReply(waitCtx, task.ID, 100*time.Millisecond) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "task": cloneSubagentTask(task), + "reply": reply, + "merged": router.MergeResults([]*tools.RouterReply{reply}), + }, nil + case "draft_config_subagent": + description := runtimeStringArg(args, "description") + if description == "" { + return nil, fmt.Errorf("description is required") + } + draft := tools.DraftConfigSubagent(description, runtimeStringArg(args, "agent_id_hint")) + return map[string]interface{}{"draft": draft}, nil + case "registry": + cfg := runtimecfg.Get() + if cfg == nil { + return map[string]interface{}{"items": []map[string]interface{}{}}, nil + } + items := make([]map[string]interface{}, 0, len(cfg.Agents.Subagents)) + for agentID, subcfg := range cfg.Agents.Subagents { + items = append(items, map[string]interface{}{ + "agent_id": agentID, + "enabled": subcfg.Enabled, + "type": subcfg.Type, + "display_name": subcfg.DisplayName, + "role": subcfg.Role, + "description": subcfg.Description, + "system_prompt": subcfg.SystemPrompt, + "memory_namespace": subcfg.MemoryNamespace, + "tool_allowlist": append([]string(nil), subcfg.Tools.Allowlist...), + "routing_keywords": routeKeywordsForRegistry(cfg.Agents.Router.Rules, agentID), + }) + } + sort.Slice(items, func(i, j int) bool { + left, _ := items[i]["agent_id"].(string) + right, _ := items[j]["agent_id"].(string) + return left < right + }) + return map[string]interface{}{"items": items}, nil + case "pending_drafts": + items := make([]map[string]interface{}, 0, len(al.pendingSubagentDraft)) + for sessionKey, draft := range al.pendingSubagentDraft { + items = append(items, map[string]interface{}{ + "session_key": sessionKey, + "draft": cloneDraftMap(draft), + }) + } + sort.Slice(items, func(i, j int) bool { + left, _ := items[i]["session_key"].(string) + right, _ := items[j]["session_key"].(string) + return left < right + }) + return map[string]interface{}{"items": items}, nil + case "clear_pending_draft": + sessionKey := fallbackString(runtimeStringArg(args, "session_key"), "main") + if al.loadPendingSubagentDraft(sessionKey) == nil { + return map[string]interface{}{"ok": false, "found": false}, nil + } + al.deletePendingSubagentDraft(sessionKey) + return map[string]interface{}{"ok": true, "found": true, "session_key": sessionKey}, nil + case "confirm_pending_draft": + sessionKey := fallbackString(runtimeStringArg(args, "session_key"), "main") + msg, handled, err := al.confirmPendingSubagentDraft(sessionKey) + if err != nil { + return nil, err + } + return map[string]interface{}{"ok": handled, "found": handled, "session_key": sessionKey, "message": msg}, nil + case "set_config_subagent_enabled": + agentID := runtimeStringArg(args, "agent_id") + if agentID == "" { + return nil, fmt.Errorf("agent_id is required") + } + enabled, ok := args["enabled"].(bool) + if !ok { + return nil, fmt.Errorf("enabled is required") + } + return tools.UpsertConfigSubagent(al.configPath, map[string]interface{}{ + "agent_id": agentID, + "enabled": enabled, + }) + case "delete_config_subagent": + agentID := runtimeStringArg(args, "agent_id") + if agentID == "" { + return nil, fmt.Errorf("agent_id is required") + } + return tools.DeleteConfigSubagent(al.configPath, agentID) + case "upsert_config_subagent": + return tools.UpsertConfigSubagent(al.configPath, args) case "kill": taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) if err != nil { @@ -77,7 +206,7 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a } label, ok := sm.ResumeTask(ctx, taskID) return map[string]interface{}{"ok": ok, "label": label}, nil - case "steer", "send": + case "steer": taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) if err != nil { return nil, err @@ -88,6 +217,85 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a } ok := sm.SteerTask(taskID, msg) return map[string]interface{}{"ok": ok}, nil + case "send": + taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + msg := runtimeStringArg(args, "message") + if msg == "" { + return nil, fmt.Errorf("message is required") + } + ok := sm.SendTaskMessage(taskID, msg) + return map[string]interface{}{"ok": ok}, nil + case "reply": + taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + msg := runtimeStringArg(args, "message") + if msg == "" { + return nil, fmt.Errorf("message is required") + } + ok := sm.ReplyToTask(taskID, runtimeStringArg(args, "message_id"), msg) + return map[string]interface{}{"ok": ok}, nil + case "ack": + taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + messageID := runtimeStringArg(args, "message_id") + if messageID == "" { + return nil, fmt.Errorf("message_id is required") + } + ok := sm.AckTaskMessage(taskID, messageID) + return map[string]interface{}{"ok": ok}, nil + case "thread", "trace": + threadID := runtimeStringArg(args, "thread_id") + if threadID == "" { + taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + task, ok := sm.GetTask(taskID) + if !ok { + return map[string]interface{}{"found": false}, nil + } + threadID = strings.TrimSpace(task.ThreadID) + } + if threadID == "" { + return nil, fmt.Errorf("thread_id is required") + } + thread, ok := sm.Thread(threadID) + if !ok { + return map[string]interface{}{"found": false}, nil + } + items, err := sm.ThreadMessages(threadID, runtimeIntArg(args, "limit", 50)) + if err != nil { + return nil, err + } + return map[string]interface{}{"found": true, "thread": thread, "messages": items}, nil + case "inbox": + agentID := runtimeStringArg(args, "agent_id") + if agentID == "" { + taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + task, ok := sm.GetTask(taskID) + if !ok { + return map[string]interface{}{"found": false}, nil + } + agentID = strings.TrimSpace(task.AgentID) + } + if agentID == "" { + return nil, fmt.Errorf("agent_id is required") + } + items, err := sm.Inbox(agentID, runtimeIntArg(args, "limit", 50)) + if err != nil { + return nil, err + } + return map[string]interface{}{"found": true, "agent_id": agentID, "messages": items}, nil default: return nil, fmt.Errorf("unsupported action: %s", action) } @@ -294,3 +502,13 @@ func fallbackString(v, fallback string) string { } return strings.TrimSpace(v) } + +func routeKeywordsForRegistry(rules []config.AgentRouteRule, agentID string) []string { + agentID = strings.TrimSpace(agentID) + for _, rule := range rules { + if strings.TrimSpace(rule.AgentID) == agentID { + return append([]string(nil), rule.Keywords...) + } + } + return nil +} diff --git a/pkg/agent/runtime_admin_test.go b/pkg/agent/runtime_admin_test.go new file mode 100644 index 0000000..115514a --- /dev/null +++ b/pkg/agent/runtime_admin_test.go @@ -0,0 +1,314 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "testing" + + "clawgo/pkg/config" + "clawgo/pkg/runtimecfg" + "clawgo/pkg/tools" +) + +func TestHandleSubagentRuntimeDispatchAndWait(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { + return "runtime-admin-result", nil + }) + loop := &AgentLoop{ + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + + out, err := loop.HandleSubagentRuntime(context.Background(), "dispatch_and_wait", map[string]interface{}{ + "task": "implement runtime action", + "agent_id": "coder", + "channel": "webui", + "chat_id": "webui", + "wait_timeout_sec": float64(5), + }) + if err != nil { + t.Fatalf("dispatch_and_wait failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("unexpected payload type: %T", out) + } + reply, ok := payload["reply"].(*tools.RouterReply) + if !ok { + t.Fatalf("expected router reply, got %T", payload["reply"]) + } + if reply.Status != "completed" || reply.Result != "runtime-admin-result" { + t.Fatalf("unexpected reply: %+v", reply) + } + merged, _ := payload["merged"].(string) + if merged == "" { + t.Fatalf("expected merged output") + } +} + +func TestHandleSubagentRuntimeUpsertConfigSubagent(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + } + 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, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + out, err := loop.HandleSubagentRuntime(context.Background(), "upsert_config_subagent", map[string]interface{}{ + "agent_id": "reviewer", + "role": "testing", + "display_name": "Review Agent", + "system_prompt": "review changes", + "routing_keywords": []interface{}{"review", "regression"}, + "tool_allowlist": []interface{}{"shell", "sessions"}, + }) + if err != nil { + t.Fatalf("upsert config subagent failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok || payload["ok"] != true { + t.Fatalf("unexpected payload: %#v", out) + } + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + subcfg, ok := reloaded.Agents.Subagents["reviewer"] + if !ok || subcfg.DisplayName != "Review Agent" { + t.Fatalf("expected reviewer subagent in config, got %+v", reloaded.Agents.Subagents) + } + if len(reloaded.Agents.Router.Rules) == 0 { + t.Fatalf("expected router rules to be persisted") + } + data, err := os.ReadFile(configPath) + if err != nil || len(data) == 0 { + t.Fatalf("expected config file to be written") + } +} + +func TestHandleSubagentRuntimeDraftConfigSubagent(t *testing.T) { + manager := tools.NewSubagentManager(nil, t.TempDir(), nil, nil) + loop := &AgentLoop{ + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + out, err := loop.HandleSubagentRuntime(context.Background(), "draft_config_subagent", map[string]interface{}{ + "description": "创建一个负责回归测试和验证修复结果的子代理", + }) + if err != nil { + t.Fatalf("draft config subagent failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("unexpected payload type: %T", out) + } + draft, ok := payload["draft"].(map[string]interface{}) + if !ok { + t.Fatalf("expected draft payload, got %#v", payload["draft"]) + } + if draft["role"] == "" || draft["agent_id"] == "" { + t.Fatalf("expected draft role and agent_id, got %#v", draft) + } +} + +func TestHandleSubagentRuntimePendingDraftsAndClear(t *testing.T) { + manager := tools.NewSubagentManager(nil, t.TempDir(), nil, nil) + loop := &AgentLoop{ + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + pendingSubagentDraft: map[string]map[string]interface{}{"main": {"agent_id": "tester", "role": "testing"}}, + } + + out, err := loop.HandleSubagentRuntime(context.Background(), "pending_drafts", nil) + if err != nil { + t.Fatalf("pending drafts failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("unexpected payload type: %T", out) + } + items, ok := payload["items"].([]map[string]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("expected one pending draft, got %#v", payload["items"]) + } + + out, err = loop.HandleSubagentRuntime(context.Background(), "clear_pending_draft", map[string]interface{}{"session_key": "main"}) + if err != nil { + t.Fatalf("clear pending draft failed: %v", err) + } + payload, ok = out.(map[string]interface{}) + if !ok || payload["ok"] != true { + t.Fatalf("unexpected clear payload: %#v", out) + } + if loop.loadPendingSubagentDraft("main") != nil { + t.Fatalf("expected pending draft to be cleared") + } +} + +func TestHandleSubagentRuntimeConfirmPendingDraft(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + } + 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, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + pendingSubagentDraft: map[string]map[string]interface{}{"main": {"agent_id": "tester", "role": "testing", "type": "worker"}}, + } + out, err := loop.HandleSubagentRuntime(context.Background(), "confirm_pending_draft", map[string]interface{}{"session_key": "main"}) + if err != nil { + t.Fatalf("confirm pending draft failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok || payload["ok"] != true { + t.Fatalf("unexpected confirm payload: %#v", out) + } + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + if _, ok := reloaded.Agents.Subagents["tester"]; !ok { + t.Fatalf("expected tester subagent to be persisted") + } +} + +func TestHandleSubagentRuntimeRegistryAndToggleEnabled(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + } + cfg.Agents.Subagents["tester"] = config.SubagentConfig{ + Enabled: true, + Type: "worker", + Role: "testing", + DisplayName: "Test Agent", + SystemPrompt: "run tests", + MemoryNamespace: "tester", + Tools: config.SubagentToolsConfig{ + Allowlist: []string{"shell", "sessions"}, + }, + } + cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "tester", Keywords: []string{"test", "regression"}}} + 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, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + out, err := loop.HandleSubagentRuntime(context.Background(), "registry", nil) + if err != nil { + t.Fatalf("registry failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok { + t.Fatalf("unexpected registry payload: %T", out) + } + items, ok := payload["items"].([]map[string]interface{}) + if !ok || len(items) < 2 { + t.Fatalf("expected registry items, got %#v", payload["items"]) + } + + _, err = loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ + "agent_id": "tester", + "enabled": false, + }) + if err != nil { + t.Fatalf("toggle enabled failed: %v", err) + } + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + if reloaded.Agents.Subagents["tester"].Enabled { + t.Fatalf("expected tester to be disabled") + } +} + +func TestHandleSubagentRuntimeDeleteConfigSubagent(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + } + cfg.Agents.Subagents["tester"] = config.SubagentConfig{ + Enabled: true, + Type: "worker", + Role: "testing", + } + cfg.Agents.Router.Rules = []config.AgentRouteRule{{AgentID: "tester", Keywords: []string{"test"}}} + 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, + subagentManager: manager, + subagentRouter: tools.NewSubagentRouter(manager), + } + out, err := loop.HandleSubagentRuntime(context.Background(), "delete_config_subagent", map[string]interface{}{"agent_id": "tester"}) + if err != nil { + t.Fatalf("delete config subagent failed: %v", err) + } + payload, ok := out.(map[string]interface{}) + if !ok || payload["ok"] != true { + t.Fatalf("unexpected delete payload: %#v", out) + } + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + if _, ok := reloaded.Agents.Subagents["tester"]; ok { + t.Fatalf("expected tester to be removed") + } + if len(reloaded.Agents.Router.Rules) != 0 { + t.Fatalf("expected tester route rule to be removed") + } +} diff --git a/pkg/agent/subagent_config_draft_store.go b/pkg/agent/subagent_config_draft_store.go new file mode 100644 index 0000000..69917fb --- /dev/null +++ b/pkg/agent/subagent_config_draft_store.go @@ -0,0 +1,141 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" +) + +type PendingSubagentDraftStore struct { + path string + mu sync.RWMutex + items map[string]map[string]interface{} +} + +func NewPendingSubagentDraftStore(workspace string) *PendingSubagentDraftStore { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return nil + } + dir := filepath.Join(workspace, "agents", "runtime") + store := &PendingSubagentDraftStore{ + path: filepath.Join(dir, "pending_subagent_drafts.json"), + items: map[string]map[string]interface{}{}, + } + _ = os.MkdirAll(dir, 0755) + _ = store.load() + return store +} + +func (s *PendingSubagentDraftStore) load() error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + s.items = map[string]map[string]interface{}{} + return nil + } + return err + } + if len(strings.TrimSpace(string(data))) == 0 { + s.items = map[string]map[string]interface{}{} + return nil + } + items := map[string]map[string]interface{}{} + if err := json.Unmarshal(data, &items); err != nil { + return err + } + s.items = items + return nil +} + +func (s *PendingSubagentDraftStore) All() map[string]map[string]interface{} { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[string]map[string]interface{}, len(s.items)) + for key, item := range s.items { + out[key] = cloneDraftMap(item) + } + return out +} + +func (s *PendingSubagentDraftStore) Get(sessionKey string) map[string]interface{} { + if s == nil { + return nil + } + sessionKey = strings.TrimSpace(sessionKey) + s.mu.RLock() + defer s.mu.RUnlock() + return cloneDraftMap(s.items[sessionKey]) +} + +func (s *PendingSubagentDraftStore) Put(sessionKey string, draft map[string]interface{}) error { + if s == nil { + return nil + } + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" || draft == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.items == nil { + s.items = map[string]map[string]interface{}{} + } + s.items[sessionKey] = cloneDraftMap(draft) + return s.persistLocked() +} + +func (s *PendingSubagentDraftStore) Delete(sessionKey string) error { + if s == nil { + return nil + } + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + delete(s.items, sessionKey) + return s.persistLocked() +} + +func (s *PendingSubagentDraftStore) persistLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(s.items, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, data, 0644) +} + +func cloneDraftMap(in map[string]interface{}) map[string]interface{} { + if in == nil { + return nil + } + out := make(map[string]interface{}, len(in)) + for k, v := range in { + switch vv := v.(type) { + case []string: + out[k] = append([]string(nil), vv...) + case []interface{}: + cp := make([]interface{}, len(vv)) + copy(cp, vv) + out[k] = cp + default: + out[k] = vv + } + } + return out +} diff --git a/pkg/agent/subagent_config_intent.go b/pkg/agent/subagent_config_intent.go new file mode 100644 index 0000000..4e2c428 --- /dev/null +++ b/pkg/agent/subagent_config_intent.go @@ -0,0 +1,209 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "clawgo/pkg/bus" + "clawgo/pkg/tools" +) + +func (al *AgentLoop) maybeHandleSubagentConfigIntent(ctx context.Context, msg bus.InboundMessage) (string, bool, error) { + _ = ctx + if al == nil { + return "", false, nil + } + if msg.Channel == "system" || msg.Channel == "internal" { + return "", false, nil + } + content := strings.TrimSpace(msg.Content) + if content == "" { + return "", false, nil + } + if isSubagentConfigConfirm(content) { + return al.confirmPendingSubagentDraft(msg.SessionKey) + } + if isSubagentConfigCancel(content) { + return al.cancelPendingSubagentDraft(msg.SessionKey) + } + if !looksLikeSubagentCreateRequest(content) { + return "", false, nil + } + description := extractSubagentDescription(content) + if description == "" { + return "", false, nil + } + draft := tools.DraftConfigSubagent(description, "") + al.storePendingSubagentDraft(msg.SessionKey, draft) + return formatSubagentDraftForUser(draft), true, nil +} + +func looksLikeSubagentCreateRequest(content string) bool { + lower := strings.ToLower(strings.TrimSpace(content)) + if lower == "" { + return false + } + createMarkers := []string{ + "创建", "新建", "增加", "添加", "配置一个", "生成一个", + "create", "add", "new", + } + subagentMarkers := []string{ + "subagent", "sub-agent", "agent", "子代理", "子 agent", "工作代理", + } + hasCreate := false + for _, item := range createMarkers { + if strings.Contains(lower, item) { + hasCreate = true + break + } + } + if !hasCreate { + return false + } + for _, item := range subagentMarkers { + if strings.Contains(lower, item) { + return true + } + } + return false +} + +func isSubagentConfigConfirm(content string) bool { + lower := strings.ToLower(strings.TrimSpace(content)) + phrases := []string{ + "确认创建", "确认保存", "确认生成", "保存这个子代理", "创建这个子代理", + "confirm create", "confirm save", "save it", "create it", + } + for _, phrase := range phrases { + if lower == phrase || strings.Contains(lower, phrase) { + return true + } + } + return false +} + +func isSubagentConfigCancel(content string) bool { + lower := strings.ToLower(strings.TrimSpace(content)) + phrases := []string{ + "取消创建", "取消保存", "取消这个子代理", "放弃创建", + "cancel create", "cancel save", "discard draft", "never mind", + } + for _, phrase := range phrases { + if lower == phrase || strings.Contains(lower, phrase) { + return true + } + } + return false +} + +func extractSubagentDescription(content string) string { + content = strings.TrimSpace(content) + replacers := []string{ + "请", "帮我", "给我", "创建", "新建", "增加", "添加", "配置", "生成", + "a ", "an ", "new ", "create ", "add ", + } + out := content + for _, item := range replacers { + out = strings.ReplaceAll(out, item, "") + } + out = strings.ReplaceAll(out, "子代理", "") + out = strings.ReplaceAll(out, "subagent", "") + out = strings.ReplaceAll(out, "sub-agent", "") + out = strings.TrimSpace(out) + if out == "" { + return strings.TrimSpace(content) + } + return out +} + +func formatSubagentDraftForUser(draft map[string]interface{}) string { + return fmt.Sprintf( + "已生成 subagent 草案。\nagent_id: %v\nrole: %v\ndisplay_name: %v\ntool_allowlist: %v\nrouting_keywords: %v\nsystem_prompt: %v\n\n回复“确认创建”会写入 config.json,回复“取消创建”会丢弃这个草案。", + draft["agent_id"], + draft["role"], + draft["display_name"], + draft["tool_allowlist"], + draft["routing_keywords"], + draft["system_prompt"], + ) +} + +func (al *AgentLoop) storePendingSubagentDraft(sessionKey string, draft map[string]interface{}) { + if al == nil || draft == nil { + return + } + if strings.TrimSpace(sessionKey) == "" { + sessionKey = "main" + } + al.streamMu.Lock() + defer al.streamMu.Unlock() + if al.pendingSubagentDraft == nil { + al.pendingSubagentDraft = map[string]map[string]interface{}{} + } + copied := make(map[string]interface{}, len(draft)) + for k, v := range draft { + copied[k] = v + } + al.pendingSubagentDraft[sessionKey] = copied + if al.pendingDraftStore != nil { + _ = al.pendingDraftStore.Put(sessionKey, copied) + } +} + +func (al *AgentLoop) loadPendingSubagentDraft(sessionKey string) map[string]interface{} { + if al == nil { + return nil + } + if strings.TrimSpace(sessionKey) == "" { + sessionKey = "main" + } + al.streamMu.Lock() + defer al.streamMu.Unlock() + draft := al.pendingSubagentDraft[sessionKey] + if draft == nil { + return nil + } + copied := make(map[string]interface{}, len(draft)) + for k, v := range draft { + copied[k] = v + } + return copied +} + +func (al *AgentLoop) deletePendingSubagentDraft(sessionKey string) { + if al == nil { + return + } + if strings.TrimSpace(sessionKey) == "" { + sessionKey = "main" + } + al.streamMu.Lock() + defer al.streamMu.Unlock() + delete(al.pendingSubagentDraft, sessionKey) + if al.pendingDraftStore != nil { + _ = al.pendingDraftStore.Delete(sessionKey) + } +} + +func (al *AgentLoop) confirmPendingSubagentDraft(sessionKey string) (string, bool, error) { + draft := al.loadPendingSubagentDraft(sessionKey) + if draft == nil { + return "", false, nil + } + result, err := tools.UpsertConfigSubagent(al.configPath, draft) + if err != nil { + return "", true, err + } + al.deletePendingSubagentDraft(sessionKey) + return fmt.Sprintf("subagent 已写入 config.json。\nagent_id: %v\nrules: %v", result["agent_id"], result["rules"]), true, nil +} + +func (al *AgentLoop) cancelPendingSubagentDraft(sessionKey string) (string, bool, error) { + draft := al.loadPendingSubagentDraft(sessionKey) + if draft == nil { + return "", false, nil + } + al.deletePendingSubagentDraft(sessionKey) + return "已取消这次 subagent 草案,不会写入 config.json。", true, nil +} diff --git a/pkg/agent/subagent_config_intent_test.go b/pkg/agent/subagent_config_intent_test.go new file mode 100644 index 0000000..b760b9d --- /dev/null +++ b/pkg/agent/subagent_config_intent_test.go @@ -0,0 +1,172 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "clawgo/pkg/bus" + "clawgo/pkg/config" + "clawgo/pkg/runtimecfg" +) + +func TestMaybeHandleSubagentConfigIntentCreateAndConfirm(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + } + 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()) }) + + loop := &AgentLoop{ + configPath: configPath, + pendingSubagentDraft: map[string]map[string]interface{}{}, + } + out, handled, err := loop.maybeHandleSubagentConfigIntent(context.Background(), bus.InboundMessage{ + SessionKey: "main", + Channel: "cli", + Content: "创建一个负责回归测试和验证修复结果的子代理", + }) + if err != nil { + t.Fatalf("create draft failed: %v", err) + } + if !handled || !strings.Contains(out, "已生成 subagent 草案") { + t.Fatalf("expected draft response, got handled=%v out=%q", handled, out) + } + + out, handled, err = loop.maybeHandleSubagentConfigIntent(context.Background(), bus.InboundMessage{ + SessionKey: "main", + Channel: "cli", + Content: "确认创建", + }) + if err != nil { + t.Fatalf("confirm draft failed: %v", err) + } + if !handled || !strings.Contains(out, "已写入 config.json") { + t.Fatalf("expected confirm response, got handled=%v out=%q", handled, out) + } + + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + if _, ok := reloaded.Agents.Subagents["tester"]; !ok { + t.Fatalf("expected tester subagent to persist, got %+v", reloaded.Agents.Subagents) + } +} + +func TestMaybeHandleSubagentConfigIntentCancel(t *testing.T) { + loop := &AgentLoop{ + pendingSubagentDraft: map[string]map[string]interface{}{}, + } + _, handled, err := loop.maybeHandleSubagentConfigIntent(context.Background(), bus.InboundMessage{ + SessionKey: "main", + Channel: "cli", + Content: "创建一个负责文档整理的子代理", + }) + if err != nil { + t.Fatalf("create draft failed: %v", err) + } + if !handled { + t.Fatalf("expected create to be handled") + } + + out, handled, err := loop.maybeHandleSubagentConfigIntent(context.Background(), bus.InboundMessage{ + SessionKey: "main", + Channel: "cli", + Content: "取消创建", + }) + if err != nil { + t.Fatalf("cancel draft failed: %v", err) + } + if !handled || !strings.Contains(out, "已取消") { + t.Fatalf("expected cancel response, got handled=%v out=%q", handled, out) + } + if got := loop.loadPendingSubagentDraft("main"); got != nil { + t.Fatalf("expected pending draft to be cleared, got %#v", got) + } +} + +func TestPendingSubagentDraftPersistsAcrossLoopRestart(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + } + 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()) }) + + store := NewPendingSubagentDraftStore(workspace) + loop := &AgentLoop{ + workspace: workspace, + configPath: configPath, + pendingDraftStore: store, + pendingSubagentDraft: map[string]map[string]interface{}{}, + } + _, handled, err := loop.maybeHandleSubagentConfigIntent(context.Background(), bus.InboundMessage{ + SessionKey: "main", + Channel: "cli", + Content: "创建一个负责文档整理的子代理", + }) + if err != nil { + t.Fatalf("create draft failed: %v", err) + } + if !handled { + t.Fatalf("expected create to be handled") + } + + reloadedStore := NewPendingSubagentDraftStore(workspace) + reloadedLoop := &AgentLoop{ + workspace: workspace, + configPath: configPath, + pendingDraftStore: reloadedStore, + pendingSubagentDraft: reloadedStore.All(), + } + if reloadedLoop.loadPendingSubagentDraft("main") == nil { + t.Fatalf("expected draft to be restored from store") + } + + out, handled, err := reloadedLoop.maybeHandleSubagentConfigIntent(context.Background(), bus.InboundMessage{ + SessionKey: "main", + Channel: "cli", + Content: "确认创建", + }) + if err != nil { + t.Fatalf("confirm draft failed: %v", err) + } + if !handled || !strings.Contains(out, "已写入 config.json") { + t.Fatalf("expected confirm response, got handled=%v out=%q", handled, out) + } + + reloadedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + if _, ok := reloadedCfg.Agents.Subagents["doc_writer"]; !ok { + t.Fatalf("expected doc_writer subagent to persist, got %+v", reloadedCfg.Agents.Subagents) + } + data, err := os.ReadFile(filepath.Join(workspace, "agents", "runtime", "pending_subagent_drafts.json")) + if err != nil { + t.Fatalf("expected persisted draft store file: %v", err) + } + if !strings.Contains(string(data), "{}") { + t.Fatalf("expected draft store to be cleared after confirm, got %s", string(data)) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 97a835f..ae6d6e3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -27,7 +27,70 @@ type Config struct { } type AgentsConfig struct { - Defaults AgentDefaults `json:"defaults"` + Defaults AgentDefaults `json:"defaults"` + Router AgentRouterConfig `json:"router,omitempty"` + Communication AgentCommunicationConfig `json:"communication,omitempty"` + Subagents map[string]SubagentConfig `json:"subagents,omitempty"` +} + +type AgentRouterConfig struct { + Enabled bool `json:"enabled"` + MainAgentID string `json:"main_agent_id,omitempty"` + Strategy string `json:"strategy,omitempty"` + Rules []AgentRouteRule `json:"rules,omitempty"` + AllowDirectAgentChat bool `json:"allow_direct_agent_chat,omitempty"` + MaxHops int `json:"max_hops,omitempty"` + DefaultTimeoutSec int `json:"default_timeout_sec,omitempty"` + DefaultWaitReply bool `json:"default_wait_reply,omitempty"` + StickyThreadOwner bool `json:"sticky_thread_owner,omitempty"` +} + +type AgentRouteRule struct { + AgentID string `json:"agent_id"` + Keywords []string `json:"keywords,omitempty"` +} + +type AgentCommunicationConfig struct { + Mode string `json:"mode,omitempty"` + PersistThreads bool `json:"persist_threads,omitempty"` + PersistMessages bool `json:"persist_messages,omitempty"` + MaxMessagesPerThread int `json:"max_messages_per_thread,omitempty"` + DeadLetterQueue bool `json:"dead_letter_queue,omitempty"` + DefaultMessageTTLSec int `json:"default_message_ttl_sec,omitempty"` +} + +type SubagentConfig struct { + Enabled bool `json:"enabled"` + Type string `json:"type,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Role string `json:"role,omitempty"` + Description string `json:"description,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + MemoryNamespace string `json:"memory_namespace,omitempty"` + AcceptFrom []string `json:"accept_from,omitempty"` + CanTalkTo []string `json:"can_talk_to,omitempty"` + RequiresMainMediation bool `json:"requires_main_mediation,omitempty"` + DefaultReplyTo string `json:"default_reply_to,omitempty"` + Tools SubagentToolsConfig `json:"tools,omitempty"` + Runtime SubagentRuntimeConfig `json:"runtime,omitempty"` +} + +type SubagentToolsConfig struct { + Allowlist []string `json:"allowlist,omitempty"` + Denylist []string `json:"denylist,omitempty"` + MaxParallelCalls int `json:"max_parallel_calls,omitempty"` +} + +type SubagentRuntimeConfig struct { + Proxy string `json:"proxy,omitempty"` + Model string `json:"model,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` + RetryBackoffMs int `json:"retry_backoff_ms,omitempty"` + MaxTaskChars int `json:"max_task_chars,omitempty"` + MaxResultChars int `json:"max_result_chars,omitempty"` + MaxParallelRuns int `json:"max_parallel_runs,omitempty"` } type AgentDefaults struct { @@ -352,6 +415,26 @@ func DefaultConfig() *Config { }, }, }, + Router: AgentRouterConfig{ + Enabled: false, + MainAgentID: "main", + Strategy: "rules_first", + Rules: []AgentRouteRule{}, + AllowDirectAgentChat: false, + MaxHops: 6, + DefaultTimeoutSec: 600, + DefaultWaitReply: true, + StickyThreadOwner: true, + }, + Communication: AgentCommunicationConfig{ + Mode: "mediated", + PersistThreads: true, + PersistMessages: true, + MaxMessagesPerThread: 100, + DeadLetterQueue: true, + DefaultMessageTTLSec: 86400, + }, + Subagents: map[string]SubagentConfig{}, }, Channels: ChannelsConfig{ InboundMessageIDDedupeTTLSeconds: 600, diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 04a6ebd..5a8d55f 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -116,6 +116,9 @@ func Validate(cfg *Config) []error { errs = append(errs, fmt.Errorf("context_compaction.mode=responses_compact requires active proxy %q with supports_responses_compact=true", active)) } } + errs = append(errs, validateAgentRouter(cfg)...) + errs = append(errs, validateAgentCommunication(cfg)...) + errs = append(errs, validateSubagents(cfg)...) if cfg.Gateway.Port <= 0 || cfg.Gateway.Port > 65535 { errs = append(errs, fmt.Errorf("gateway.port must be in 1..65535")) @@ -213,6 +216,170 @@ func Validate(cfg *Config) []error { return errs } +func validateAgentRouter(cfg *Config) []error { + router := cfg.Agents.Router + var errs []error + if strings.TrimSpace(router.Strategy) != "" { + switch strings.TrimSpace(router.Strategy) { + case "rules_first", "round_robin", "manual": + default: + errs = append(errs, fmt.Errorf("agents.router.strategy must be one of: rules_first, round_robin, manual")) + } + } + if router.MaxHops < 0 { + errs = append(errs, fmt.Errorf("agents.router.max_hops must be >= 0")) + } + if router.DefaultTimeoutSec < 0 { + errs = append(errs, fmt.Errorf("agents.router.default_timeout_sec must be >= 0")) + } + if router.Enabled && strings.TrimSpace(router.MainAgentID) == "" { + errs = append(errs, fmt.Errorf("agents.router.main_agent_id is required when agents.router.enabled=true")) + } + for i, rule := range router.Rules { + agentID := strings.TrimSpace(rule.AgentID) + if agentID == "" { + errs = append(errs, fmt.Errorf("agents.router.rules[%d].agent_id is required", i)) + continue + } + if _, ok := cfg.Agents.Subagents[agentID]; !ok { + errs = append(errs, fmt.Errorf("agents.router.rules[%d].agent_id %q not found in agents.subagents", i, agentID)) + } + if len(rule.Keywords) == 0 { + errs = append(errs, fmt.Errorf("agents.router.rules[%d].keywords must not be empty", i)) + } + for _, kw := range rule.Keywords { + if strings.TrimSpace(kw) == "" { + errs = append(errs, fmt.Errorf("agents.router.rules[%d].keywords must not contain empty values", i)) + } + } + } + return errs +} + +func validateAgentCommunication(cfg *Config) []error { + comm := cfg.Agents.Communication + var errs []error + if strings.TrimSpace(comm.Mode) != "" { + switch strings.TrimSpace(comm.Mode) { + case "mediated", "direct": + default: + errs = append(errs, fmt.Errorf("agents.communication.mode must be one of: mediated, direct")) + } + } + if comm.MaxMessagesPerThread < 0 { + errs = append(errs, fmt.Errorf("agents.communication.max_messages_per_thread must be >= 0")) + } + if comm.DefaultMessageTTLSec < 0 { + errs = append(errs, fmt.Errorf("agents.communication.default_message_ttl_sec must be >= 0")) + } + return errs +} + +func validateSubagents(cfg *Config) []error { + var errs []error + if len(cfg.Agents.Subagents) == 0 { + return errs + } + mainID := strings.TrimSpace(cfg.Agents.Router.MainAgentID) + if cfg.Agents.Router.Enabled && mainID != "" { + if _, ok := cfg.Agents.Subagents[mainID]; !ok { + errs = append(errs, fmt.Errorf("agents.router.main_agent_id %q not found in agents.subagents", mainID)) + } + } + for agentID, raw := range cfg.Agents.Subagents { + id := strings.TrimSpace(agentID) + if id == "" { + errs = append(errs, fmt.Errorf("agents.subagents contains an empty agent id")) + continue + } + if strings.TrimSpace(raw.Type) != "" { + switch strings.TrimSpace(raw.Type) { + case "router", "worker", "reviewer", "observer": + default: + errs = append(errs, fmt.Errorf("agents.subagents.%s.type must be one of: router, worker, reviewer, observer", id)) + } + } + if raw.Runtime.TimeoutSec < 0 { + errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.timeout_sec must be >= 0", id)) + } + if raw.Runtime.MaxRetries < 0 { + errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_retries must be >= 0", id)) + } + if raw.Runtime.RetryBackoffMs < 0 { + errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.retry_backoff_ms must be >= 0", id)) + } + if raw.Runtime.MaxTaskChars < 0 { + errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_task_chars must be >= 0", id)) + } + if raw.Runtime.MaxResultChars < 0 { + errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_result_chars must be >= 0", id)) + } + if raw.Runtime.MaxParallelRuns < 0 { + errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_parallel_runs must be >= 0", id)) + } + if raw.Tools.MaxParallelCalls < 0 { + errs = append(errs, fmt.Errorf("agents.subagents.%s.tools.max_parallel_calls must be >= 0", id)) + } + if proxy := strings.TrimSpace(raw.Runtime.Proxy); proxy != "" && !providerExists(cfg, proxy) { + errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.proxy %q not found in providers", id, proxy)) + } + for _, sender := range raw.AcceptFrom { + sender = strings.TrimSpace(sender) + if sender == "" { + errs = append(errs, fmt.Errorf("agents.subagents.%s.accept_from must not contain empty values", id)) + continue + } + if sender != "user" && sender != id { + if _, ok := cfg.Agents.Subagents[sender]; !ok { + errs = append(errs, fmt.Errorf("agents.subagents.%s.accept_from references unknown agent %q", id, sender)) + } + } + } + for _, target := range raw.CanTalkTo { + target = strings.TrimSpace(target) + if target == "" { + errs = append(errs, fmt.Errorf("agents.subagents.%s.can_talk_to must not contain empty values", id)) + continue + } + if target != "user" { + if _, ok := cfg.Agents.Subagents[target]; !ok { + errs = append(errs, fmt.Errorf("agents.subagents.%s.can_talk_to references unknown agent %q", id, target)) + } + } + } + if raw.RequiresMainMediation && mainID != "" && id == mainID { + errs = append(errs, fmt.Errorf("agents.subagents.%s.requires_main_mediation must be false for main agent", id)) + } + } + for agentID, raw := range cfg.Agents.Subagents { + id := strings.TrimSpace(agentID) + for _, target := range raw.CanTalkTo { + target = strings.TrimSpace(target) + if target == "" || target == "user" { + continue + } + peer, ok := cfg.Agents.Subagents[target] + if !ok { + continue + } + if !containsString(raw.AcceptFrom, target) && !containsString(peer.AcceptFrom, id) { + errs = append(errs, fmt.Errorf("agents.subagents.%s.can_talk_to %q is not reciprocated by accept_from", id, target)) + } + } + } + return errs +} + +func containsString(items []string, target string) bool { + target = strings.TrimSpace(target) + for _, item := range items { + if strings.TrimSpace(item) == target { + return true + } + } + return false +} + func validateProviderConfig(path string, p ProviderConfig) []error { var errs []error if p.APIBase == "" { diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go new file mode 100644 index 0000000..549927b --- /dev/null +++ b/pkg/config/validate_test.go @@ -0,0 +1,44 @@ +package config + +import "testing" + +func TestValidateSubagentsAllowsKnownPeers(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + 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"}, + } + cfg.Agents.Subagents["coder"] = SubagentConfig{ + Enabled: true, + Type: "worker", + AcceptFrom: []string{"main"}, + CanTalkTo: []string{"main"}, + Runtime: SubagentRuntimeConfig{ + Proxy: "proxy", + }, + } + + if errs := Validate(cfg); len(errs) != 0 { + t.Fatalf("expected config to be valid, got %v", errs) + } +} + +func TestValidateSubagentsRejectsUnknownPeer(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.Agents.Subagents["coder"] = SubagentConfig{ + Enabled: true, + AcceptFrom: []string{"main"}, + } + + if errs := Validate(cfg); len(errs) == 0 { + t.Fatalf("expected validation errors") + } +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index f889ab2..e7d8c73 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -31,6 +31,11 @@ type SubagentTask struct { RetryCount int `json:"retry_count,omitempty"` PipelineID string `json:"pipeline_id,omitempty"` PipelineTask string `json:"pipeline_task,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + ParentRunID string `json:"parent_run_id,omitempty"` + LastMessageID string `json:"last_message_id,omitempty"` + WaitingReply bool `json:"waiting_for_reply,omitempty"` SharedState map[string]interface{} `json:"shared_state,omitempty"` OriginChannel string `json:"origin_channel,omitempty"` OriginChatID string `json:"origin_chat_id,omitempty"` @@ -53,6 +58,8 @@ type SubagentManager struct { nextID int runFunc SubagentRunFunc profileStore *SubagentProfileStore + runStore *SubagentRunStore + mailboxStore *AgentMailboxStore } type SubagentSpawnOptions struct { @@ -69,11 +76,16 @@ type SubagentSpawnOptions struct { OriginChatID string PipelineID string PipelineTask string + ThreadID string + CorrelationID string + ParentRunID string } func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus, orc *Orchestrator) *SubagentManager { store := NewSubagentProfileStore(workspace) - return &SubagentManager{ + runStore := NewSubagentRunStore(workspace) + mailboxStore := NewAgentMailboxStore(workspace) + mgr := &SubagentManager{ tasks: make(map[string]*SubagentTask), cancelFuncs: make(map[string]context.CancelFunc), archiveAfterMinute: 60, @@ -83,13 +95,44 @@ func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *b workspace: workspace, nextID: 1, profileStore: store, + runStore: runStore, + mailboxStore: mailboxStore, } + if runStore != nil { + for _, task := range runStore.List() { + mgr.tasks[task.ID] = task + } + mgr.nextID = runStore.NextIDSeed() + } + return mgr } func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) (string, error) { + task, err := sm.spawnTask(ctx, opts) + if err != nil { + return "", err + } + desc := fmt.Sprintf("Spawned subagent for task: %s (agent=%s)", task.Task, task.AgentID) + if task.Label != "" { + desc = fmt.Sprintf("Spawned subagent '%s' for task: %s (agent=%s)", task.Label, task.Task, task.AgentID) + } + if task.Role != "" { + desc += fmt.Sprintf(" role=%s", task.Role) + } + if task.PipelineID != "" && task.PipelineTask != "" { + desc += fmt.Sprintf(" (pipeline=%s task=%s)", task.PipelineID, task.PipelineTask) + } + return desc, nil +} + +func (sm *SubagentManager) SpawnTask(ctx context.Context, opts SubagentSpawnOptions) (*SubagentTask, error) { + return sm.spawnTask(ctx, opts) +} + +func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOptions) (*SubagentTask, error) { task := strings.TrimSpace(opts.Task) if task == "" { - return "", fmt.Errorf("task is required") + return nil, fmt.Errorf("task is required") } label := strings.TrimSpace(opts.Label) role := strings.TrimSpace(opts.Role) @@ -99,13 +142,13 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) if sm.profileStore != nil { if agentID != "" { if p, ok, err := sm.profileStore.Get(agentID); err != nil { - return "", err + return nil, err } else if ok { profile = p } } else if role != "" { if p, ok, err := sm.profileStore.FindByRole(role); err != nil { - return "", err + return nil, err } else if ok { profile = p agentID = normalizeSubagentIdentifier(p.AgentID) @@ -128,14 +171,14 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) maxResultChars := 0 if profile == nil && sm.profileStore != nil { if p, ok, err := sm.profileStore.Get(agentID); err != nil { - return "", err + return nil, err } else if ok { profile = p } } if profile != nil { if strings.EqualFold(strings.TrimSpace(profile.Status), "disabled") { - return "", fmt.Errorf("subagent profile '%s' is disabled", profile.AgentID) + return nil, fmt.Errorf("subagent profile '%s' is disabled", profile.AgentID) } if label == "" { label = strings.TrimSpace(profile.Name) @@ -170,7 +213,7 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) maxResultChars = opts.MaxResultChars } if maxTaskChars > 0 && len(task) > maxTaskChars { - return "", fmt.Errorf("task exceeds max_task_chars quota (%d > %d)", len(task), maxTaskChars) + return nil, fmt.Errorf("task exceeds max_task_chars quota (%d > %d)", len(task), maxTaskChars) } maxRetries = normalizePositiveBound(maxRetries, 0, 8) retryBackoff = normalizePositiveBound(retryBackoff, 500, 120000) @@ -184,6 +227,9 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) originChatID := strings.TrimSpace(opts.OriginChatID) pipelineID := strings.TrimSpace(opts.PipelineID) pipelineTask := strings.TrimSpace(opts.PipelineTask) + threadID := strings.TrimSpace(opts.ThreadID) + correlationID := strings.TrimSpace(opts.CorrelationID) + parentRunID := strings.TrimSpace(opts.ParentRunID) sm.mu.Lock() defer sm.mu.Unlock() @@ -193,6 +239,23 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) sessionKey := buildSubagentSessionKey(agentID, taskID) now := time.Now().UnixMilli() + if correlationID == "" { + correlationID = taskID + } + if sm.mailboxStore != nil { + thread, err := sm.mailboxStore.EnsureThread(AgentThread{ + ThreadID: threadID, + Owner: "main", + Participants: []string{"main", agentID}, + Status: "open", + Topic: task, + CreatedAt: now, + UpdatedAt: now, + }) + if err == nil { + threadID = thread.ThreadID + } + } subagentTask := &SubagentTask{ ID: taskID, Task: task, @@ -211,6 +274,9 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) RetryCount: 0, PipelineID: pipelineID, PipelineTask: pipelineTask, + ThreadID: threadID, + CorrelationID: correlationID, + ParentRunID: parentRunID, OriginChannel: originChannel, OriginChatID: originChatID, Status: "running", @@ -220,20 +286,21 @@ func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) taskCtx, cancel := context.WithCancel(ctx) sm.tasks[taskID] = subagentTask sm.cancelFuncs[taskID] = cancel + sm.recordMailboxMessageLocked(subagentTask, AgentMessage{ + ThreadID: threadID, + FromAgent: "main", + ToAgent: agentID, + CorrelationID: correlationID, + Type: "task", + Content: task, + RequiresReply: true, + Status: "queued", + CreatedAt: now, + }) + sm.persistTaskLocked(subagentTask, "spawned", "") go sm.runTask(taskCtx, subagentTask) - - desc := fmt.Sprintf("Spawned subagent for task: %s (agent=%s)", task, agentID) - if label != "" { - desc = fmt.Sprintf("Spawned subagent '%s' for task: %s (agent=%s)", label, task, agentID) - } - if role != "" { - desc += fmt.Sprintf(" role=%s", role) - } - if pipelineID != "" && pipelineTask != "" { - desc += fmt.Sprintf(" (pipeline=%s task=%s)", pipelineID, pipelineTask) - } - return desc, nil + return cloneSubagentTask(subagentTask), nil } func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { @@ -247,6 +314,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { task.Status = "running" task.Created = time.Now().UnixMilli() task.Updated = task.Created + sm.persistTaskLocked(task, "started", "") sm.mu.Unlock() if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { @@ -259,6 +327,19 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { task.Result = fmt.Sprintf("Error: %v", runErr) task.Result = applySubagentResultQuota(task.Result, task.MaxResultChars) task.Updated = time.Now().UnixMilli() + task.WaitingReply = false + sm.recordMailboxMessageLocked(task, AgentMessage{ + ThreadID: task.ThreadID, + FromAgent: task.AgentID, + ToAgent: "main", + ReplyTo: task.LastMessageID, + CorrelationID: task.CorrelationID, + Type: "result", + Content: task.Result, + Status: "delivered", + CreatedAt: task.Updated, + }) + sm.persistTaskLocked(task, "completed", task.Result) if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, runErr) } @@ -266,6 +347,19 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { task.Status = "completed" task.Result = applySubagentResultQuota(result, task.MaxResultChars) task.Updated = time.Now().UnixMilli() + task.WaitingReply = false + sm.recordMailboxMessageLocked(task, AgentMessage{ + ThreadID: task.ThreadID, + FromAgent: task.AgentID, + ToAgent: "main", + ReplyTo: task.LastMessageID, + CorrelationID: task.CorrelationID, + Type: "result", + Content: task.Result, + Status: "delivered", + CreatedAt: task.Updated, + }) + sm.persistTaskLocked(task, "completed", task.Result) if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, nil) } @@ -331,6 +425,7 @@ func (sm *SubagentManager) runWithRetry(ctx context.Context, task *SubagentTask) sm.mu.Lock() task.RetryCount = attempt task.Updated = time.Now().UnixMilli() + sm.persistTaskLocked(task, "attempt_succeeded", "") sm.mu.Unlock() return result, nil } @@ -338,6 +433,7 @@ func (sm *SubagentManager) runWithRetry(ctx context.Context, task *SubagentTask) sm.mu.Lock() task.RetryCount = attempt task.Updated = time.Now().UnixMilli() + sm.persistTaskLocked(task, "attempt_failed", err.Error()) sm.mu.Unlock() if attempt >= maxRetries { break @@ -358,10 +454,18 @@ func (sm *SubagentManager) executeTaskOnce(ctx context.Context, task *SubagentTa if task == nil { return "", fmt.Errorf("subagent task is nil") } + pending, consumedIDs := sm.consumeThreadInbox(task) if sm.runFunc != nil { - return sm.runFunc(ctx, task) + result, err := sm.runFunc(ctx, task) + if err != nil { + sm.restoreMessageStatuses(consumedIDs) + } else { + sm.ackMessageStatuses(consumedIDs) + } + return result, err } if sm.provider == nil { + sm.restoreMessageStatuses(consumedIDs) return "", fmt.Errorf("no llm provider configured for subagent execution") } @@ -388,13 +492,21 @@ func (sm *SubagentManager) executeTaskOnce(ctx context.Context, task *SubagentTa Content: task.Task, }, } + if strings.TrimSpace(pending) != "" { + messages = append(messages, providers.Message{ + Role: "user", + Content: "Mailbox updates on this thread:\n" + pending, + }) + } response, err := sm.provider.Chat(ctx, messages, nil, sm.provider.GetDefaultModel(), map[string]interface{}{ "max_tokens": 4096, }) if err != nil { + sm.restoreMessageStatuses(consumedIDs) return "", err } + sm.ackMessageStatuses(consumedIDs) return response.Content, nil } @@ -412,11 +524,20 @@ func (sm *SubagentManager) ProfileStore() *SubagentProfileStore { return sm.profileStore } +func (sm *SubagentManager) NextTaskSequence() int { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.nextID +} + func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { sm.mu.Lock() defer sm.mu.Unlock() sm.pruneArchivedLocked() task, ok := sm.tasks[taskID] + if !ok && sm.runStore != nil { + return sm.runStore.Get(taskID) + } return task, ok } @@ -426,8 +547,18 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask { sm.pruneArchivedLocked() tasks := make([]*SubagentTask, 0, len(sm.tasks)) + seen := make(map[string]struct{}, len(sm.tasks)) for _, task := range sm.tasks { tasks = append(tasks, task) + seen[task.ID] = struct{}{} + } + if sm.runStore != nil { + for _, task := range sm.runStore.List() { + if _, ok := seen[task.ID]; ok { + continue + } + tasks = append(tasks, task) + } } return tasks } @@ -445,24 +576,46 @@ func (sm *SubagentManager) KillTask(taskID string) bool { } if t.Status == "running" { t.Status = "killed" + t.WaitingReply = false t.Updated = time.Now().UnixMilli() + sm.persistTaskLocked(t, "killed", "") } return true } func (sm *SubagentManager) SteerTask(taskID, message string) bool { + return sm.sendTaskMessage(taskID, "main", "control", message, false, "") +} + +func (sm *SubagentManager) SendTaskMessage(taskID, message string) bool { + return sm.sendTaskMessage(taskID, "main", "message", message, false, "") +} + +func (sm *SubagentManager) ReplyToTask(taskID, replyToMessageID, message string) bool { + return sm.sendTaskMessage(taskID, "main", "reply", message, false, replyToMessageID) +} + +func (sm *SubagentManager) AckTaskMessage(taskID, messageID string) bool { sm.mu.Lock() defer sm.mu.Unlock() t, ok := sm.tasks[taskID] if !ok { return false } - message = strings.TrimSpace(message) - if message == "" { + if sm.mailboxStore == nil { + return false + } + if strings.TrimSpace(messageID) == "" { return false } - t.Steering = append(t.Steering, message) t.Updated = time.Now().UnixMilli() + msg, err := sm.mailboxStore.UpdateMessageStatus(messageID, "acked", t.Updated) + if err != nil { + return false + } + t.LastMessageID = msg.MessageID + t.WaitingReply = false + sm.persistTaskLocked(t, "acked", messageID) return true } @@ -496,13 +649,66 @@ func (sm *SubagentManager) ResumeTask(ctx context.Context, taskID string) (strin OriginChatID: t.OriginChatID, PipelineID: t.PipelineID, PipelineTask: t.PipelineTask, + ThreadID: t.ThreadID, + CorrelationID: t.CorrelationID, + ParentRunID: t.ID, }) if err != nil { return "", false } + sm.mu.Lock() + if original, ok := sm.tasks[taskID]; ok { + sm.persistTaskLocked(original, "resumed", label) + } + sm.mu.Unlock() return label, true } +func (sm *SubagentManager) Events(taskID string, limit int) ([]SubagentRunEvent, error) { + if sm.runStore == nil { + return nil, nil + } + return sm.runStore.Events(taskID, limit) +} + +func (sm *SubagentManager) Thread(threadID string) (*AgentThread, bool) { + if sm.mailboxStore == nil { + return nil, false + } + return sm.mailboxStore.Thread(threadID) +} + +func (sm *SubagentManager) ThreadMessages(threadID string, limit int) ([]AgentMessage, error) { + if sm.mailboxStore == nil { + return nil, nil + } + return sm.mailboxStore.MessagesByThread(threadID, limit) +} + +func (sm *SubagentManager) Inbox(agentID string, limit int) ([]AgentMessage, error) { + if sm.mailboxStore == nil { + return nil, nil + } + return sm.mailboxStore.Inbox(agentID, limit) +} + +func (sm *SubagentManager) TaskInbox(taskID string, limit int) ([]AgentMessage, error) { + sm.mu.RLock() + task, ok := sm.tasks[taskID] + sm.mu.RUnlock() + if !ok || sm.mailboxStore == nil { + return nil, nil + } + return sm.mailboxStore.ThreadInbox(task.ThreadID, task.AgentID, limit) +} + +func (sm *SubagentManager) Message(messageID string) (*AgentMessage, bool) { + if sm.mailboxStore == nil { + return nil, false + } + return sm.mailboxStore.Message(messageID) +} + func (sm *SubagentManager) pruneArchivedLocked() { if sm.archiveAfterMinute <= 0 { return @@ -580,3 +786,133 @@ func buildSubagentSessionKey(agentID, taskID string) string { } return fmt.Sprintf("subagent:%s:%s", a, t) } + +func (sm *SubagentManager) persistTaskLocked(task *SubagentTask, eventType, message string) { + if task == nil || sm.runStore == nil { + return + } + cp := cloneSubagentTask(task) + _ = sm.runStore.AppendRun(cp) + _ = sm.runStore.AppendEvent(SubagentRunEvent{ + RunID: cp.ID, + AgentID: cp.AgentID, + Type: strings.TrimSpace(eventType), + Status: cp.Status, + Message: strings.TrimSpace(message), + RetryCount: cp.RetryCount, + At: cp.Updated, + }) +} + +func (sm *SubagentManager) recordMailboxMessageLocked(task *SubagentTask, msg AgentMessage) { + if sm.mailboxStore == nil || task == nil { + return + } + if strings.TrimSpace(msg.ThreadID) == "" { + msg.ThreadID = task.ThreadID + } + stored, err := sm.mailboxStore.AppendMessage(msg) + if err != nil { + return + } + task.LastMessageID = stored.MessageID + if stored.RequiresReply { + task.WaitingReply = true + } +} + +func (sm *SubagentManager) sendTaskMessage(taskID, fromAgent, msgType, message string, requiresReply bool, replyTo string) bool { + sm.mu.Lock() + defer sm.mu.Unlock() + t, ok := sm.tasks[taskID] + if !ok { + return false + } + message = strings.TrimSpace(message) + if message == "" { + return false + } + fromAgent = strings.TrimSpace(fromAgent) + if fromAgent == "" { + fromAgent = "main" + } + t.Updated = time.Now().UnixMilli() + if fromAgent == "main" { + t.Steering = append(t.Steering, message) + } + if strings.TrimSpace(replyTo) == "" { + replyTo = t.LastMessageID + } + toAgent := t.AgentID + if fromAgent != "main" { + toAgent = "main" + } + sm.recordMailboxMessageLocked(t, AgentMessage{ + ThreadID: t.ThreadID, + FromAgent: fromAgent, + ToAgent: toAgent, + ReplyTo: replyTo, + CorrelationID: t.CorrelationID, + Type: msgType, + Content: message, + RequiresReply: requiresReply, + Status: "queued", + CreatedAt: t.Updated, + }) + switch msgType { + case "control": + sm.persistTaskLocked(t, "steered", message) + case "reply": + sm.persistTaskLocked(t, "reply_sent", message) + default: + sm.persistTaskLocked(t, "message_sent", message) + } + return true +} + +func (sm *SubagentManager) consumeThreadInbox(task *SubagentTask) (string, []string) { + if task == nil || sm.mailboxStore == nil { + return "", nil + } + msgs, err := sm.mailboxStore.ThreadInbox(task.ThreadID, task.AgentID, 0) + if err != nil || len(msgs) == 0 { + return "", nil + } + var sb strings.Builder + consumed := make([]string, 0, len(msgs)) + now := time.Now().UnixMilli() + for _, msg := range msgs { + if _, err := sm.mailboxStore.UpdateMessageStatus(msg.MessageID, "processing", now); err != nil { + continue + } + consumed = append(consumed, msg.MessageID) + sb.WriteString(fmt.Sprintf("- [%s] from=%s type=%s", msg.MessageID, msg.FromAgent, msg.Type)) + if strings.TrimSpace(msg.ReplyTo) != "" { + sb.WriteString(fmt.Sprintf(" reply_to=%s", msg.ReplyTo)) + } + sb.WriteString("\n") + sb.WriteString(strings.TrimSpace(msg.Content)) + sb.WriteString("\n") + } + return strings.TrimSpace(sb.String()), consumed +} + +func (sm *SubagentManager) restoreMessageStatuses(messageIDs []string) { + if sm.mailboxStore == nil || len(messageIDs) == 0 { + return + } + now := time.Now().UnixMilli() + for _, messageID := range messageIDs { + _, _ = sm.mailboxStore.UpdateMessageStatus(messageID, "queued", now) + } +} + +func (sm *SubagentManager) ackMessageStatuses(messageIDs []string) { + if sm.mailboxStore == nil || len(messageIDs) == 0 { + return + } + now := time.Now().UnixMilli() + for _, messageID := range messageIDs { + _, _ = sm.mailboxStore.UpdateMessageStatus(messageID, "acked", now) + } +} diff --git a/pkg/tools/subagent_config_manager.go b/pkg/tools/subagent_config_manager.go new file mode 100644 index 0000000..07331ad --- /dev/null +++ b/pkg/tools/subagent_config_manager.go @@ -0,0 +1,364 @@ +package tools + +import ( + "encoding/json" + "fmt" + "strings" + + "clawgo/pkg/config" + "clawgo/pkg/configops" + "clawgo/pkg/runtimecfg" +) + +func DraftConfigSubagent(description, agentIDHint string) map[string]interface{} { + desc := strings.TrimSpace(description) + lower := strings.ToLower(desc) + role := inferDraftRole(lower) + agentID := strings.TrimSpace(agentIDHint) + if agentID == "" { + agentID = inferDraftAgentID(role, lower) + } + displayName := inferDraftDisplayName(role, agentID) + toolAllowlist := inferDraftToolAllowlist(role) + keywords := inferDraftKeywords(role, lower) + systemPrompt := inferDraftSystemPrompt(role, desc) + return map[string]interface{}{ + "agent_id": agentID, + "role": role, + "display_name": displayName, + "description": desc, + "system_prompt": systemPrompt, + "memory_namespace": agentID, + "tool_allowlist": toolAllowlist, + "routing_keywords": keywords, + "type": "worker", + } +} + +func UpsertConfigSubagent(configPath string, args map[string]interface{}) (map[string]interface{}, error) { + configPath = strings.TrimSpace(configPath) + if configPath == "" { + return nil, fmt.Errorf("config path not configured") + } + agentID := stringArgFromMap(args, "agent_id") + if agentID == "" { + return nil, fmt.Errorf("agent_id is required") + } + cfg, err := config.LoadConfig(configPath) + if err != nil { + return nil, err + } + if cfg.Agents.Subagents == nil { + cfg.Agents.Subagents = map[string]config.SubagentConfig{} + } + subcfg := cfg.Agents.Subagents[agentID] + if enabled, ok := boolArgFromMap(args, "enabled"); ok { + subcfg.Enabled = enabled + } else if !subcfg.Enabled { + subcfg.Enabled = true + } + if v := stringArgFromMap(args, "role"); v != "" { + subcfg.Role = v + } + if v := stringArgFromMap(args, "display_name"); v != "" { + subcfg.DisplayName = v + } + if v := stringArgFromMap(args, "description"); v != "" { + subcfg.Description = v + } + if v := stringArgFromMap(args, "system_prompt"); v != "" { + subcfg.SystemPrompt = v + } + if v := stringArgFromMap(args, "memory_namespace"); v != "" { + subcfg.MemoryNamespace = v + } + if items := stringListArgFromMap(args, "tool_allowlist"); len(items) > 0 { + subcfg.Tools.Allowlist = items + } + if v := stringArgFromMap(args, "type"); v != "" { + subcfg.Type = v + } else if strings.TrimSpace(subcfg.Type) == "" { + subcfg.Type = "worker" + } + 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{ + AgentID: agentID, + Keywords: kws, + }) + } + if errs := config.Validate(cfg); len(errs) > 0 { + return nil, fmt.Errorf("config validation failed: %v", errs[0]) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, err + } + if _, err := configops.WriteConfigAtomicWithBackup(configPath, data); err != nil { + return nil, err + } + runtimecfg.Set(cfg) + return map[string]interface{}{ + "ok": true, + "agent_id": agentID, + "subagent": cfg.Agents.Subagents[agentID], + "rules": cfg.Agents.Router.Rules, + }, nil +} + +func DeleteConfigSubagent(configPath, agentID string) (map[string]interface{}, error) { + configPath = strings.TrimSpace(configPath) + if configPath == "" { + return nil, fmt.Errorf("config path not configured") + } + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return nil, fmt.Errorf("agent_id is required") + } + cfg, err := config.LoadConfig(configPath) + if err != nil { + return nil, err + } + if cfg.Agents.Subagents == nil { + return map[string]interface{}{"ok": false, "found": false, "agent_id": agentID}, nil + } + if _, ok := cfg.Agents.Subagents[agentID]; !ok { + return map[string]interface{}{"ok": false, "found": false, "agent_id": agentID}, nil + } + delete(cfg.Agents.Subagents, agentID) + cfg.Agents.Router.Rules = removeRouteRuleConfig(cfg.Agents.Router.Rules, agentID) + if errs := config.Validate(cfg); len(errs) > 0 { + return nil, fmt.Errorf("config validation failed: %v", errs[0]) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, err + } + if _, err := configops.WriteConfigAtomicWithBackup(configPath, data); err != nil { + return nil, err + } + runtimecfg.Set(cfg) + return map[string]interface{}{ + "ok": true, + "found": true, + "agent_id": agentID, + "rules": cfg.Agents.Router.Rules, + }, nil +} + +func stringArgFromMap(args map[string]interface{}, key string) string { + if args == nil { + return "" + } + v, _ := args[key].(string) + return strings.TrimSpace(v) +} + +func boolArgFromMap(args map[string]interface{}, key string) (bool, bool) { + if args == nil { + return false, false + } + raw, ok := args[key] + if !ok { + return false, false + } + switch v := raw.(type) { + case bool: + return v, true + default: + return false, false + } +} + +func stringListArgFromMap(args map[string]interface{}, key string) []string { + if args == nil { + return nil + } + raw, ok := args[key] + if !ok { + return nil + } + switch v := raw.(type) { + case []string: + return normalizeKeywords(v) + case []interface{}: + items := make([]string, 0, len(v)) + for _, item := range v { + s, _ := item.(string) + s = strings.TrimSpace(s) + if s != "" { + items = append(items, s) + } + } + return normalizeKeywords(items) + default: + return nil + } +} + +func upsertRouteRuleConfig(rules []config.AgentRouteRule, rule config.AgentRouteRule) []config.AgentRouteRule { + agentID := strings.TrimSpace(rule.AgentID) + if agentID == "" { + return rules + } + rule.Keywords = normalizeKeywords(rule.Keywords) + if len(rule.Keywords) == 0 { + return rules + } + out := make([]config.AgentRouteRule, 0, len(rules)+1) + replaced := false + for _, existing := range rules { + if strings.TrimSpace(existing.AgentID) == agentID { + out = append(out, rule) + replaced = true + continue + } + out = append(out, existing) + } + if !replaced { + out = append(out, rule) + } + return out +} + +func removeRouteRuleConfig(rules []config.AgentRouteRule, agentID string) []config.AgentRouteRule { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return rules + } + out := make([]config.AgentRouteRule, 0, len(rules)) + for _, existing := range rules { + if strings.TrimSpace(existing.AgentID) == agentID { + continue + } + out = append(out, existing) + } + return out +} + +func normalizeKeywords(items []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} + +func inferDraftRole(lower string) string { + switch { + case containsDraftKeyword(lower, "test", "regression", "qa", "回归", "测试", "验证"): + return "testing" + case containsDraftKeyword(lower, "doc", "docs", "readme", "文档", "说明"): + return "docs" + case containsDraftKeyword(lower, "research", "investigate", "analyze", "调研", "分析", "研究"): + return "research" + default: + return "coding" + } +} + +func inferDraftAgentID(role, lower string) string { + switch role { + case "testing": + if containsDraftKeyword(lower, "review", "审查", "reviewer") { + return "reviewer" + } + return "tester" + case "docs": + return "doc_writer" + case "research": + return "researcher" + default: + if containsDraftKeyword(lower, "frontend", "ui", "前端") { + return "frontend-coder" + } + if containsDraftKeyword(lower, "backend", "api", "后端") { + return "backend-coder" + } + return "coder" + } +} + +func inferDraftDisplayName(role, agentID string) string { + switch role { + case "testing": + return "Test Agent" + case "docs": + return "Docs Agent" + case "research": + return "Research Agent" + default: + if strings.Contains(agentID, "frontend") { + return "Frontend Code Agent" + } + if strings.Contains(agentID, "backend") { + return "Backend Code Agent" + } + return "Code Agent" + } +} + +func inferDraftToolAllowlist(role string) []string { + switch role { + case "testing": + return []string{"shell", "filesystem", "process_manager", "sessions"} + case "docs": + return []string{"filesystem", "read_file", "write_file", "edit_file", "repo_map", "sessions"} + case "research": + return []string{"web_search", "web_fetch", "repo_map", "sessions", "memory_search"} + default: + return []string{"filesystem", "shell", "repo_map", "sessions"} + } +} + +func inferDraftKeywords(role, lower string) []string { + seed := []string{} + switch role { + case "testing": + seed = []string{"test", "regression", "verify", "回归", "测试", "验证"} + case "docs": + seed = []string{"docs", "readme", "document", "文档", "说明"} + case "research": + seed = []string{"research", "analyze", "investigate", "调研", "分析", "研究"} + default: + seed = []string{"code", "implement", "fix", "refactor", "代码", "实现", "修复", "重构"} + } + if containsDraftKeyword(lower, "frontend", "前端", "ui") { + seed = append(seed, "frontend", "ui", "前端") + } + if containsDraftKeyword(lower, "backend", "后端", "api") { + seed = append(seed, "backend", "api", "后端") + } + return normalizeKeywords(seed) +} + +func inferDraftSystemPrompt(role, description string) string { + switch role { + case "testing": + return "你负责测试、验证、回归检查与风险反馈。任务描述:" + description + case "docs": + return "你负责文档编写、结构整理和说明补全。任务描述:" + description + case "research": + return "你负责调研、分析、比较方案,并输出结论与依据。任务描述:" + description + default: + return "你负责代码实现与重构,输出具体修改建议和变更结果。任务描述:" + description + } +} + +func containsDraftKeyword(text string, items ...string) bool { + for _, item := range items { + if strings.Contains(text, strings.ToLower(strings.TrimSpace(item))) { + return true + } + } + return false +} diff --git a/pkg/tools/subagent_config_tool.go b/pkg/tools/subagent_config_tool.go new file mode 100644 index 0000000..365155f --- /dev/null +++ b/pkg/tools/subagent_config_tool.go @@ -0,0 +1,115 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" +) + +type SubagentConfigTool struct { + mu sync.RWMutex + configPath string +} + +func NewSubagentConfigTool(configPath string) *SubagentConfigTool { + return &SubagentConfigTool{configPath: strings.TrimSpace(configPath)} +} + +func (t *SubagentConfigTool) Name() string { return "subagent_config" } + +func (t *SubagentConfigTool) Description() string { + return "Draft or persist subagent config and router rules into config.json." +} + +func (t *SubagentConfigTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "description": "draft|upsert", + }, + "description": map[string]interface{}{ + "type": "string", + "description": "Natural-language worker description for draft or upsert.", + }, + "agent_id_hint": map[string]interface{}{ + "type": "string", + "description": "Optional preferred agent id seed for draft.", + }, + "agent_id": map[string]interface{}{"type": "string"}, + "role": map[string]interface{}{"type": "string"}, + "display_name": map[string]interface{}{"type": "string"}, + "system_prompt": map[string]interface{}{"type": "string"}, + "memory_namespace": map[string]interface{}{"type": "string"}, + "type": map[string]interface{}{"type": "string"}, + "tool_allowlist": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + }, + "routing_keywords": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + }, + }, + "required": []string{"action"}, + } +} + +func (t *SubagentConfigTool) SetConfigPath(path string) { + t.mu.Lock() + defer t.mu.Unlock() + t.configPath = strings.TrimSpace(path) +} + +func (t *SubagentConfigTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + _ = ctx + switch stringArgFromMap(args, "action") { + case "draft": + description := stringArgFromMap(args, "description") + if description == "" { + return "", fmt.Errorf("description is required") + } + return marshalSubagentConfigPayload(map[string]interface{}{ + "draft": DraftConfigSubagent(description, stringArgFromMap(args, "agent_id_hint")), + }) + case "upsert": + result, err := UpsertConfigSubagent(t.getConfigPath(), cloneSubagentConfigArgs(args)) + if err != nil { + return "", err + } + return marshalSubagentConfigPayload(result) + default: + return "", fmt.Errorf("unsupported action") + } +} + +func (t *SubagentConfigTool) getConfigPath() string { + t.mu.RLock() + defer t.mu.RUnlock() + return t.configPath +} + +func cloneSubagentConfigArgs(args map[string]interface{}) map[string]interface{} { + if args == nil { + return map[string]interface{}{} + } + out := make(map[string]interface{}, len(args)) + for k, v := range args { + if k == "action" || k == "agent_id_hint" { + continue + } + out[k] = v + } + return out +} + +func marshalSubagentConfigPayload(payload map[string]interface{}) (string, error) { + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/pkg/tools/subagent_config_tool_test.go b/pkg/tools/subagent_config_tool_test.go new file mode 100644 index 0000000..68c6424 --- /dev/null +++ b/pkg/tools/subagent_config_tool_test.go @@ -0,0 +1,82 @@ +package tools + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "clawgo/pkg/config" + "clawgo/pkg/runtimecfg" +) + +func TestSubagentConfigToolDraft(t *testing.T) { + tool := NewSubagentConfigTool("") + out, err := tool.Execute(context.Background(), map[string]interface{}{ + "action": "draft", + "description": "创建一个负责回归测试和验证修复结果的子代理", + }) + if err != nil { + t.Fatalf("draft failed: %v", err) + } + var payload map[string]interface{} + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("unmarshal payload failed: %v", err) + } + draft, ok := payload["draft"].(map[string]interface{}) + if !ok { + t.Fatalf("expected draft map, got %#v", payload["draft"]) + } + if draft["agent_id"] == "" || draft["role"] == "" { + t.Fatalf("expected draft agent_id and role, got %#v", draft) + } +} + +func TestSubagentConfigToolUpsert(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + Role: "orchestrator", + } + 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()) }) + + 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"}, + }) + if err != nil { + t.Fatalf("upsert failed: %v", err) + } + var payload map[string]interface{} + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("unmarshal payload failed: %v", err) + } + if payload["ok"] != true { + t.Fatalf("expected ok payload, got %#v", payload) + } + reloaded, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("reload config failed: %v", err) + } + if reloaded.Agents.Subagents["reviewer"].DisplayName != "Review Agent" { + t.Fatalf("expected config to persist reviewer, got %+v", reloaded.Agents.Subagents["reviewer"]) + } + if len(reloaded.Agents.Router.Rules) == 0 { + t.Fatalf("expected router rules to persist") + } +} diff --git a/pkg/tools/subagent_mailbox.go b/pkg/tools/subagent_mailbox.go new file mode 100644 index 0000000..b146839 --- /dev/null +++ b/pkg/tools/subagent_mailbox.go @@ -0,0 +1,347 @@ +package tools + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" +) + +type AgentThread struct { + ThreadID string `json:"thread_id"` + Owner string `json:"owner"` + Participants []string `json:"participants,omitempty"` + Status string `json:"status"` + Topic string `json:"topic,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type AgentMessage struct { + MessageID string `json:"message_id"` + ThreadID string `json:"thread_id"` + FromAgent string `json:"from_agent"` + ToAgent string `json:"to_agent"` + ReplyTo string `json:"reply_to,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + Type string `json:"type"` + Content string `json:"content"` + RequiresReply bool `json:"requires_reply,omitempty"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` +} + +type AgentMailboxStore struct { + dir string + threadsPath string + msgsPath string + mu sync.RWMutex + threads map[string]*AgentThread + messages map[string]*AgentMessage + msgSeq int + threadSeq int +} + +func NewAgentMailboxStore(workspace string) *AgentMailboxStore { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return nil + } + dir := filepath.Join(workspace, "agents", "runtime") + s := &AgentMailboxStore{ + dir: dir, + threadsPath: filepath.Join(dir, "threads.jsonl"), + msgsPath: filepath.Join(dir, "agent_messages.jsonl"), + threads: map[string]*AgentThread{}, + messages: map[string]*AgentMessage{}, + } + _ = os.MkdirAll(dir, 0755) + _ = s.load() + return s +} + +func (s *AgentMailboxStore) load() error { + s.mu.Lock() + defer s.mu.Unlock() + s.threads = map[string]*AgentThread{} + s.messages = map[string]*AgentMessage{} + if err := s.loadThreadsLocked(); err != nil { + return err + } + return s.scanMessagesLocked() +} + +func (s *AgentMailboxStore) loadThreadsLocked() error { + f, err := os.Open(s.threadsPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer f.Close() + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 2*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var thread AgentThread + if err := json.Unmarshal([]byte(line), &thread); err != nil { + continue + } + cp := thread + s.threads[thread.ThreadID] = &cp + if n := parseThreadSequence(thread.ThreadID); n > s.threadSeq { + s.threadSeq = n + } + } + return scanner.Err() +} + +func (s *AgentMailboxStore) scanMessagesLocked() error { + f, err := os.Open(s.msgsPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer f.Close() + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 2*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var msg AgentMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue + } + if n := parseMessageSequence(msg.MessageID); n > s.msgSeq { + s.msgSeq = n + } + cp := msg + s.messages[msg.MessageID] = &cp + if thread := s.threads[msg.ThreadID]; thread != nil && msg.CreatedAt > thread.UpdatedAt { + thread.UpdatedAt = msg.CreatedAt + } + } + return scanner.Err() +} + +func (s *AgentMailboxStore) EnsureThread(thread AgentThread) (AgentThread, error) { + if s == nil { + return thread, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if err := os.MkdirAll(s.dir, 0755); err != nil { + return AgentThread{}, err + } + if strings.TrimSpace(thread.ThreadID) == "" { + s.threadSeq++ + thread.ThreadID = fmt.Sprintf("thread-%04d", s.threadSeq) + } + thread.Participants = normalizeStringList(thread.Participants) + if strings.TrimSpace(thread.Status) == "" { + thread.Status = "open" + } + if thread.CreatedAt <= 0 { + thread.CreatedAt = thread.UpdatedAt + } + if thread.CreatedAt <= 0 { + thread.CreatedAt = 1 + } + if thread.UpdatedAt <= 0 { + thread.UpdatedAt = thread.CreatedAt + } + data, err := json.Marshal(thread) + if err != nil { + return AgentThread{}, err + } + f, err := os.OpenFile(s.threadsPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return AgentThread{}, err + } + defer f.Close() + if _, err := f.Write(append(data, '\n')); err != nil { + return AgentThread{}, err + } + cp := thread + s.threads[thread.ThreadID] = &cp + return thread, nil +} + +func (s *AgentMailboxStore) AppendMessage(msg AgentMessage) (AgentMessage, error) { + if s == nil { + return msg, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if err := os.MkdirAll(s.dir, 0755); err != nil { + return AgentMessage{}, err + } + if strings.TrimSpace(msg.MessageID) == "" { + s.msgSeq++ + msg.MessageID = fmt.Sprintf("msg-%06d", s.msgSeq) + } + if strings.TrimSpace(msg.Status) == "" { + msg.Status = "queued" + } + data, err := json.Marshal(msg) + if err != nil { + return AgentMessage{}, err + } + f, err := os.OpenFile(s.msgsPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return AgentMessage{}, err + } + defer f.Close() + if _, err := f.Write(append(data, '\n')); err != nil { + return AgentMessage{}, err + } + if thread := s.threads[msg.ThreadID]; thread != nil { + thread.UpdatedAt = msg.CreatedAt + participants := append([]string(nil), thread.Participants...) + participants = append(participants, msg.FromAgent, msg.ToAgent) + thread.Participants = normalizeStringList(participants) + } + cp := msg + s.messages[msg.MessageID] = &cp + return msg, nil +} + +func (s *AgentMailboxStore) Thread(threadID string) (*AgentThread, bool) { + if s == nil { + return nil, false + } + s.mu.RLock() + defer s.mu.RUnlock() + thread, ok := s.threads[strings.TrimSpace(threadID)] + if !ok { + return nil, false + } + cp := *thread + cp.Participants = append([]string(nil), thread.Participants...) + return &cp, true +} + +func (s *AgentMailboxStore) MessagesByThread(threadID string, limit int) ([]AgentMessage, error) { + if s == nil { + return nil, nil + } + return s.currentMessages(func(msg AgentMessage) bool { + return msg.ThreadID == strings.TrimSpace(threadID) + }, limit), nil +} + +func (s *AgentMailboxStore) Inbox(agentID string, limit int) ([]AgentMessage, error) { + if s == nil { + return nil, nil + } + agentID = strings.TrimSpace(agentID) + return s.currentMessages(func(msg AgentMessage) bool { + return msg.ToAgent == agentID && strings.EqualFold(strings.TrimSpace(msg.Status), "queued") + }, limit), nil +} + +func (s *AgentMailboxStore) ThreadInbox(threadID, agentID string, limit int) ([]AgentMessage, error) { + if s == nil { + return nil, nil + } + threadID = strings.TrimSpace(threadID) + agentID = strings.TrimSpace(agentID) + return s.currentMessages(func(msg AgentMessage) bool { + return msg.ThreadID == threadID && msg.ToAgent == agentID && strings.EqualFold(strings.TrimSpace(msg.Status), "queued") + }, limit), nil +} + +func (s *AgentMailboxStore) Message(messageID string) (*AgentMessage, bool) { + if s == nil { + return nil, false + } + s.mu.RLock() + defer s.mu.RUnlock() + msg, ok := s.messages[strings.TrimSpace(messageID)] + if !ok { + return nil, false + } + cp := *msg + return &cp, true +} + +func (s *AgentMailboxStore) UpdateMessageStatus(messageID, status string, at int64) (*AgentMessage, error) { + if s == nil { + return nil, nil + } + s.mu.RLock() + current, ok := s.messages[strings.TrimSpace(messageID)] + s.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("message not found: %s", messageID) + } + updated := *current + updated.Status = strings.TrimSpace(status) + if updated.Status == "" { + updated.Status = current.Status + } + if at > 0 { + updated.CreatedAt = at + } + msg, err := s.AppendMessage(updated) + if err != nil { + return nil, err + } + return &msg, nil +} + +func (s *AgentMailboxStore) currentMessages(match func(AgentMessage) bool, limit int) []AgentMessage { + s.mu.RLock() + defer s.mu.RUnlock() + var out []AgentMessage + for _, item := range s.messages { + msg := *item + if match != nil && !match(msg) { + continue + } + out = append(out, msg) + } + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt != out[j].CreatedAt { + return out[i].CreatedAt < out[j].CreatedAt + } + return out[i].MessageID < out[j].MessageID + }) + if limit > 0 && len(out) > limit { + out = out[len(out)-limit:] + } + return out +} + +func parseThreadSequence(threadID string) int { + threadID = strings.TrimSpace(threadID) + if !strings.HasPrefix(threadID, "thread-") { + return 0 + } + n, _ := strconv.Atoi(strings.TrimPrefix(threadID, "thread-")) + return n +} + +func parseMessageSequence(messageID string) int { + messageID = strings.TrimSpace(messageID) + if !strings.HasPrefix(messageID, "msg-") { + return 0 + } + n, _ := strconv.Atoi(strings.TrimPrefix(messageID, "msg-")) + return n +} diff --git a/pkg/tools/subagent_profile.go b/pkg/tools/subagent_profile.go index 509e019..8d26205 100644 --- a/pkg/tools/subagent_profile.go +++ b/pkg/tools/subagent_profile.go @@ -10,6 +10,9 @@ import ( "strings" "sync" "time" + + "clawgo/pkg/config" + "clawgo/pkg/runtimecfg" ) type SubagentProfile struct { @@ -27,6 +30,7 @@ type SubagentProfile struct { Status string `json:"status"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` + ManagedBy string `json:"managed_by,omitempty"` } type SubagentProfileStore struct { @@ -51,32 +55,14 @@ func (s *SubagentProfileStore) List() ([]SubagentProfile, error) { s.mu.RLock() defer s.mu.RUnlock() - dir := s.profilesDir() - entries, err := os.ReadDir(dir) + merged, err := s.mergedProfilesLocked() if err != nil { - if os.IsNotExist(err) { - return []SubagentProfile{}, nil - } return nil, err } - - out := make([]SubagentProfile, 0, len(entries)) - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") { - continue - } - path := filepath.Join(dir, e.Name()) - b, err := os.ReadFile(path) - if err != nil { - continue - } - var p SubagentProfile - if err := json.Unmarshal(b, &p); err != nil { - continue - } - out = append(out, normalizeSubagentProfile(p)) + out := make([]SubagentProfile, 0, len(merged)) + for _, p := range merged { + out = append(out, p) } - sort.Slice(out, func(i, j int) bool { if out[i].UpdatedAt != out[j].UpdatedAt { return out[i].UpdatedAt > out[j].UpdatedAt @@ -94,19 +80,16 @@ func (s *SubagentProfileStore) Get(agentID string) (*SubagentProfile, bool, erro s.mu.RLock() defer s.mu.RUnlock() - b, err := os.ReadFile(s.profilePath(id)) + merged, err := s.mergedProfilesLocked() if err != nil { - if os.IsNotExist(err) { - return nil, false, nil - } return nil, false, err } - var p SubagentProfile - if err := json.Unmarshal(b, &p); err != nil { - return nil, false, err + p, ok := merged[id] + if !ok { + return nil, false, nil } - norm := normalizeSubagentProfile(p) - return &norm, true, nil + cp := p + return &cp, true, nil } func (s *SubagentProfileStore) FindByRole(role string) (*SubagentProfile, bool, error) { @@ -135,6 +118,9 @@ func (s *SubagentProfileStore) Upsert(profile SubagentProfile) (*SubagentProfile s.mu.Lock() defer s.mu.Unlock() + if managed, ok := s.configProfileLocked(p.AgentID); ok { + return nil, fmt.Errorf("subagent profile %q is managed by %s", p.AgentID, managed.ManagedBy) + } now := time.Now().UnixMilli() path := s.profilePath(p.AgentID) @@ -170,6 +156,9 @@ func (s *SubagentProfileStore) Delete(agentID string) error { } s.mu.Lock() defer s.mu.Unlock() + if managed, ok := s.configProfileLocked(id); ok { + return fmt.Errorf("subagent profile %q is managed by %s", id, managed.ManagedBy) + } err := os.Remove(s.profilePath(id)) if err != nil && !os.IsNotExist(err) { @@ -193,6 +182,7 @@ func normalizeSubagentProfile(in SubagentProfile) SubagentProfile { } p.Status = normalizeProfileStatus(p.Status) p.ToolAllowlist = normalizeToolAllowlist(p.ToolAllowlist) + p.ManagedBy = strings.TrimSpace(p.ManagedBy) p.MaxRetries = clampInt(p.MaxRetries, 0, 8) p.RetryBackoff = clampInt(p.RetryBackoff, 500, 120000) p.TimeoutSec = clampInt(p.TimeoutSec, 0, 3600) @@ -269,6 +259,106 @@ func parseStringList(raw interface{}) []string { return normalizeStringList(out) } +func (s *SubagentProfileStore) mergedProfilesLocked() (map[string]SubagentProfile, error) { + merged := make(map[string]SubagentProfile) + for _, p := range s.configProfilesLocked() { + merged[p.AgentID] = p + } + fileProfiles, err := s.fileProfilesLocked() + if err != nil { + return nil, err + } + for _, p := range fileProfiles { + if _, exists := merged[p.AgentID]; exists { + continue + } + merged[p.AgentID] = p + } + return merged, nil +} + +func (s *SubagentProfileStore) fileProfilesLocked() ([]SubagentProfile, error) { + dir := s.profilesDir() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return []SubagentProfile{}, nil + } + return nil, err + } + out := make([]SubagentProfile, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") { + continue + } + path := filepath.Join(dir, e.Name()) + b, err := os.ReadFile(path) + if err != nil { + continue + } + var p SubagentProfile + if err := json.Unmarshal(b, &p); err != nil { + continue + } + out = append(out, normalizeSubagentProfile(p)) + } + return out, nil +} + +func (s *SubagentProfileStore) configProfilesLocked() []SubagentProfile { + cfg := runtimecfg.Get() + if cfg == nil || len(cfg.Agents.Subagents) == 0 { + return nil + } + out := make([]SubagentProfile, 0, len(cfg.Agents.Subagents)) + for agentID, subcfg := range cfg.Agents.Subagents { + profile := profileFromConfig(agentID, subcfg) + if profile.AgentID == "" { + continue + } + out = append(out, profile) + } + return out +} + +func (s *SubagentProfileStore) configProfileLocked(agentID string) (SubagentProfile, bool) { + id := normalizeSubagentIdentifier(agentID) + if id == "" { + return SubagentProfile{}, false + } + cfg := runtimecfg.Get() + if cfg == nil { + return SubagentProfile{}, false + } + subcfg, ok := cfg.Agents.Subagents[id] + if !ok { + return SubagentProfile{}, false + } + return profileFromConfig(id, subcfg), true +} + +func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentProfile { + status := "active" + if !subcfg.Enabled { + status = "disabled" + } + return normalizeSubagentProfile(SubagentProfile{ + AgentID: agentID, + Name: strings.TrimSpace(subcfg.DisplayName), + Role: strings.TrimSpace(subcfg.Role), + SystemPrompt: strings.TrimSpace(subcfg.SystemPrompt), + ToolAllowlist: append([]string(nil), subcfg.Tools.Allowlist...), + MemoryNamespace: strings.TrimSpace(subcfg.MemoryNamespace), + MaxRetries: subcfg.Runtime.MaxRetries, + RetryBackoff: subcfg.Runtime.RetryBackoffMs, + TimeoutSec: subcfg.Runtime.TimeoutSec, + MaxTaskChars: subcfg.Runtime.MaxTaskChars, + MaxResultChars: subcfg.Runtime.MaxResultChars, + Status: status, + ManagedBy: "config.json", + }) +} + type SubagentProfileTool struct { store *SubagentProfileStore } diff --git a/pkg/tools/subagent_profile_test.go b/pkg/tools/subagent_profile_test.go index ddb3e96..07cf39d 100644 --- a/pkg/tools/subagent_profile_test.go +++ b/pkg/tools/subagent_profile_test.go @@ -4,11 +4,13 @@ import ( "context" "strings" "testing" + "time" + + "clawgo/pkg/config" + "clawgo/pkg/runtimecfg" ) func TestSubagentProfileStoreNormalization(t *testing.T) { - t.Parallel() - store := NewSubagentProfileStore(t.TempDir()) saved, err := store.Upsert(SubagentProfile{ AgentID: "Coder Agent", @@ -42,8 +44,6 @@ func TestSubagentProfileStoreNormalization(t *testing.T) { } func TestSubagentManagerSpawnRejectsDisabledProfile(t *testing.T) { - t.Parallel() - workspace := t.TempDir() manager := NewSubagentManager(nil, workspace, nil, nil) manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { @@ -72,8 +72,6 @@ func TestSubagentManagerSpawnRejectsDisabledProfile(t *testing.T) { } func TestSubagentManagerSpawnResolvesProfileByRole(t *testing.T) { - t.Parallel() - workspace := t.TempDir() manager := NewSubagentManager(nil, workspace, nil, nil) store := manager.ProfileStore() @@ -113,4 +111,72 @@ func TestSubagentManagerSpawnResolvesProfileByRole(t *testing.T) { if len(task.ToolAllowlist) != 1 || task.ToolAllowlist[0] != "read_file" { t.Fatalf("expected allowlist from profile, got: %v", task.ToolAllowlist) } + _ = waitSubagentDone(t, manager, 4*time.Second) +} + +func TestSubagentProfileStoreReadsProfilesFromRuntimeConfig(t *testing.T) { + runtimecfg.Set(config.DefaultConfig()) + t.Cleanup(func() { + runtimecfg.Set(config.DefaultConfig()) + }) + + cfg := config.DefaultConfig() + cfg.Agents.Subagents["coder"] = config.SubagentConfig{ + Enabled: true, + DisplayName: "Code Agent", + Role: "coding", + SystemPrompt: "write code", + MemoryNamespace: "code-ns", + Tools: config.SubagentToolsConfig{ + Allowlist: []string{"read_file", "shell"}, + }, + Runtime: config.SubagentRuntimeConfig{ + MaxRetries: 2, + RetryBackoffMs: 2000, + TimeoutSec: 120, + MaxTaskChars: 4096, + MaxResultChars: 2048, + }, + } + runtimecfg.Set(cfg) + + store := NewSubagentProfileStore(t.TempDir()) + profile, ok, err := store.Get("coder") + if err != nil { + t.Fatalf("get failed: %v", err) + } + if !ok { + t.Fatalf("expected config-backed profile") + } + if profile.ManagedBy != "config.json" { + t.Fatalf("expected config ownership, got: %s", profile.ManagedBy) + } + if profile.Name != "Code Agent" || profile.Role != "coding" { + t.Fatalf("unexpected profile fields: %+v", profile) + } + if len(profile.ToolAllowlist) != 2 { + t.Fatalf("expected merged allowlist, got: %v", profile.ToolAllowlist) + } +} + +func TestSubagentProfileStoreRejectsWritesForConfigManagedProfiles(t *testing.T) { + runtimecfg.Set(config.DefaultConfig()) + t.Cleanup(func() { + runtimecfg.Set(config.DefaultConfig()) + }) + + cfg := config.DefaultConfig() + cfg.Agents.Subagents["tester"] = config.SubagentConfig{ + Enabled: true, + Role: "test", + } + runtimecfg.Set(cfg) + + store := NewSubagentProfileStore(t.TempDir()) + if _, err := store.Upsert(SubagentProfile{AgentID: "tester"}); err == nil { + t.Fatalf("expected config-managed upsert to fail") + } + if err := store.Delete("tester"); err == nil { + t.Fatalf("expected config-managed delete to fail") + } } diff --git a/pkg/tools/subagent_router.go b/pkg/tools/subagent_router.go new file mode 100644 index 0000000..eaaccfb --- /dev/null +++ b/pkg/tools/subagent_router.go @@ -0,0 +1,121 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "time" +) + +type RouterDispatchRequest struct { + Task string + Label string + Role string + AgentID string + ThreadID string + CorrelationID string + ParentRunID string + OriginChannel string + OriginChatID string + MaxRetries int + RetryBackoff int + TimeoutSec int + MaxTaskChars int + MaxResultChars int +} + +type RouterReply struct { + TaskID string + ThreadID string + CorrelationID string + AgentID string + Status string + Result string +} + +type SubagentRouter struct { + manager *SubagentManager +} + +func NewSubagentRouter(manager *SubagentManager) *SubagentRouter { + return &SubagentRouter{manager: manager} +} + +func (r *SubagentRouter) DispatchTask(ctx context.Context, req RouterDispatchRequest) (*SubagentTask, error) { + if r == nil || r.manager == nil { + return nil, fmt.Errorf("subagent router is not configured") + } + task, err := r.manager.SpawnTask(ctx, SubagentSpawnOptions{ + Task: req.Task, + Label: req.Label, + Role: req.Role, + AgentID: req.AgentID, + ThreadID: req.ThreadID, + CorrelationID: req.CorrelationID, + ParentRunID: req.ParentRunID, + OriginChannel: req.OriginChannel, + OriginChatID: req.OriginChatID, + MaxRetries: req.MaxRetries, + RetryBackoff: req.RetryBackoff, + TimeoutSec: req.TimeoutSec, + MaxTaskChars: req.MaxTaskChars, + MaxResultChars: req.MaxResultChars, + }) + if err != nil { + return nil, err + } + return task, nil +} + +func (r *SubagentRouter) WaitReply(ctx context.Context, taskID string, interval time.Duration) (*RouterReply, error) { + if r == nil || r.manager == nil { + return nil, fmt.Errorf("subagent router is not configured") + } + if interval <= 0 { + interval = 100 * time.Millisecond + } + taskID = strings.TrimSpace(taskID) + if taskID == "" { + return nil, fmt.Errorf("task id is required") + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + task, ok := r.manager.GetTask(taskID) + if ok && task != nil && task.Status != "running" { + return &RouterReply{ + TaskID: task.ID, + ThreadID: task.ThreadID, + CorrelationID: task.CorrelationID, + AgentID: task.AgentID, + Status: task.Status, + Result: strings.TrimSpace(task.Result), + }, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + } +} + +func (r *SubagentRouter) MergeResults(replies []*RouterReply) string { + if len(replies) == 0 { + return "" + } + var sb strings.Builder + for _, reply := range replies { + if reply == nil { + continue + } + sb.WriteString(fmt.Sprintf("[%s] agent=%s status=%s\n", reply.TaskID, reply.AgentID, reply.Status)) + if txt := strings.TrimSpace(reply.Result); txt != "" { + sb.WriteString(txt) + } else { + sb.WriteString("(empty result)") + } + sb.WriteString("\n\n") + } + return strings.TrimSpace(sb.String()) +} diff --git a/pkg/tools/subagent_router_test.go b/pkg/tools/subagent_router_test.go new file mode 100644 index 0000000..47c3a23 --- /dev/null +++ b/pkg/tools/subagent_router_test.go @@ -0,0 +1,49 @@ +package tools + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestSubagentRouterDispatchAndWaitReply(t *testing.T) { + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + return "router-result", nil + }) + router := NewSubagentRouter(manager) + + task, err := router.DispatchTask(context.Background(), RouterDispatchRequest{ + Task: "implement feature", + AgentID: "coder", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err != nil { + t.Fatalf("dispatch failed: %v", err) + } + if task.ThreadID == "" { + t.Fatalf("expected thread id on dispatched task") + } + + reply, err := router.WaitReply(context.Background(), task.ID, 20*time.Millisecond) + if err != nil { + t.Fatalf("wait reply failed: %v", err) + } + if reply.Status != "completed" || reply.Result != "router-result" { + t.Fatalf("unexpected reply: %+v", reply) + } +} + +func TestSubagentRouterMergeResults(t *testing.T) { + router := NewSubagentRouter(nil) + out := router.MergeResults([]*RouterReply{ + {TaskID: "subagent-1", AgentID: "coder", Status: "completed", Result: "done"}, + {TaskID: "subagent-2", AgentID: "tester", Status: "failed", Result: "boom"}, + }) + if !strings.Contains(out, "subagent-1") || !strings.Contains(out, "agent=tester") { + t.Fatalf("unexpected merged output: %s", out) + } +} diff --git a/pkg/tools/subagent_runtime_control_test.go b/pkg/tools/subagent_runtime_control_test.go index 5ec604a..1102c8a 100644 --- a/pkg/tools/subagent_runtime_control_test.go +++ b/pkg/tools/subagent_runtime_control_test.go @@ -147,6 +147,323 @@ func TestSubagentBroadcastIncludesFailureStatus(t *testing.T) { } } +func TestSubagentManagerRestoresPersistedRuns(t *testing.T) { + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + return "persisted", nil + }) + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "persist task", + AgentID: "coder", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + task := waitSubagentDone(t, manager, 4*time.Second) + if task.Status != "completed" { + t.Fatalf("expected completed task, got %s", task.Status) + } + + reloaded := NewSubagentManager(nil, workspace, nil, nil) + got, ok := reloaded.GetTask(task.ID) + if !ok { + t.Fatalf("expected persisted task to reload") + } + if got.Status != "completed" || got.Result != "persisted" { + t.Fatalf("unexpected restored task: %+v", got) + } + + _, err = reloaded.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "second task", + AgentID: "coder", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err != nil { + t.Fatalf("spawn after reload failed: %v", err) + } + tasks := reloaded.ListTasks() + found := false + for _, item := range tasks { + if item.ID == "subagent-2" { + found = true + break + } + } + if !found { + t.Fatalf("expected nextID seed to continue from persisted runs, got %+v", tasks) + } + _ = waitSubagentDone(t, reloaded, 4*time.Second) + time.Sleep(100 * time.Millisecond) +} + +func TestSubagentManagerPersistsEvents(t *testing.T) { + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + time.Sleep(100 * time.Millisecond) + return "ok", nil + }) + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "event task", + AgentID: "coder", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + time.Sleep(20 * time.Millisecond) + if !manager.SteerTask("subagent-1", "focus on tests") { + t.Fatalf("expected steer to succeed") + } + task := waitSubagentDone(t, manager, 4*time.Second) + events, err := manager.Events(task.ID, 0) + if err != nil { + t.Fatalf("events failed: %v", err) + } + if len(events) == 0 { + t.Fatalf("expected persisted events") + } + hasSteer := false + for _, evt := range events { + if evt.Type == "steered" { + hasSteer = true + break + } + } + if !hasSteer { + t.Fatalf("expected steered event, got %+v", events) + } +} + +func TestSubagentMailboxStoresThreadAndReplies(t *testing.T) { + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + return "done", nil + }) + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "implement feature", + AgentID: "coder", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + task := waitSubagentDone(t, manager, 4*time.Second) + if task.ThreadID == "" { + t.Fatalf("expected thread id") + } + thread, ok := manager.Thread(task.ThreadID) + if !ok { + t.Fatalf("expected thread to exist") + } + if thread.Owner != "main" { + t.Fatalf("expected thread owner main, got %s", thread.Owner) + } + + msgs, err := manager.ThreadMessages(task.ThreadID, 10) + if err != nil { + t.Fatalf("thread messages failed: %v", err) + } + if len(msgs) < 2 { + t.Fatalf("expected task and reply messages, got %+v", msgs) + } + if msgs[0].FromAgent != "main" || msgs[0].ToAgent != "coder" { + t.Fatalf("unexpected initial message: %+v", msgs[0]) + } + last := msgs[len(msgs)-1] + if last.FromAgent != "coder" || last.ToAgent != "main" || last.Type != "result" { + t.Fatalf("unexpected reply message: %+v", last) + } +} + +func TestSubagentMailboxInboxIncludesControlMessages(t *testing.T) { + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + time.Sleep(150 * time.Millisecond) + return "ok", nil + }) + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "run checks", + AgentID: "tester", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + time.Sleep(30 * time.Millisecond) + if !manager.SteerTask("subagent-1", "focus on regressions") { + t.Fatalf("expected steer to succeed") + } + inbox, err := manager.Inbox("tester", 10) + if err != nil { + t.Fatalf("inbox failed: %v", err) + } + if len(inbox) < 1 { + t.Fatalf("expected queued control message, got %+v", inbox) + } + foundControl := false + for _, msg := range inbox { + if msg.Type == "control" && strings.Contains(msg.Content, "regressions") { + foundControl = true + break + } + } + if !foundControl { + t.Fatalf("expected control message in inbox, got %+v", inbox) + } + _ = waitSubagentDone(t, manager, 4*time.Second) +} + +func TestSubagentMailboxReplyAndAckFlow(t *testing.T) { + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + time.Sleep(150 * time.Millisecond) + return "ok", nil + }) + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "review patch", + AgentID: "tester", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + time.Sleep(30 * time.Millisecond) + if !manager.SendTaskMessage("subagent-1", "please confirm scope") { + t.Fatalf("expected send to succeed") + } + + inbox, err := manager.Inbox("tester", 10) + if err != nil { + t.Fatalf("inbox failed: %v", err) + } + if len(inbox) == 0 { + t.Fatalf("expected inbox messages") + } + initial := inbox[0] + if !manager.ReplyToTask("subagent-1", initial.MessageID, "working on it") { + t.Fatalf("expected reply to succeed") + } + threadMsgs, err := manager.ThreadMessages(initial.ThreadID, 10) + if err != nil { + t.Fatalf("thread messages failed: %v", err) + } + foundReply := false + for _, msg := range threadMsgs { + if msg.Type == "reply" && msg.ReplyTo == initial.MessageID { + foundReply = true + break + } + } + if !foundReply { + t.Fatalf("expected reply message linked to %s, got %+v", initial.MessageID, threadMsgs) + } + if !manager.AckTaskMessage("subagent-1", initial.MessageID) { + t.Fatalf("expected ack to succeed") + } + updated, ok := manager.Message(initial.MessageID) + if !ok { + t.Fatalf("expected message lookup to succeed") + } + if updated.Status != "acked" { + t.Fatalf("expected acked status, got %+v", updated) + } + queuedInbox, err := manager.Inbox("tester", 10) + if err != nil { + t.Fatalf("queued inbox failed: %v", err) + } + for _, msg := range queuedInbox { + if msg.MessageID == initial.MessageID { + t.Fatalf("acked message should not remain in queued inbox: %+v", queuedInbox) + } + } + _ = waitSubagentDone(t, manager, 4*time.Second) +} + +func TestSubagentResumeConsumesQueuedThreadInbox(t *testing.T) { + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + observedQueued := make(chan int, 4) + manager.SetRunFunc(func(ctx context.Context, task *SubagentTask) (string, error) { + inbox, err := manager.TaskInbox(task.ID, 10) + if err != nil { + return "", err + } + observedQueued <- len(inbox) + return "ok", nil + }) + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "initial task", + AgentID: "coder", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + initial := waitSubagentDone(t, manager, 4*time.Second) + if queued := <-observedQueued; queued != 0 { + t.Fatalf("expected initial run to see empty queued inbox during execution, got %d", queued) + } + + if !manager.SendTaskMessage(initial.ID, "please address follow-up") { + t.Fatalf("expected send to succeed") + } + inbox, err := manager.Inbox("coder", 10) + if err != nil { + t.Fatalf("inbox failed: %v", err) + } + if len(inbox) == 0 { + t.Fatalf("expected queued inbox after send") + } + messageID := inbox[0].MessageID + + if _, ok := manager.ResumeTask(context.Background(), initial.ID); !ok { + t.Fatalf("expected resume to succeed") + } + _ = waitSubagentDone(t, manager, 4*time.Second) + if queued := <-observedQueued; queued != 0 { + t.Fatalf("expected resumed run to consume queued inbox before execution, got %d", queued) + } + remaining, err := manager.Inbox("coder", 10) + if err != nil { + t.Fatalf("remaining inbox failed: %v", err) + } + for _, msg := range remaining { + if msg.MessageID == messageID { + t.Fatalf("expected consumed message to leave queued inbox, got %+v", remaining) + } + } + stored, ok := manager.Message(messageID) + if !ok { + t.Fatalf("expected stored message lookup") + } + if stored.Status != "acked" { + t.Fatalf("expected consumed message to be acked, got %+v", stored) + } +} + func waitSubagentDone(t *testing.T, manager *SubagentManager, timeout time.Duration) *SubagentTask { t.Helper() deadline := time.Now().Add(timeout) diff --git a/pkg/tools/subagent_store.go b/pkg/tools/subagent_store.go new file mode 100644 index 0000000..fd903d5 --- /dev/null +++ b/pkg/tools/subagent_store.go @@ -0,0 +1,264 @@ +package tools + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" +) + +type SubagentRunEvent struct { + RunID string `json:"run_id"` + AgentID string `json:"agent_id,omitempty"` + Type string `json:"type"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + RetryCount int `json:"retry_count,omitempty"` + At int64 `json:"ts"` +} + +type SubagentRunStore struct { + dir string + runsPath string + eventsPath string + mu sync.RWMutex + runs map[string]*SubagentTask +} + +func NewSubagentRunStore(workspace string) *SubagentRunStore { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return nil + } + dir := filepath.Join(workspace, "agents", "runtime") + store := &SubagentRunStore{ + dir: dir, + runsPath: filepath.Join(dir, "subagent_runs.jsonl"), + eventsPath: filepath.Join(dir, "subagent_events.jsonl"), + runs: map[string]*SubagentTask{}, + } + _ = os.MkdirAll(dir, 0755) + _ = store.load() + return store +} + +func (s *SubagentRunStore) load() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.runs = map[string]*SubagentTask{} + f, err := os.Open(s.runsPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 2*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var task SubagentTask + if err := json.Unmarshal([]byte(line), &task); err != nil { + continue + } + cp := cloneSubagentTask(&task) + s.runs[task.ID] = cp + } + return scanner.Err() +} + +func (s *SubagentRunStore) AppendRun(task *SubagentTask) error { + if s == nil || task == nil { + return nil + } + cp := cloneSubagentTask(task) + data, err := json.Marshal(cp) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + if err := os.MkdirAll(s.dir, 0755); err != nil { + return err + } + f, err := os.OpenFile(s.runsPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + if _, err := f.Write(append(data, '\n')); err != nil { + return err + } + s.runs[cp.ID] = cp + return nil +} + +func (s *SubagentRunStore) AppendEvent(evt SubagentRunEvent) error { + if s == nil { + return nil + } + data, err := json.Marshal(evt) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if err := os.MkdirAll(s.dir, 0755); err != nil { + return err + } + f, err := os.OpenFile(s.eventsPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(append(data, '\n')) + return err +} + +func (s *SubagentRunStore) Get(runID string) (*SubagentTask, bool) { + if s == nil { + return nil, false + } + s.mu.RLock() + defer s.mu.RUnlock() + task, ok := s.runs[strings.TrimSpace(runID)] + if !ok { + return nil, false + } + return cloneSubagentTask(task), true +} + +func (s *SubagentRunStore) List() []*SubagentTask { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*SubagentTask, 0, len(s.runs)) + for _, task := range s.runs { + out = append(out, cloneSubagentTask(task)) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Created != out[j].Created { + return out[i].Created > out[j].Created + } + return out[i].ID > out[j].ID + }) + return out +} + +func (s *SubagentRunStore) Events(runID string, limit int) ([]SubagentRunEvent, error) { + if s == nil { + return nil, nil + } + f, err := os.Open(s.eventsPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + + runID = strings.TrimSpace(runID) + events := make([]SubagentRunEvent, 0) + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 2*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var evt SubagentRunEvent + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + if evt.RunID != runID { + continue + } + events = append(events, evt) + } + if err := scanner.Err(); err != nil { + return nil, err + } + sort.Slice(events, func(i, j int) bool { return events[i].At < events[j].At }) + if limit > 0 && len(events) > limit { + events = events[len(events)-limit:] + } + return events, nil +} + +func (s *SubagentRunStore) NextIDSeed() int { + if s == nil { + return 1 + } + s.mu.RLock() + defer s.mu.RUnlock() + maxSeq := 0 + for runID := range s.runs { + if n := parseSubagentSequence(runID); n > maxSeq { + maxSeq = n + } + } + if maxSeq <= 0 { + return 1 + } + return maxSeq + 1 +} + +func parseSubagentSequence(runID string) int { + runID = strings.TrimSpace(runID) + if !strings.HasPrefix(runID, "subagent-") { + return 0 + } + n, _ := strconv.Atoi(strings.TrimPrefix(runID, "subagent-")) + return n +} + +func cloneSubagentTask(task *SubagentTask) *SubagentTask { + if task == nil { + return nil + } + cp := *task + if len(task.ToolAllowlist) > 0 { + cp.ToolAllowlist = append([]string(nil), task.ToolAllowlist...) + } + if len(task.Steering) > 0 { + cp.Steering = append([]string(nil), task.Steering...) + } + if task.SharedState != nil { + cp.SharedState = make(map[string]interface{}, len(task.SharedState)) + for k, v := range task.SharedState { + cp.SharedState[k] = v + } + } + return &cp +} + +func formatSubagentEventLog(evt SubagentRunEvent) string { + base := fmt.Sprintf("- %d %s", evt.At, evt.Type) + if strings.TrimSpace(evt.Status) != "" { + base += fmt.Sprintf(" status=%s", evt.Status) + } + if evt.RetryCount > 0 { + base += fmt.Sprintf(" retry=%d", evt.RetryCount) + } + if strings.TrimSpace(evt.Message) != "" { + base += fmt.Sprintf(" msg=%s", strings.TrimSpace(evt.Message)) + } + return base +} diff --git a/pkg/tools/subagents_tool.go b/pkg/tools/subagents_tool.go index 564ae7a..8a02917 100644 --- a/pkg/tools/subagents_tool.go +++ b/pkg/tools/subagents_tool.go @@ -27,9 +27,13 @@ func (t *SubagentsTool) Parameters() map[string]interface{} { return map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "action": map[string]interface{}{"type": "string", "description": "list|info|kill|steer|send|log|resume"}, + "action": map[string]interface{}{"type": "string", "description": "list|info|kill|steer|send|log|resume|thread|inbox|reply|trace|ack"}, "id": map[string]interface{}{"type": "string", "description": "subagent id/#index/all for info/kill/steer/send/log"}, "message": map[string]interface{}{"type": "string", "description": "steering message for steer/send action"}, + "message_id": map[string]interface{}{"type": "string", "description": "message id for reply/ack"}, + "thread_id": map[string]interface{}{"type": "string", "description": "thread id for thread/trace action; defaults to task thread"}, + "agent_id": map[string]interface{}{"type": "string", "description": "agent id for inbox action; defaults to task agent"}, + "limit": map[string]interface{}{"type": "integer", "description": "max messages/events to show", "default": 20}, "recent_minutes": map[string]interface{}{"type": "integer", "description": "optional list/info all filter by recent updated minutes"}, }, "required": []string{"action"}, @@ -47,6 +51,16 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} id = strings.TrimSpace(id) message, _ := args["message"].(string) message = strings.TrimSpace(message) + messageID, _ := args["message_id"].(string) + messageID = strings.TrimSpace(messageID) + threadID, _ := args["thread_id"].(string) + threadID = strings.TrimSpace(threadID) + agentID, _ := args["agent_id"].(string) + agentID = strings.TrimSpace(agentID) + limit := 20 + if v, ok := args["limit"].(float64); ok && int(v) > 0 { + limit = int(v) + } recentMinutes := 0 if v, ok := args["recent_minutes"].(float64); ok && int(v) > 0 { recentMinutes = int(v) @@ -89,10 +103,20 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} if !ok { return "subagent not found", nil } - return fmt.Sprintf("ID: %s\nStatus: %s\nLabel: %s\nAgent ID: %s\nRole: %s\nSession Key: %s\nMemory Namespace: %s\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\nMax Task Chars: %d\nMax Result Chars: %d\nCreated: %d\nUpdated: %d\nSteering Count: %d\nTask: %s\nResult:\n%s", - task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, task.MemoryNS, + info := fmt.Sprintf("ID: %s\nStatus: %s\nLabel: %s\nAgent ID: %s\nRole: %s\nSession Key: %s\nThread ID: %s\nCorrelation ID: %s\nWaiting Reply: %t\nMemory Namespace: %s\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\nMax Task Chars: %d\nMax Result Chars: %d\nCreated: %d\nUpdated: %d\nSteering Count: %d\nTask: %s\nResult:\n%s", + task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, task.ThreadID, task.CorrelationID, task.WaitingReply, task.MemoryNS, task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec, task.MaxTaskChars, task.MaxResultChars, - task.Created, task.Updated, len(task.Steering), task.Task, task.Result), nil + task.Created, task.Updated, len(task.Steering), task.Task, task.Result) + if events, err := t.manager.Events(task.ID, 6); err == nil && len(events) > 0 { + var sb strings.Builder + sb.WriteString(info) + sb.WriteString("\nEvents:\n") + for _, evt := range events { + sb.WriteString(formatSubagentEventLog(evt) + "\n") + } + return strings.TrimSpace(sb.String()), nil + } + return info, nil case "kill": if strings.EqualFold(strings.TrimSpace(id), "all") { tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes) @@ -115,9 +139,9 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} return "subagent not found", nil } return "subagent kill requested", nil - case "steer", "send": + case "steer": if message == "" { - return "message is required for steer/send", nil + return "message is required for steer", nil } resolvedID, err := t.resolveTaskID(id) if err != nil { @@ -127,6 +151,105 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} return "subagent not found", nil } return "steering message accepted", nil + case "send": + if message == "" { + return "message is required for send", nil + } + resolvedID, err := t.resolveTaskID(id) + if err != nil { + return err.Error(), nil + } + if !t.manager.SendTaskMessage(resolvedID, message) { + return "subagent not found", nil + } + return "message sent", nil + case "reply": + if message == "" { + return "message is required for reply", nil + } + resolvedID, err := t.resolveTaskID(id) + if err != nil { + return err.Error(), nil + } + if !t.manager.ReplyToTask(resolvedID, messageID, message) { + return "subagent not found", nil + } + return "reply sent", nil + case "ack": + if messageID == "" { + return "message_id is required for ack", nil + } + resolvedID, err := t.resolveTaskID(id) + if err != nil { + return err.Error(), nil + } + if !t.manager.AckTaskMessage(resolvedID, messageID) { + return "subagent or message not found", nil + } + return "message acked", nil + case "thread", "trace": + if threadID == "" { + resolvedID, err := t.resolveTaskID(id) + if err != nil { + return err.Error(), nil + } + task, ok := t.manager.GetTask(resolvedID) + if !ok { + return "subagent not found", nil + } + threadID = task.ThreadID + } + if threadID == "" { + return "thread_id is required", nil + } + thread, ok := t.manager.Thread(threadID) + if !ok { + return "thread not found", nil + } + msgs, err := t.manager.ThreadMessages(threadID, limit) + if err != nil { + return "", err + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Thread: %s\nOwner: %s\nStatus: %s\nParticipants: %s\nTopic: %s\n", + thread.ThreadID, thread.Owner, thread.Status, strings.Join(thread.Participants, ","), thread.Topic)) + if len(msgs) > 0 { + sb.WriteString("Messages:\n") + for _, msg := range msgs { + sb.WriteString(fmt.Sprintf("- %s %s -> %s type=%s reply_to=%s status=%s\n %s\n", + msg.MessageID, msg.FromAgent, msg.ToAgent, msg.Type, msg.ReplyTo, msg.Status, msg.Content)) + } + } + return strings.TrimSpace(sb.String()), nil + case "inbox": + if agentID == "" { + resolvedID, err := t.resolveTaskID(id) + if err != nil { + return err.Error(), nil + } + task, ok := t.manager.GetTask(resolvedID) + if !ok { + return "subagent not found", nil + } + agentID = task.AgentID + } + if agentID == "" { + return "agent_id is required", nil + } + msgs, err := t.manager.Inbox(agentID, limit) + if err != nil { + return "", err + } + if len(msgs) == 0 { + return "No inbox messages.", nil + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Inbox for %s:\n", agentID)) + for _, msg := range msgs { + sb.WriteString(fmt.Sprintf("- %s thread=%s from=%s type=%s status=%s\n %s\n", + msg.MessageID, msg.ThreadID, msg.FromAgent, msg.Type, msg.Status, msg.Content)) + } + return strings.TrimSpace(sb.String()), nil case "log": resolvedID, err := t.resolveTaskID(id) if err != nil { @@ -139,14 +262,20 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} var sb strings.Builder sb.WriteString(fmt.Sprintf("Subagent %s Log\n", task.ID)) sb.WriteString(fmt.Sprintf("Status: %s\n", task.Status)) - sb.WriteString(fmt.Sprintf("Agent ID: %s\nRole: %s\nSession Key: %s\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\n", - task.AgentID, task.Role, task.SessionKey, task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec)) + sb.WriteString(fmt.Sprintf("Agent ID: %s\nRole: %s\nSession Key: %s\nThread ID: %s\nCorrelation ID: %s\nWaiting Reply: %t\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\n", + task.AgentID, task.Role, task.SessionKey, task.ThreadID, task.CorrelationID, task.WaitingReply, task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec)) if len(task.Steering) > 0 { sb.WriteString("Steering Messages:\n") for _, m := range task.Steering { sb.WriteString("- " + m + "\n") } } + if events, err := t.manager.Events(task.ID, 20); err == nil && len(events) > 0 { + sb.WriteString("Events:\n") + for _, evt := range events { + sb.WriteString(formatSubagentEventLog(evt) + "\n") + } + } if strings.TrimSpace(task.Result) != "" { result := strings.TrimSpace(task.Result) if len(result) > 500 { diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 0ce1007..15b23ff 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -20,6 +20,30 @@ const resources = { subagentsRuntime: 'Subagents Runtime', subagentDetail: 'Subagent Detail', spawnSubagent: 'Spawn Subagent', + dispatchAndWait: 'Dispatch And Wait', + dispatchReply: 'Dispatch Reply', + mergedResult: 'Merged Result', + configSubagentDraft: 'Config Subagent Draft', + agentRegistry: 'Agent Registry', + pendingSubagentDrafts: 'Pending Subagent Drafts', + subagentDraftDescription: 'Describe the subagent you want the main agent to create', + generateDraft: 'Generate Draft', + confirmDraft: 'Confirm Draft', + loadDraft: 'Load Draft', + discardDraft: 'Discard Draft', + enableAgent: 'Enable Agent', + disableAgent: 'Disable Agent', + deleteAgent: 'Delete Agent', + deleteAgentConfirm: 'Delete agent "{{id}}" from config.json permanently?', + noRegistryAgents: 'No configured agents.', + noPendingSubagentDrafts: 'No pending subagent drafts.', + saveToConfig: 'Save To Config', + configSubagentSaved: 'Subagent config saved and runtime updated.', + threadTrace: 'Thread Trace', + threadMessages: 'Thread Messages', + inbox: 'Inbox', + reply: 'Reply', + ack: 'Ack', steerMessage: 'Steering message', pipelines: 'Pipelines', pipelineDetail: 'Pipeline Detail', @@ -421,6 +445,30 @@ const resources = { subagentsRuntime: '子代理运行态', subagentDetail: '子代理详情', spawnSubagent: '创建子代理任务', + dispatchAndWait: '派发并等待', + dispatchReply: '派发回复', + mergedResult: '汇总结果', + configSubagentDraft: '配置子代理草案', + agentRegistry: '代理注册表', + pendingSubagentDrafts: '待确认子代理草案', + subagentDraftDescription: '描述你希望主代理创建的子代理职责', + generateDraft: '生成草案', + confirmDraft: '确认草案', + loadDraft: '载入草案', + discardDraft: '丢弃草案', + enableAgent: '启用代理', + disableAgent: '停用代理', + deleteAgent: '删除代理', + deleteAgentConfirm: '确认从 config.json 中永久删除代理 "{{id}}" 吗?', + noRegistryAgents: '当前没有已配置代理。', + noPendingSubagentDrafts: '当前没有待确认的子代理草案。', + saveToConfig: '写入配置', + configSubagentSaved: '子代理配置已写入并刷新运行态。', + threadTrace: '线程追踪', + threadMessages: '线程消息', + inbox: '收件箱', + reply: '回复', + ack: '确认', steerMessage: '引导消息', pipelines: '流水线', pipelineDetail: '流水线详情', diff --git a/webui/src/pages/Subagents.tsx b/webui/src/pages/Subagents.tsx index fee9b32..2e36859 100644 --- a/webui/src/pages/Subagents.tsx +++ b/webui/src/pages/Subagents.tsx @@ -22,6 +22,66 @@ type SubagentTask = { updated?: number; task?: string; result?: string; + thread_id?: string; + correlation_id?: string; + waiting_for_reply?: boolean; +}; + +type RouterReply = { + task_id?: string; + thread_id?: string; + correlation_id?: string; + agent_id?: string; + status?: string; + result?: string; +}; + +type AgentThread = { + thread_id?: string; + owner?: string; + participants?: string[]; + status?: string; + topic?: string; +}; + +type AgentMessage = { + message_id?: string; + thread_id?: string; + from_agent?: string; + to_agent?: string; + reply_to?: string; + correlation_id?: string; + type?: string; + content?: string; + requires_reply?: boolean; + status?: string; + created_at?: number; +}; + +type PendingSubagentDraft = { + session_key?: string; + draft?: { + agent_id?: string; + role?: string; + display_name?: string; + description?: string; + system_prompt?: string; + tool_allowlist?: string[]; + routing_keywords?: string[]; + }; +}; + +type RegistrySubagent = { + agent_id?: string; + enabled?: boolean; + type?: string; + display_name?: string; + role?: string; + description?: string; + system_prompt?: string; + memory_namespace?: string; + tool_allowlist?: string[]; + routing_keywords?: string[]; }; const Subagents: React.FC = () => { @@ -36,21 +96,51 @@ const Subagents: React.FC = () => { const [spawnRole, setSpawnRole] = useState(''); const [spawnLabel, setSpawnLabel] = useState(''); const [steerMessage, setSteerMessage] = useState(''); + const [dispatchTask, setDispatchTask] = useState(''); + const [dispatchAgentID, setDispatchAgentID] = useState(''); + const [dispatchRole, setDispatchRole] = useState(''); + const [dispatchWaitTimeout, setDispatchWaitTimeout] = useState('120'); + const [dispatchReply, setDispatchReply] = useState(null); + const [dispatchMerged, setDispatchMerged] = useState(''); + const [threadDetail, setThreadDetail] = useState(null); + const [threadMessages, setThreadMessages] = useState([]); + const [inboxMessages, setInboxMessages] = useState([]); + const [replyMessage, setReplyMessage] = useState(''); + const [replyToMessageID, setReplyToMessageID] = useState(''); + const [configAgentID, setConfigAgentID] = useState(''); + const [configRole, setConfigRole] = useState(''); + const [configDisplayName, setConfigDisplayName] = useState(''); + const [configSystemPrompt, setConfigSystemPrompt] = useState(''); + const [configToolAllowlist, setConfigToolAllowlist] = useState(''); + const [configRoutingKeywords, setConfigRoutingKeywords] = useState(''); + const [draftDescription, setDraftDescription] = useState(''); + const [pendingDrafts, setPendingDrafts] = useState([]); + const [registryItems, setRegistryItems] = useState([]); const apiPath = '/webui/api/subagents_runtime'; const withAction = (action: string) => `${apiPath}${q}${q ? '&' : '?'}action=${encodeURIComponent(action)}`; const load = async () => { - const r = await fetch(withAction('list')); - if (!r.ok) throw new Error(await r.text()); - const j = await r.json(); + const [tasksRes, draftsRes, registryRes] = await Promise.all([ + fetch(withAction('list')), + fetch(withAction('pending_drafts')), + fetch(withAction('registry')), + ]); + if (!tasksRes.ok) throw new Error(await tasksRes.text()); + if (!draftsRes.ok) throw new Error(await draftsRes.text()); + if (!registryRes.ok) throw new Error(await registryRes.text()); + const j = await tasksRes.json(); + const draftsJson = await draftsRes.json(); + const registryJson = await registryRes.json(); const arr = Array.isArray(j?.result?.items) ? j.result.items : []; + const draftItems = Array.isArray(draftsJson?.result?.items) ? draftsJson.result.items : []; + const registryItems = Array.isArray(registryJson?.result?.items) ? registryJson.result.items : []; setItems(arr); + setPendingDrafts(draftItems); + setRegistryItems(registryItems); if (arr.length === 0) { setSelectedId(''); - return; - } - if (!arr.find((x: SubagentTask) => x.id === selectedId)) { + } else if (!arr.find((x: SubagentTask) => x.id === selectedId)) { setSelectedId(arr[0].id || ''); } }; @@ -74,6 +164,26 @@ const Subagents: React.FC = () => { return r.json(); }; + const loadThreadAndInbox = async (task: SubagentTask | null) => { + if (!task?.id) { + setThreadDetail(null); + setThreadMessages([]); + setInboxMessages([]); + return; + } + const [threadRes, inboxRes] = await Promise.all([ + callAction({ action: 'thread', id: task.id, limit: 50 }), + callAction({ action: 'inbox', id: task.id, limit: 50 }), + ]); + setThreadDetail(threadRes?.result?.thread || null); + setThreadMessages(Array.isArray(threadRes?.result?.messages) ? threadRes.result.messages : []); + setInboxMessages(Array.isArray(inboxRes?.result?.messages) ? inboxRes.result.messages : []); + }; + + useEffect(() => { + loadThreadAndInbox(selected).catch(() => {}); + }, [selectedId, q, items]); + const spawn = async () => { if (!spawnTask.trim()) { await ui.notify({ title: t('requestFailed'), message: 'task is required' }); @@ -108,6 +218,158 @@ const Subagents: React.FC = () => { await callAction({ action: 'steer', id: selected.id, message: steerMessage }); setSteerMessage(''); await load(); + await loadThreadAndInbox(selected); + }; + + const dispatchAndWait = async () => { + if (!dispatchTask.trim()) { + await ui.notify({ title: t('requestFailed'), message: 'task is required' }); + return; + } + const waitTimeout = Number.parseInt(dispatchWaitTimeout, 10); + const data = await callAction({ + action: 'dispatch_and_wait', + task: dispatchTask, + agent_id: dispatchAgentID, + role: dispatchRole, + wait_timeout_sec: Number.isFinite(waitTimeout) && waitTimeout > 0 ? waitTimeout : 120, + }); + if (!data) return; + setDispatchReply(data?.result?.reply || null); + setDispatchMerged(data?.result?.merged || ''); + await load(); + }; + + const sendMessage = async () => { + if (!selected?.id || !replyMessage.trim()) return; + await callAction({ action: 'send', id: selected.id, message: replyMessage }); + setReplyMessage(''); + setReplyToMessageID(''); + await load(); + await loadThreadAndInbox(selected); + }; + + const replyToMessage = async () => { + if (!selected?.id || !replyMessage.trim()) return; + await callAction({ action: 'reply', id: selected.id, message_id: replyToMessageID, message: replyMessage }); + setReplyMessage(''); + setReplyToMessageID(''); + await load(); + await loadThreadAndInbox(selected); + }; + + const ackMessage = async (messageID: string) => { + if (!selected?.id || !messageID) return; + await callAction({ action: 'ack', id: selected.id, message_id: messageID }); + await load(); + await loadThreadAndInbox(selected); + }; + + const upsertConfigSubagent = async () => { + if (!configAgentID.trim()) { + await ui.notify({ title: t('requestFailed'), message: 'agent_id is required' }); + return; + } + const toolAllowlist = configToolAllowlist + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + const routingKeywords = configRoutingKeywords + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + const data = await callAction({ + action: 'upsert_config_subagent', + agent_id: configAgentID, + role: configRole, + display_name: configDisplayName, + system_prompt: configSystemPrompt, + tool_allowlist: toolAllowlist, + routing_keywords: routingKeywords, + }); + if (!data) return; + await ui.notify({ title: t('saved'), message: t('configSubagentSaved') }); + setConfigAgentID(''); + setConfigRole(''); + setConfigDisplayName(''); + setConfigSystemPrompt(''); + setConfigToolAllowlist(''); + setConfigRoutingKeywords(''); + await load(); + }; + + const draftConfigSubagent = async () => { + if (!draftDescription.trim()) { + await ui.notify({ title: t('requestFailed'), message: 'description is required' }); + return; + } + const data = await callAction({ + action: 'draft_config_subagent', + description: draftDescription, + agent_id_hint: configAgentID, + }); + if (!data) return; + const draft = data?.result?.draft || {}; + setConfigAgentID(draft.agent_id || ''); + setConfigRole(draft.role || ''); + setConfigDisplayName(draft.display_name || ''); + setConfigSystemPrompt(draft.system_prompt || ''); + setConfigToolAllowlist(Array.isArray(draft.tool_allowlist) ? draft.tool_allowlist.join(', ') : ''); + setConfigRoutingKeywords(Array.isArray(draft.routing_keywords) ? draft.routing_keywords.join(', ') : ''); + await load(); + }; + + const fillDraftForm = (draft: PendingSubagentDraft['draft']) => { + if (!draft) return; + setConfigAgentID(draft.agent_id || ''); + setConfigRole(draft.role || ''); + setConfigDisplayName(draft.display_name || ''); + setConfigSystemPrompt(draft.system_prompt || ''); + setConfigToolAllowlist(Array.isArray(draft.tool_allowlist) ? draft.tool_allowlist.join(', ') : ''); + setConfigRoutingKeywords(Array.isArray(draft.routing_keywords) ? draft.routing_keywords.join(', ') : ''); + }; + + const clearPendingDraft = async (sessionKey: string) => { + const data = await callAction({ action: 'clear_pending_draft', session_key: sessionKey }); + if (!data) return; + await load(); + }; + + const confirmPendingDraft = async (sessionKey: string) => { + const data = await callAction({ action: 'confirm_pending_draft', session_key: sessionKey }); + if (!data) return; + await ui.notify({ title: t('saved'), message: data?.result?.message || t('configSubagentSaved') }); + await load(); + }; + + const loadRegistryItem = (item: RegistrySubagent) => { + setConfigAgentID(item.agent_id || ''); + setConfigRole(item.role || ''); + setConfigDisplayName(item.display_name || ''); + setConfigSystemPrompt(item.system_prompt || ''); + setConfigToolAllowlist(Array.isArray(item.tool_allowlist) ? item.tool_allowlist.join(', ') : ''); + setConfigRoutingKeywords(Array.isArray(item.routing_keywords) ? item.routing_keywords.join(', ') : ''); + }; + + const setRegistryEnabled = async (item: RegistrySubagent, enabled: boolean) => { + if (!item.agent_id) return; + const data = await callAction({ action: 'set_config_subagent_enabled', agent_id: item.agent_id, enabled }); + if (!data) return; + await load(); + }; + + const deleteRegistryItem = async (item: RegistrySubagent) => { + if (!item.agent_id) return; + const ok = await ui.confirmDialog({ + title: t('deleteAgent'), + message: t('deleteAgentConfirm', { id: item.agent_id }), + danger: true, + confirmText: t('delete'), + }); + if (!ok) return; + const data = await callAction({ action: 'delete_config_subagent', agent_id: item.agent_id }); + if (!data) return; + await load(); }; return ( @@ -147,9 +409,12 @@ const Subagents: React.FC = () => {
Agent ID: {selected.agent_id || '-'}
Role: {selected.role || '-'}
Session: {selected.session_key || '-'}
+
Thread: {selected.thread_id || '-'}
+
Correlation: {selected.correlation_id || '-'}
Memory NS: {selected.memory_ns || '-'}
Retries: {selected.retry_count || 0}/{selected.max_retries || 0}
Timeout: {selected.timeout_sec || 0}s
+
Waiting Reply: {selected.waiting_for_reply ? 'yes' : 'no'}
Task
{selected.task || '-'}
@@ -187,6 +452,166 @@ const Subagents: React.FC = () => { + +
+
+
{t('agentRegistry')}
+ +
+
+ {registryItems.map((item) => ( +
+
{item.agent_id || '-'} · {item.role || '-'} · {item.enabled ? t('active') : t('paused')}
+
{item.type || '-'} · {item.display_name || '-'}
+
{item.system_prompt || item.description || '-'}
+
{(item.routing_keywords || []).join(', ') || '-'}
+
+ + + {item.agent_id !== 'main' && ( + + )} +
+
+ ))} + {registryItems.length === 0 &&
{t('noRegistryAgents')}
} +
+
+ +
+
{t('configSubagentDraft')}
+