From cc04d9ab3aff537614b58686ef217a3e32c5dad7 Mon Sep 17 00:00:00 2001 From: lpf Date: Fri, 6 Mar 2026 15:14:58 +0800 Subject: [PATCH] Unify agent topology and subagent memory logging --- config.example.json | 16 + pkg/agent/context.go | 2 - pkg/agent/loop.go | 162 +++++- pkg/agent/memory_log_test.go | 66 +++ pkg/agent/runtime_admin.go | 114 ++-- pkg/agent/runtime_admin_test.go | 100 ---- pkg/agent/subagent_config_draft_store.go | 141 ----- pkg/agent/subagent_config_intent.go | 140 +---- pkg/agent/subagent_config_intent_test.go | 145 +---- pkg/agent/subagent_node_test.go | 58 ++ pkg/api/server.go | 204 ++++++- pkg/config/config.go | 3 + pkg/config/validate.go | 13 +- pkg/config/validate_test.go | 23 + pkg/tools/subagent.go | 15 + pkg/tools/subagent_config_manager.go | 14 +- pkg/tools/subagent_config_tool.go | 19 +- pkg/tools/subagent_config_tool_test.go | 22 - pkg/tools/subagent_profile.go | 111 ++++ pkg/tools/subagent_profile_test.go | 49 ++ webui/src/App.tsx | 2 - webui/src/components/Sidebar.tsx | 5 +- webui/src/context/AppContext.tsx | 9 +- webui/src/i18n/index.ts | 54 +- webui/src/pages/Nodes.tsx | 61 --- webui/src/pages/Subagents.tsx | 649 +++++++++++++++++++---- webui/src/types/index.ts | 2 +- 27 files changed, 1408 insertions(+), 791 deletions(-) create mode 100644 pkg/agent/memory_log_test.go delete mode 100644 pkg/agent/subagent_config_draft_store.go create mode 100644 pkg/agent/subagent_node_test.go delete mode 100644 webui/src/pages/Nodes.tsx diff --git a/config.example.json b/config.example.json index 4aa4b25..0414940 100644 --- a/config.example.json +++ b/config.example.json @@ -134,6 +134,22 @@ "retry_backoff_ms": 1000, "max_parallel_runs": 2 } + }, + "node.edge-dev.main": { + "enabled": true, + "type": "worker", + "transport": "node", + "node_id": "edge-dev", + "parent_agent_id": "main", + "display_name": "Edge Dev Main Agent", + "role": "remote_main", + "memory_namespace": "node.edge-dev.main", + "runtime": { + "timeout_sec": 1200, + "max_retries": 1, + "retry_backoff_ms": 1000, + "max_parallel_runs": 1 + } } } }, diff --git a/pkg/agent/context.go b/pkg/agent/context.go index caefc87..6e5dce2 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -88,8 +88,6 @@ 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 98bf0ae..2d2eb02 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -28,6 +28,7 @@ import ( "clawgo/pkg/logger" "clawgo/pkg/nodes" "clawgo/pkg/providers" + "clawgo/pkg/runtimecfg" "clawgo/pkg/scheduling" "clawgo/pkg/session" "clawgo/pkg/tools" @@ -63,8 +64,7 @@ type AgentLoop struct { orchestrator *tools.Orchestrator subagentRouter *tools.SubagentRouter subagentConfigTool *tools.SubagentConfigTool - pendingSubagentDraft map[string]map[string]interface{} - pendingDraftStore *PendingSubagentDraftStore + nodeRouter *nodes.Router configPath string } @@ -188,7 +188,6 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers 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)) @@ -261,11 +260,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers orchestrator: orchestrator, subagentRouter: subagentRouter, subagentConfigTool: subagentConfigTool, - pendingSubagentDraft: map[string]map[string]interface{}{}, - pendingDraftStore: pendingDraftStore, - } - if pendingDraftStore != nil { - loop.pendingSubagentDraft = pendingDraftStore.All() + nodeRouter: nodesRouter, } // Initialize provider fallback chain (primary + proxy_fallbacks). loop.providerPool = map[string]providers.LLMProvider{} @@ -309,6 +304,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers if task == nil { return "", fmt.Errorf("subagent task is nil") } + if strings.EqualFold(strings.TrimSpace(task.Transport), "node") { + return loop.dispatchNodeSubagentTask(ctx, task) + } sessionKey := strings.TrimSpace(task.SessionKey) if sessionKey == "" { sessionKey = fmt.Sprintf("subagent:%s", strings.TrimSpace(task.ID)) @@ -320,6 +318,61 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers return loop } +func (al *AgentLoop) dispatchNodeSubagentTask(ctx context.Context, task *tools.SubagentTask) (string, error) { + if al == nil || task == nil { + return "", fmt.Errorf("node subagent task is nil") + } + if al.nodeRouter == nil { + return "", fmt.Errorf("node router is not configured") + } + nodeID := strings.TrimSpace(task.NodeID) + if nodeID == "" { + return "", fmt.Errorf("node-backed subagent %q missing node_id", task.AgentID) + } + taskInput := loopTaskInputForNode(task) + resp, err := al.nodeRouter.Dispatch(ctx, nodes.Request{ + Action: "agent_task", + Node: nodeID, + Task: taskInput, + }, "auto") + if err != nil { + return "", err + } + if !resp.OK { + if strings.TrimSpace(resp.Error) != "" { + return "", fmt.Errorf("node %s agent_task failed: %s", nodeID, strings.TrimSpace(resp.Error)) + } + return "", fmt.Errorf("node %s agent_task failed", nodeID) + } + if result := nodeAgentTaskResult(resp.Payload); result != "" { + return result, nil + } + return fmt.Sprintf("node %s completed agent_task", nodeID), nil +} + +func loopTaskInputForNode(task *tools.SubagentTask) string { + if task == nil { + return "" + } + if parent := strings.TrimSpace(task.ParentAgentID); parent != "" { + return fmt.Sprintf("Parent Agent: %s\nSubtree Branch: %s\n\nTask:\n%s", parent, task.AgentID, strings.TrimSpace(task.Task)) + } + return strings.TrimSpace(task.Task) +} + +func nodeAgentTaskResult(payload map[string]interface{}) string { + if len(payload) == 0 { + return "" + } + if result, _ := payload["result"].(string); strings.TrimSpace(result) != "" { + return strings.TrimSpace(result) + } + if content, _ := payload["content"].(string); strings.TrimSpace(content) != "" { + return strings.TrimSpace(content) + } + return "" +} + func (al *AgentLoop) buildSubagentTaskInput(task *tools.SubagentTask) string { if task == nil { return "" @@ -1109,27 +1162,96 @@ func (al *AgentLoop) appendDailySummaryLog(msg bus.InboundMessage, response stri if strings.TrimSpace(al.workspace) == "" { return } - userText := strings.TrimSpace(msg.Content) respText := strings.TrimSpace(response) - if userText == "" && respText == "" { + if respText == "" { return } // Avoid noisy heartbeat/system boilerplate. - lc := strings.ToLower(userText) + lc := strings.ToLower(strings.TrimSpace(msg.Content)) if strings.Contains(lc, "heartbeat") && strings.Contains(strings.ToLower(respText), "heartbeat_ok") { return } - ms := NewMemoryStore(al.workspace) - line := fmt.Sprintf("- [%s] channel=%s session=%s\n - user: %s\n - result: %s", - time.Now().Format("15:04"), - strings.TrimSpace(msg.Channel), - strings.TrimSpace(msg.SessionKey), - truncate(strings.ReplaceAll(userText, "\n", " "), 180), - truncate(strings.ReplaceAll(respText, "\n", " "), 220), - ) - if err := ms.AppendToday(line); err != nil { + namespace := resolveInboundMemoryNamespace(msg) + detailLine := al.buildDailySummaryEntry(msg, namespace, respText) + ms := NewMemoryStoreWithNamespace(al.workspace, namespace) + if err := ms.AppendToday(detailLine); err != nil { logger.WarnCF("agent", logger.C0158, map[string]interface{}{logger.FieldError: err.Error()}) } + if namespace != "main" { + mainLine := al.buildMainMemorySubagentEntry(msg, namespace, respText) + if err := NewMemoryStore(al.workspace).AppendToday(mainLine); err != nil { + logger.WarnCF("agent", logger.C0158, map[string]interface{}{"target": "main", logger.FieldError: err.Error()}) + } + } +} + +func (al *AgentLoop) buildDailySummaryEntry(msg bus.InboundMessage, namespace, response string) string { + title := al.dailySummaryTitle(msg, namespace) + return fmt.Sprintf("## %s %s\n\n- Did: %s\n- Channel: %s\n- Session: %s", + time.Now().Format("15:04"), + title, + truncate(strings.ReplaceAll(strings.TrimSpace(response), "\n", " "), 320), + strings.TrimSpace(msg.Channel), + strings.TrimSpace(msg.SessionKey), + ) +} + +func (al *AgentLoop) buildMainMemorySubagentEntry(msg bus.InboundMessage, namespace, response string) string { + title := al.dailySummaryTitle(msg, namespace) + return fmt.Sprintf("## %s %s\n\n- Subagent: %s\n- Did: %s\n- Session: %s", + time.Now().Format("15:04"), + title, + al.memoryAgentTitle(namespace), + truncate(strings.ReplaceAll(strings.TrimSpace(response), "\n", " "), 220), + strings.TrimSpace(msg.SessionKey), + ) +} + +func (al *AgentLoop) dailySummaryTitle(msg bus.InboundMessage, namespace string) string { + agentName := al.memoryAgentTitle(namespace) + taskTitle := summarizeMemoryTaskTitle(msg.Content) + if taskTitle == "" { + return agentName + } + return fmt.Sprintf("%s | %s", agentName, taskTitle) +} + +func (al *AgentLoop) memoryAgentTitle(namespace string) string { + ns := normalizeMemoryNamespace(namespace) + if ns == "main" { + return "Main Agent" + } + cfg := runtimecfg.Get() + if cfg != nil { + if subcfg, ok := cfg.Agents.Subagents[ns]; ok { + if name := strings.TrimSpace(subcfg.DisplayName); name != "" { + return name + } + } + } + return strings.ReplaceAll(ns, "-", " ") +} + +func summarizeMemoryTaskTitle(content string) string { + text := strings.TrimSpace(content) + if text == "" { + return "" + } + if idx := strings.Index(text, "Task:\n"); idx >= 0 { + text = strings.TrimSpace(text[idx+len("Task:\n"):]) + } + text = strings.ReplaceAll(text, "\r\n", "\n") + if idx := strings.Index(text, "\n"); idx >= 0 { + text = text[:idx] + } + text = strings.TrimSpace(text) + if text == "" { + return "" + } + if len(text) > 72 { + return strings.TrimSpace(text[:69]) + "..." + } + return text } func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { diff --git a/pkg/agent/memory_log_test.go b/pkg/agent/memory_log_test.go new file mode 100644 index 0000000..7d40385 --- /dev/null +++ b/pkg/agent/memory_log_test.go @@ -0,0 +1,66 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "clawgo/pkg/bus" + "clawgo/pkg/config" + "clawgo/pkg/runtimecfg" +) + +func TestAppendDailySummaryLogUsesSubagentNamespaceAndTitle(t *testing.T) { + workspace := t.TempDir() + cfg := config.DefaultConfig() + cfg.Agents.Subagents["coder"] = config.SubagentConfig{ + Enabled: true, + DisplayName: "Code Agent", + SystemPromptFile: "agents/coder/AGENT.md", + } + runtimecfg.Set(cfg) + t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) + + loop := &AgentLoop{workspace: workspace} + loop.appendDailySummaryLog(bus.InboundMessage{ + Channel: "cli", + SessionKey: "subagent:coder:subagent-1", + Content: "Role Profile Policy (agents/coder/AGENT.md):\n...\n\nTask:\n修复登录接口并补测试\nextra details", + Metadata: map[string]string{ + "memory_ns": "coder", + }, + }, "完成了登录接口修复、增加回归测试,并验证通过。") + + entries, err := os.ReadFile(filepath.Join(workspace, "agents", "coder", "memory", currentDateFileName())) + if err != nil { + t.Fatalf("read namespaced daily note failed: %v", err) + } + content := string(entries) + if !strings.Contains(content, "Code Agent | 修复登录接口并补测试") { + t.Fatalf("expected agent title + task summary, got %s", content) + } + if !strings.Contains(content, "- Did: 完成了登录接口修复、增加回归测试,并验证通过。") { + t.Fatalf("expected did summary, got %s", content) + } + mainToday := filepath.Join(workspace, "memory", currentDateFileName()) + mainEntries, err := os.ReadFile(mainToday) + if err != nil { + t.Fatalf("expected main memory summary to be written, got %v", err) + } + mainContent := string(mainEntries) + if !strings.Contains(mainContent, "Code Agent | 修复登录接口并补测试") { + t.Fatalf("expected main memory to include subagent title, got %s", mainContent) + } + if !strings.Contains(mainContent, "- Subagent: Code Agent") { + t.Fatalf("expected main memory to include subagent name, got %s", mainContent) + } + if !strings.Contains(mainContent, "- Did: 完成了登录接口修复、增加回归测试,并验证通过。") { + t.Fatalf("expected main memory to include summary, got %s", mainContent) + } +} + +func currentDateFileName() string { + return time.Now().Format("2006-01-02") + ".md" +} diff --git a/pkg/agent/runtime_admin.go b/pkg/agent/runtime_admin.go index ef8cf1d..234188a 100644 --- a/pkg/agent/runtime_admin.go +++ b/pkg/agent/runtime_admin.go @@ -112,42 +112,66 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a "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 { - promptFileFound := false - if strings.TrimSpace(subcfg.SystemPromptFile) != "" { - if absPath, err := al.resolvePromptFilePath(subcfg.SystemPromptFile); err == nil { - if info, statErr := os.Stat(absPath); statErr == nil && !info.IsDir() { - promptFileFound = true + items := make([]map[string]interface{}, 0) + if cfg != nil { + items = make([]map[string]interface{}, 0, len(cfg.Agents.Subagents)) + for agentID, subcfg := range cfg.Agents.Subagents { + promptFileFound := false + if strings.TrimSpace(subcfg.SystemPromptFile) != "" { + if absPath, err := al.resolvePromptFilePath(subcfg.SystemPromptFile); err == nil { + if info, statErr := os.Stat(absPath); statErr == nil && !info.IsDir() { + promptFileFound = true + } } } + items = append(items, map[string]interface{}{ + "agent_id": agentID, + "enabled": subcfg.Enabled, + "type": subcfg.Type, + "transport": fallbackString(strings.TrimSpace(subcfg.Transport), "local"), + "node_id": strings.TrimSpace(subcfg.NodeID), + "parent_agent_id": strings.TrimSpace(subcfg.ParentAgentID), + "display_name": subcfg.DisplayName, + "role": subcfg.Role, + "description": subcfg.Description, + "system_prompt": subcfg.SystemPrompt, + "system_prompt_file": subcfg.SystemPromptFile, + "prompt_file_found": promptFileFound, + "memory_namespace": subcfg.MemoryNamespace, + "tool_allowlist": append([]string(nil), subcfg.Tools.Allowlist...), + "routing_keywords": routeKeywordsForRegistry(cfg.Agents.Router.Rules, agentID), + "managed_by": "config.json", + }) + } + } + if store := sm.ProfileStore(); store != nil { + if profiles, err := store.List(); err == nil { + for _, profile := range profiles { + if strings.TrimSpace(profile.ManagedBy) != "node_registry" { + continue + } + items = append(items, map[string]interface{}{ + "agent_id": profile.AgentID, + "enabled": strings.EqualFold(strings.TrimSpace(profile.Status), "active"), + "type": "node_branch", + "transport": profile.Transport, + "node_id": profile.NodeID, + "parent_agent_id": profile.ParentAgentID, + "display_name": profile.Name, + "role": profile.Role, + "description": "Node-registered remote main agent branch", + "system_prompt": profile.SystemPrompt, + "system_prompt_file": profile.SystemPromptFile, + "prompt_file_found": false, + "memory_namespace": profile.MemoryNamespace, + "tool_allowlist": append([]string(nil), profile.ToolAllowlist...), + "routing_keywords": []string{}, + "managed_by": profile.ManagedBy, + }) + } } - 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, - "system_prompt_file": subcfg.SystemPromptFile, - "prompt_file_found": promptFileFound, - "memory_namespace": subcfg.MemoryNamespace, - "tool_allowlist": append([]string(nil), subcfg.Tools.Allowlist...), - "routing_keywords": routeKeywordsForRegistry(cfg.Agents.Router.Rules, agentID), - }) } sort.Slice(items, func(i, j int) bool { left, _ := items[i]["agent_id"].(string) @@ -155,34 +179,6 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a 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 == "" { diff --git a/pkg/agent/runtime_admin_test.go b/pkg/agent/runtime_admin_test.go index 22f6075..840f54e 100644 --- a/pkg/agent/runtime_admin_test.go +++ b/pkg/agent/runtime_admin_test.go @@ -108,106 +108,6 @@ func TestHandleSubagentRuntimeUpsertConfigSubagent(t *testing.T) { } } -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", - SystemPromptFile: "agents/main/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - manager := tools.NewSubagentManager(nil, workspace, nil, nil) - loop := &AgentLoop{ - configPath: configPath, - subagentManager: manager, - subagentRouter: tools.NewSubagentRouter(manager), - pendingSubagentDraft: map[string]map[string]interface{}{"main": {"agent_id": "tester", "role": "testing", "type": "worker", "system_prompt_file": "agents/tester/AGENT.md"}}, - } - out, err := loop.HandleSubagentRuntime(context.Background(), "confirm_pending_draft", map[string]interface{}{"session_key": "main"}) - if err != nil { - 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") diff --git a/pkg/agent/subagent_config_draft_store.go b/pkg/agent/subagent_config_draft_store.go deleted file mode 100644 index 69917fb..0000000 --- a/pkg/agent/subagent_config_draft_store.go +++ /dev/null @@ -1,141 +0,0 @@ -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 index 4e2c428..ed1935c 100644 --- a/pkg/agent/subagent_config_intent.go +++ b/pkg/agent/subagent_config_intent.go @@ -3,6 +3,7 @@ package agent import ( "context" "fmt" + "path/filepath" "strings" "clawgo/pkg/bus" @@ -21,12 +22,6 @@ func (al *AgentLoop) maybeHandleSubagentConfigIntent(ctx context.Context, msg bu 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 } @@ -35,8 +30,11 @@ func (al *AgentLoop) maybeHandleSubagentConfigIntent(ctx context.Context, msg bu return "", false, nil } draft := tools.DraftConfigSubagent(description, "") - al.storePendingSubagentDraft(msg.SessionKey, draft) - return formatSubagentDraftForUser(draft), true, nil + result, err := tools.UpsertConfigSubagent(al.configPath, draft) + if err != nil { + return "", true, fmt.Errorf("persist subagent config to %s failed: %w", al.displayConfigPath(), err) + } + return formatCreatedSubagentForUser(result, al.displayConfigPath()), true, nil } func looksLikeSubagentCreateRequest(content string) bool { @@ -69,34 +67,6 @@ func looksLikeSubagentCreateRequest(content string) bool { 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{ @@ -117,93 +87,25 @@ func extractSubagentDescription(content string) string { return out } -func formatSubagentDraftForUser(draft map[string]interface{}) string { +func formatCreatedSubagentForUser(result map[string]interface{}, configPath string) 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"], + "subagent 已写入 config.json。\npath: %s\nagent_id: %v\nrole: %v\ndisplay_name: %v\ntool_allowlist: %v\nrouting_keywords: %v\nsystem_prompt_file: %v", + configPath, + result["agent_id"], + result["role"], + result["display_name"], + result["tool_allowlist"], + result["routing_keywords"], + result["system_prompt_file"], ) } -func (al *AgentLoop) storePendingSubagentDraft(sessionKey string, draft map[string]interface{}) { - if al == nil || draft == nil { - return +func (al *AgentLoop) displayConfigPath() string { + if al == nil || strings.TrimSpace(al.configPath) == "" { + return "config path not configured" } - 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) + if abs, err := filepath.Abs(al.configPath); err == nil { + return abs } -} - -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 + return strings.TrimSpace(al.configPath) } diff --git a/pkg/agent/subagent_config_intent_test.go b/pkg/agent/subagent_config_intent_test.go index baec572..85703e4 100644 --- a/pkg/agent/subagent_config_intent_test.go +++ b/pkg/agent/subagent_config_intent_test.go @@ -2,7 +2,6 @@ package agent import ( "context" - "os" "path/filepath" "strings" "testing" @@ -12,7 +11,7 @@ import ( "clawgo/pkg/runtimecfg" ) -func TestMaybeHandleSubagentConfigIntentCreateAndConfirm(t *testing.T) { +func TestMaybeHandleSubagentConfigIntentCreatePersistsImmediately(t *testing.T) { workspace := t.TempDir() configPath := filepath.Join(workspace, "config.json") cfg := config.DefaultConfig() @@ -29,32 +28,20 @@ func TestMaybeHandleSubagentConfigIntentCreateAndConfirm(t *testing.T) { runtimecfg.Set(cfg) t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - loop := &AgentLoop{ - configPath: configPath, - pendingSubagentDraft: map[string]map[string]interface{}{}, - } + loop := &AgentLoop{configPath: configPath} 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) + t.Fatalf("create subagent failed: %v", err) } if !handled || !strings.Contains(out, "已写入 config.json") { - t.Fatalf("expected confirm response, got handled=%v out=%q", handled, out) + t.Fatalf("expected immediate persist response, got handled=%v out=%q", handled, out) + } + if !strings.Contains(out, configPath) { + t.Fatalf("expected response to include config path, got %q", out) } reloaded, err := config.LoadConfig(configPath) @@ -66,109 +53,19 @@ func TestMaybeHandleSubagentConfigIntentCreateAndConfirm(t *testing.T) { } } -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", - SystemPromptFile: "agents/main/AGENT.md", - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("save config failed: %v", err) - } - runtimecfg.Set(cfg) - t.Cleanup(func() { runtimecfg.Set(config.DefaultConfig()) }) - - 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)) +func TestMaybeHandleSubagentConfigIntentConfirmCancelNoLongerHandled(t *testing.T) { + loop := &AgentLoop{} + for _, content := range []string{"确认创建", "取消创建"} { + out, handled, err := loop.maybeHandleSubagentConfigIntent(context.Background(), bus.InboundMessage{ + SessionKey: "main", + Channel: "cli", + Content: content, + }) + if err != nil { + t.Fatalf("unexpected error for %q: %v", content, err) + } + if handled || out != "" { + t.Fatalf("expected %q to pass through, got handled=%v out=%q", content, handled, out) + } } } diff --git a/pkg/agent/subagent_node_test.go b/pkg/agent/subagent_node_test.go new file mode 100644 index 0000000..f99e24b --- /dev/null +++ b/pkg/agent/subagent_node_test.go @@ -0,0 +1,58 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "clawgo/pkg/nodes" + "clawgo/pkg/tools" +) + +func TestDispatchNodeSubagentTaskUsesNodeAgentTask(t *testing.T) { + manager := nodes.NewManager() + manager.Upsert(nodes.NodeInfo{ + ID: "edge-dev", + Name: "Edge Dev", + Online: true, + Capabilities: nodes.Capabilities{ + Model: true, + }, + }) + manager.RegisterHandler("edge-dev", func(req nodes.Request) nodes.Response { + if req.Action != "agent_task" { + t.Fatalf("unexpected action: %s", req.Action) + } + if !strings.Contains(req.Task, "Parent Agent: main") { + t.Fatalf("expected parent-agent context in task, got %q", req.Task) + } + return nodes.Response{ + OK: true, + Action: req.Action, + Node: req.Node, + Payload: map[string]interface{}{ + "result": "remote-main-done", + }, + } + }) + + loop := &AgentLoop{ + nodeRouter: &nodes.Router{ + Relay: &nodes.HTTPRelayTransport{Manager: manager}, + }, + } + out, err := loop.dispatchNodeSubagentTask(context.Background(), &tools.SubagentTask{ + ID: "subagent-1", + AgentID: "node.edge-dev.main", + Transport: "node", + NodeID: "edge-dev", + ParentAgentID: "main", + Task: "Implement fix on remote node", + }) + if err != nil { + t.Fatalf("dispatchNodeSubagentTask failed: %v", err) + } + if out != "remote-main-done" { + t.Fatalf("unexpected node result: %q", out) + } +} diff --git a/pkg/api/server.go b/pkg/api/server.go index e8d8ef2..5bf48e5 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -636,7 +636,8 @@ func (s *Server) handleWebUINodes(w http.ResponseWriter, r *http.Request) { if !matched { list = append([]nodes.NodeInfo{local}, list...) } - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list}) + trees := s.buildNodeAgentTrees(r.Context(), list) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "nodes": list, "trees": trees}) case http.MethodPost: var body struct { Action string `json:"action"` @@ -663,6 +664,207 @@ func (s *Server) handleWebUINodes(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) buildNodeAgentTrees(ctx context.Context, nodeList []nodes.NodeInfo) []map[string]interface{} { + trees := make([]map[string]interface{}, 0, len(nodeList)) + localRegistry := s.fetchRegistryItems(ctx) + for _, node := range nodeList { + nodeID := strings.TrimSpace(node.ID) + items := []map[string]interface{}{} + source := "unavailable" + readonly := true + if nodeID == "local" { + items = localRegistry + source = "local_runtime" + readonly = false + } else if remoteItems, err := s.fetchRemoteNodeRegistry(ctx, node); err == nil { + items = remoteItems + source = "remote_webui" + } + trees = append(trees, map[string]interface{}{ + "node_id": nodeID, + "node_name": fallbackNodeName(node), + "online": node.Online, + "source": source, + "readonly": readonly, + "root": buildAgentTreeRoot(nodeID, items), + }) + } + return trees +} + +func (s *Server) fetchRegistryItems(ctx context.Context) []map[string]interface{} { + if s == nil || s.onSubagents == nil { + return nil + } + result, err := s.onSubagents(ctx, "registry", nil) + if err != nil { + return nil + } + payload, ok := result.(map[string]interface{}) + if !ok { + return nil + } + rawItems, ok := payload["items"].([]map[string]interface{}) + if ok { + return rawItems + } + list, ok := payload["items"].([]interface{}) + if !ok { + return nil + } + items := make([]map[string]interface{}, 0, len(list)) + for _, item := range list { + row, ok := item.(map[string]interface{}) + if ok { + items = append(items, row) + } + } + return items +} + +func (s *Server) fetchRemoteNodeRegistry(ctx context.Context, node nodes.NodeInfo) ([]map[string]interface{}, error) { + baseURL := nodeWebUIBaseURL(node) + if baseURL == "" { + return nil, fmt.Errorf("node %s endpoint missing", strings.TrimSpace(node.ID)) + } + reqURL := baseURL + "/webui/api/subagents_runtime?action=registry" + if tok := strings.TrimSpace(node.Token); tok != "" { + reqURL += "&token=" + url.QueryEscape(tok) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("remote status %d", resp.StatusCode) + } + var payload struct { + OK bool `json:"ok"` + Result struct { + Items []map[string]interface{} `json:"items"` + } `json:"result"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil { + return nil, err + } + return payload.Result.Items, nil +} + +func nodeWebUIBaseURL(node nodes.NodeInfo) string { + endpoint := strings.TrimSpace(node.Endpoint) + if endpoint == "" || strings.EqualFold(endpoint, "gateway") { + return "" + } + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + return strings.TrimRight(endpoint, "/") + } + return "http://" + strings.TrimRight(endpoint, "/") +} + +func fallbackNodeName(node nodes.NodeInfo) string { + if name := strings.TrimSpace(node.Name); name != "" { + return name + } + if id := strings.TrimSpace(node.ID); id != "" { + return id + } + return "node" +} + +func buildAgentTreeRoot(nodeID string, items []map[string]interface{}) map[string]interface{} { + rootID := "main" + for _, item := range items { + if strings.TrimSpace(stringFromMap(item, "type")) == "router" && strings.TrimSpace(stringFromMap(item, "agent_id")) != "" { + rootID = strings.TrimSpace(stringFromMap(item, "agent_id")) + break + } + } + nodesByID := make(map[string]map[string]interface{}, len(items)+1) + for _, item := range items { + id := strings.TrimSpace(stringFromMap(item, "agent_id")) + if id == "" { + continue + } + nodesByID[id] = map[string]interface{}{ + "agent_id": id, + "display_name": stringFromMap(item, "display_name"), + "role": stringFromMap(item, "role"), + "type": stringFromMap(item, "type"), + "transport": fallbackString(stringFromMap(item, "transport"), "local"), + "managed_by": stringFromMap(item, "managed_by"), + "node_id": stringFromMap(item, "node_id"), + "parent_agent_id": stringFromMap(item, "parent_agent_id"), + "enabled": boolFromMap(item, "enabled"), + "children": []map[string]interface{}{}, + } + } + root, ok := nodesByID[rootID] + if !ok { + root = map[string]interface{}{ + "agent_id": rootID, + "display_name": "Main Agent", + "role": "orchestrator", + "type": "router", + "transport": "local", + "managed_by": "derived", + "enabled": true, + "children": []map[string]interface{}{}, + } + nodesByID[rootID] = root + } + for _, item := range items { + id := strings.TrimSpace(stringFromMap(item, "agent_id")) + if id == "" || id == rootID { + continue + } + parentID := strings.TrimSpace(stringFromMap(item, "parent_agent_id")) + if parentID == "" { + parentID = rootID + } + parent, ok := nodesByID[parentID] + if !ok { + parent = root + } + parent["children"] = append(parent["children"].([]map[string]interface{}), nodesByID[id]) + } + return map[string]interface{}{ + "node_id": nodeID, + "agent_id": root["agent_id"], + "root": root, + "child_cnt": len(root["children"].([]map[string]interface{})), + } +} + +func stringFromMap(item map[string]interface{}, key string) string { + if item == nil { + return "" + } + v, _ := item[key].(string) + return strings.TrimSpace(v) +} + +func boolFromMap(item map[string]interface{}, key string) bool { + if item == nil { + return false + } + v, _ := item[key].(bool) + return v +} + +func fallbackString(value, fallback string) string { + value = strings.TrimSpace(value) + if value != "" { + return value + } + return strings.TrimSpace(fallback) +} + func (s *Server) handleWebUICron(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) diff --git a/pkg/config/config.go b/pkg/config/config.go index af6a899..dc63903 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -68,6 +68,9 @@ type AgentCommunicationConfig struct { type SubagentConfig struct { Enabled bool `json:"enabled"` Type string `json:"type,omitempty"` + Transport string `json:"transport,omitempty"` + NodeID string `json:"node_id,omitempty"` + ParentAgentID string `json:"parent_agent_id,omitempty"` DisplayName string `json:"display_name,omitempty"` Role string `json:"role,omitempty"` Description string `json:"description,omitempty"` diff --git a/pkg/config/validate.go b/pkg/config/validate.go index fed0805..b3b31db 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -301,6 +301,17 @@ func validateSubagents(cfg *Config) []error { errs = append(errs, fmt.Errorf("agents.subagents.%s.type must be one of: router, worker, reviewer, observer", id)) } } + transport := strings.TrimSpace(raw.Transport) + if transport != "" { + switch transport { + case "local", "node": + default: + errs = append(errs, fmt.Errorf("agents.subagents.%s.transport must be one of: local, node", id)) + } + } + if transport == "node" && strings.TrimSpace(raw.NodeID) == "" { + errs = append(errs, fmt.Errorf("agents.subagents.%s.node_id is required when transport=node", id)) + } if raw.Runtime.TimeoutSec < 0 { errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.timeout_sec must be >= 0", id)) } @@ -322,7 +333,7 @@ func validateSubagents(cfg *Config) []error { if raw.Tools.MaxParallelCalls < 0 { errs = append(errs, fmt.Errorf("agents.subagents.%s.tools.max_parallel_calls must be >= 0", id)) } - if raw.Enabled && strings.TrimSpace(raw.SystemPromptFile) == "" { + if raw.Enabled && transport != "node" && strings.TrimSpace(raw.SystemPromptFile) == "" { errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file is required when enabled=true", id)) } if promptFile := strings.TrimSpace(raw.SystemPromptFile); promptFile != "" { diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index 40a5530..53885df 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -78,3 +78,26 @@ func TestValidateSubagentsRequiresPromptFileWhenEnabled(t *testing.T) { t.Fatalf("expected validation errors") } } + +func TestValidateNodeBackedSubagentAllowsMissingPromptFile(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", + SystemPromptFile: "agents/main/AGENT.md", + } + cfg.Agents.Subagents["node.edge.main"] = SubagentConfig{ + Enabled: true, + Type: "worker", + Transport: "node", + NodeID: "edge", + } + + if errs := Validate(cfg); len(errs) != 0 { + t.Fatalf("expected node-backed config to be valid, got %v", errs) + } +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 7c11356..e07c5c3 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -19,6 +19,9 @@ type SubagentTask struct { Label string `json:"label"` Role string `json:"role"` AgentID string `json:"agent_id"` + Transport string `json:"transport,omitempty"` + NodeID string `json:"node_id,omitempty"` + ParentAgentID string `json:"parent_agent_id,omitempty"` SessionKey string `json:"session_key"` MemoryNS string `json:"memory_ns"` SystemPrompt string `json:"system_prompt,omitempty"` @@ -165,6 +168,9 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti memoryNS := agentID systemPrompt := "" systemPromptFile := "" + transport := "local" + nodeID := "" + parentAgentID := "" toolAllowlist := []string(nil) maxRetries := 0 retryBackoff := 1000 @@ -191,6 +197,12 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti if ns := normalizeSubagentIdentifier(profile.MemoryNamespace); ns != "" { memoryNS = ns } + transport = strings.TrimSpace(profile.Transport) + if transport == "" { + transport = "local" + } + nodeID = strings.TrimSpace(profile.NodeID) + parentAgentID = strings.TrimSpace(profile.ParentAgentID) systemPrompt = strings.TrimSpace(profile.SystemPrompt) systemPromptFile = strings.TrimSpace(profile.SystemPromptFile) toolAllowlist = append([]string(nil), profile.ToolAllowlist...) @@ -265,6 +277,9 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti Label: label, Role: role, AgentID: agentID, + Transport: transport, + NodeID: nodeID, + ParentAgentID: parentAgentID, SessionKey: sessionKey, MemoryNS: memoryNS, SystemPrompt: systemPrompt, diff --git a/pkg/tools/subagent_config_manager.go b/pkg/tools/subagent_config_manager.go index d4004b6..2ac1521 100644 --- a/pkg/tools/subagent_config_manager.go +++ b/pkg/tools/subagent_config_manager.go @@ -68,6 +68,15 @@ func UpsertConfigSubagent(configPath string, args map[string]interface{}) (map[s if v := stringArgFromMap(args, "role"); v != "" { subcfg.Role = v } + if v := stringArgFromMap(args, "transport"); v != "" { + subcfg.Transport = v + } + if v := stringArgFromMap(args, "node_id"); v != "" { + subcfg.NodeID = v + } + if v := stringArgFromMap(args, "parent_agent_id"); v != "" { + subcfg.ParentAgentID = v + } if v := stringArgFromMap(args, "display_name"); v != "" { subcfg.DisplayName = v } @@ -91,7 +100,10 @@ func UpsertConfigSubagent(configPath string, args map[string]interface{}) (map[s } else if strings.TrimSpace(subcfg.Type) == "" { subcfg.Type = "worker" } - if subcfg.Enabled && strings.TrimSpace(subcfg.SystemPromptFile) == "" { + if strings.TrimSpace(subcfg.Transport) == "" { + subcfg.Transport = "local" + } + if subcfg.Enabled && strings.TrimSpace(subcfg.Transport) != "node" && strings.TrimSpace(subcfg.SystemPromptFile) == "" { return nil, fmt.Errorf("system_prompt_file is required for enabled agent %q", agentID) } cfg.Agents.Subagents[agentID] = subcfg diff --git a/pkg/tools/subagent_config_tool.go b/pkg/tools/subagent_config_tool.go index 52db482..16c72bb 100644 --- a/pkg/tools/subagent_config_tool.go +++ b/pkg/tools/subagent_config_tool.go @@ -20,7 +20,7 @@ func NewSubagentConfigTool(configPath string) *SubagentConfigTool { 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." + return "Persist subagent config and router rules into config.json." } func (t *SubagentConfigTool) Parameters() map[string]interface{} { @@ -29,17 +29,20 @@ func (t *SubagentConfigTool) Parameters() map[string]interface{} { "properties": map[string]interface{}{ "action": map[string]interface{}{ "type": "string", - "description": "draft|upsert", + "description": "upsert", }, "description": map[string]interface{}{ "type": "string", - "description": "Natural-language worker description for draft or upsert.", + "description": "Natural-language worker description used by callers before upsert.", }, "agent_id_hint": map[string]interface{}{ "type": "string", - "description": "Optional preferred agent id seed for draft.", + "description": "Optional preferred agent id seed used by callers before upsert.", }, "agent_id": map[string]interface{}{"type": "string"}, + "transport": map[string]interface{}{"type": "string"}, + "node_id": map[string]interface{}{"type": "string"}, + "parent_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"}, @@ -68,14 +71,6 @@ func (t *SubagentConfigTool) SetConfigPath(path string) { 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 { diff --git a/pkg/tools/subagent_config_tool_test.go b/pkg/tools/subagent_config_tool_test.go index 80a5e2b..8470887 100644 --- a/pkg/tools/subagent_config_tool_test.go +++ b/pkg/tools/subagent_config_tool_test.go @@ -10,28 +10,6 @@ import ( "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") diff --git a/pkg/tools/subagent_profile.go b/pkg/tools/subagent_profile.go index c2cd06e..97ad42a 100644 --- a/pkg/tools/subagent_profile.go +++ b/pkg/tools/subagent_profile.go @@ -12,12 +12,16 @@ import ( "time" "clawgo/pkg/config" + "clawgo/pkg/nodes" "clawgo/pkg/runtimecfg" ) type SubagentProfile struct { AgentID string `json:"agent_id"` Name string `json:"name"` + Transport string `json:"transport,omitempty"` + NodeID string `json:"node_id,omitempty"` + ParentAgentID string `json:"parent_agent_id,omitempty"` Role string `json:"role,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` SystemPromptFile string `json:"system_prompt_file,omitempty"` @@ -122,6 +126,9 @@ func (s *SubagentProfileStore) Upsert(profile SubagentProfile) (*SubagentProfile if managed, ok := s.configProfileLocked(p.AgentID); ok { return nil, fmt.Errorf("subagent profile %q is managed by %s", p.AgentID, managed.ManagedBy) } + if managed, ok := s.nodeProfileLocked(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) @@ -160,6 +167,9 @@ func (s *SubagentProfileStore) Delete(agentID string) error { if managed, ok := s.configProfileLocked(id); ok { return fmt.Errorf("subagent profile %q is managed by %s", id, managed.ManagedBy) } + if managed, ok := s.nodeProfileLocked(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) { @@ -175,6 +185,9 @@ func normalizeSubagentProfile(in SubagentProfile) SubagentProfile { if p.Name == "" { p.Name = p.AgentID } + p.Transport = normalizeProfileTransport(p.Transport) + p.NodeID = strings.TrimSpace(p.NodeID) + p.ParentAgentID = normalizeSubagentIdentifier(p.ParentAgentID) p.Role = strings.TrimSpace(p.Role) p.SystemPrompt = strings.TrimSpace(p.SystemPrompt) p.SystemPromptFile = strings.TrimSpace(p.SystemPromptFile) @@ -203,6 +216,17 @@ func normalizeProfileStatus(s string) string { } } +func normalizeProfileTransport(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "local": + return "local" + case "node": + return "node" + default: + return "local" + } +} + func normalizeStringList(in []string) []string { if len(in) == 0 { return nil @@ -266,6 +290,12 @@ func (s *SubagentProfileStore) mergedProfilesLocked() (map[string]SubagentProfil for _, p := range s.configProfilesLocked() { merged[p.AgentID] = p } + for _, p := range s.nodeProfilesLocked() { + if _, exists := merged[p.AgentID]; exists { + continue + } + merged[p.AgentID] = p + } fileProfiles, err := s.fileProfilesLocked() if err != nil { return nil, err @@ -339,6 +369,27 @@ func (s *SubagentProfileStore) configProfileLocked(agentID string) (SubagentProf return profileFromConfig(id, subcfg), true } +func (s *SubagentProfileStore) nodeProfileLocked(agentID string) (SubagentProfile, bool) { + id := normalizeSubagentIdentifier(agentID) + if id == "" { + return SubagentProfile{}, false + } + cfg := runtimecfg.Get() + parentAgentID := "main" + if cfg != nil { + if mainID := normalizeSubagentIdentifier(cfg.Agents.Router.MainAgentID); mainID != "" { + parentAgentID = mainID + } + } + for _, node := range nodes.DefaultManager().List() { + profile := profileFromNode(node, parentAgentID) + if profile.AgentID == id { + return profile, true + } + } + return SubagentProfile{}, false +} + func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentProfile { status := "active" if !subcfg.Enabled { @@ -347,6 +398,9 @@ func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentPro return normalizeSubagentProfile(SubagentProfile{ AgentID: agentID, Name: strings.TrimSpace(subcfg.DisplayName), + Transport: strings.TrimSpace(subcfg.Transport), + NodeID: strings.TrimSpace(subcfg.NodeID), + ParentAgentID: strings.TrimSpace(subcfg.ParentAgentID), Role: strings.TrimSpace(subcfg.Role), SystemPrompt: strings.TrimSpace(subcfg.SystemPrompt), SystemPromptFile: strings.TrimSpace(subcfg.SystemPromptFile), @@ -362,6 +416,63 @@ func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentPro }) } +func (s *SubagentProfileStore) nodeProfilesLocked() []SubagentProfile { + nodeItems := nodes.DefaultManager().List() + if len(nodeItems) == 0 { + return nil + } + cfg := runtimecfg.Get() + parentAgentID := "main" + if cfg != nil { + if mainID := normalizeSubagentIdentifier(cfg.Agents.Router.MainAgentID); mainID != "" { + parentAgentID = mainID + } + } + out := make([]SubagentProfile, 0, len(nodeItems)) + for _, node := range nodeItems { + profile := profileFromNode(node, parentAgentID) + if profile.AgentID == "" { + continue + } + out = append(out, profile) + } + return out +} + +func profileFromNode(node nodes.NodeInfo, parentAgentID string) SubagentProfile { + agentID := nodeBranchAgentID(node.ID) + if agentID == "" { + return SubagentProfile{} + } + name := strings.TrimSpace(node.Name) + if name == "" { + name = strings.TrimSpace(node.ID) + } + status := "active" + if !node.Online { + status = "disabled" + } + return normalizeSubagentProfile(SubagentProfile{ + AgentID: agentID, + Name: name + " Main Agent", + Transport: "node", + NodeID: strings.TrimSpace(node.ID), + ParentAgentID: parentAgentID, + Role: "remote_main", + MemoryNamespace: agentID, + Status: status, + ManagedBy: "node_registry", + }) +} + +func nodeBranchAgentID(nodeID string) string { + id := normalizeSubagentIdentifier(nodeID) + if id == "" { + return "" + } + return "node." + id + ".main" +} + type SubagentProfileTool struct { store *SubagentProfileStore } diff --git a/pkg/tools/subagent_profile_test.go b/pkg/tools/subagent_profile_test.go index 792fd2e..489bc9b 100644 --- a/pkg/tools/subagent_profile_test.go +++ b/pkg/tools/subagent_profile_test.go @@ -7,6 +7,7 @@ import ( "time" "clawgo/pkg/config" + "clawgo/pkg/nodes" "clawgo/pkg/runtimecfg" ) @@ -185,3 +186,51 @@ func TestSubagentProfileStoreRejectsWritesForConfigManagedProfiles(t *testing.T) t.Fatalf("expected config-managed delete to fail") } } + +func TestSubagentProfileStoreIncludesNodeMainBranchProfiles(t *testing.T) { + runtimecfg.Set(config.DefaultConfig()) + t.Cleanup(func() { + runtimecfg.Set(config.DefaultConfig()) + nodes.DefaultManager().Remove("edge-dev") + }) + + cfg := config.DefaultConfig() + cfg.Agents.Router.Enabled = true + cfg.Agents.Router.MainAgentID = "main" + cfg.Agents.Subagents["main"] = config.SubagentConfig{ + Enabled: true, + Type: "router", + SystemPromptFile: "agents/main/AGENT.md", + } + runtimecfg.Set(cfg) + + nodes.DefaultManager().Upsert(nodes.NodeInfo{ + ID: "edge-dev", + Name: "Edge Dev", + Online: true, + Capabilities: nodes.Capabilities{ + Model: true, + }, + }) + + store := NewSubagentProfileStore(t.TempDir()) + profile, ok, err := store.Get(nodeBranchAgentID("edge-dev")) + if err != nil { + t.Fatalf("get failed: %v", err) + } + if !ok { + t.Fatalf("expected node-backed profile") + } + if profile.ManagedBy != "node_registry" || profile.Transport != "node" || profile.NodeID != "edge-dev" { + t.Fatalf("unexpected node profile: %+v", profile) + } + if profile.ParentAgentID != "main" { + t.Fatalf("expected main parent agent, got %+v", profile) + } + if _, err := store.Upsert(SubagentProfile{AgentID: profile.AgentID}); err == nil { + t.Fatalf("expected node-managed upsert to fail") + } + if err := store.Delete(profile.AgentID); err == nil { + t.Fatalf("expected node-managed delete to fail") + } +} diff --git a/webui/src/App.tsx b/webui/src/App.tsx index a8d2b42..c3946dd 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -7,7 +7,6 @@ import Dashboard from './pages/Dashboard'; import Chat from './pages/Chat'; import Config from './pages/Config'; import Cron from './pages/Cron'; -import Nodes from './pages/Nodes'; import Logs from './pages/Logs'; import Skills from './pages/Skills'; import Memory from './pages/Memory'; @@ -32,7 +31,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 7801fe9..c5347fd 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, BrainCircuit, Hash, Bot, Workflow, Boxes } from 'lucide-react'; +import { LayoutDashboard, MessageSquare, Settings, Clock, Terminal, Zap, FolderOpen, ClipboardList, BrainCircuit, Hash, Bot, Workflow, Boxes } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import NavItem from './NavItem'; @@ -14,6 +14,7 @@ const Sidebar: React.FC = () => { items: [ { icon: , label: t('dashboard'), to: '/' }, { icon: , label: t('chat'), to: '/chat' }, + { icon: , label: t('subagentsRuntime'), to: '/subagents' }, { icon: , label: t('logs'), to: '/logs' }, { icon: , label: t('logCodes'), to: '/log-codes' }, { icon: , label: t('skills'), to: '/skills' }, @@ -24,10 +25,8 @@ const Sidebar: React.FC = () => { items: [ { icon: , label: t('config'), to: '/config' }, { icon: , label: t('cronJobs'), to: '/cron' }, - { icon: , label: t('nodes'), to: '/nodes' }, { icon: , label: t('memory'), to: '/memory' }, { icon: , label: t('subagentProfiles'), to: '/subagent-profiles' }, - { icon: , label: t('subagentsRuntime'), to: '/subagents' }, { icon: , label: t('pipelines'), to: '/pipelines' }, ], }, diff --git a/webui/src/context/AppContext.tsx b/webui/src/context/AppContext.tsx index c29fdae..68bd1d8 100644 --- a/webui/src/context/AppContext.tsx +++ b/webui/src/context/AppContext.tsx @@ -14,6 +14,8 @@ interface AppContextType { setCfgRaw: (raw: string) => void; nodes: string; setNodes: (nodes: string) => void; + nodeTrees: string; + setNodeTrees: (trees: string) => void; cron: CronJob[]; setCron: (cron: CronJob[]) => void; skills: Skill[]; @@ -53,6 +55,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children const [cfg, setCfg] = useState({}); const [cfgRaw, setCfgRaw] = useState('{}'); const [nodes, setNodes] = useState('[]'); + const [nodeTrees, setNodeTrees] = useState('[]'); const [cron, setCron] = useState([]); const [skills, setSkills] = useState([]); const [clawhubInstalled, setClawhubInstalled] = useState(false); @@ -99,6 +102,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children if (!r.ok) throw new Error('Failed to load nodes'); const j = await r.json(); setNodes(JSON.stringify(j.nodes || [], null, 2)); + setNodeTrees(JSON.stringify(j.trees || [], null, 2)); setIsGatewayOnline(true); } catch (e) { setIsGatewayOnline(false); @@ -167,6 +171,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children useEffect(() => { refreshAll(); const interval = setInterval(() => { + loadConfig(); refreshCron(); refreshNodes(); refreshSkills(); @@ -174,12 +179,12 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children refreshVersion(); }, 10000); return () => clearInterval(interval); - }, [token, refreshAll, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion]); + }, [token, refreshAll, loadConfig, refreshCron, refreshNodes, refreshSkills, refreshSessions, refreshVersion]); return ( { - const { t } = useTranslation(); - const { nodes, refreshNodes } = useAppContext(); - - return ( -
-
-

{t('nodes')}

- -
- -
-
- {(() => { - try { - const parsedNodes = JSON.parse(nodes); - if (!Array.isArray(parsedNodes) || parsedNodes.length === 0) { - return ( -
- -

{t('noNodes')}

-
- ); - } - return ( -
- {parsedNodes.map((node: any, i: number) => ( -
-
-
-
-
{node.name || node.id || `${t('node')} ${i + 1}`}
-
{node.id}
-
-
-
-
{node.ip || t('unknownIp')}
-
v{node.version || '0.0.0'}
-
-
- ))} -
- ); - } catch (e) { - return
{nodes}
; - } - })()} -
-
-
- ); -}; - -export default Nodes; diff --git a/webui/src/pages/Subagents.tsx b/webui/src/pages/Subagents.tsx index 2580532..b3d48ff 100644 --- a/webui/src/pages/Subagents.tsx +++ b/webui/src/pages/Subagents.tsx @@ -58,24 +58,14 @@ type AgentMessage = { created_at?: number; }; -type PendingSubagentDraft = { - session_key?: string; - draft?: { - agent_id?: string; - role?: string; - display_name?: string; - description?: string; - system_prompt?: string; - system_prompt_file?: string; - tool_allowlist?: string[]; - routing_keywords?: string[]; - }; -}; - type RegistrySubagent = { agent_id?: string; enabled?: boolean; type?: string; + transport?: string; + node_id?: string; + parent_agent_id?: string; + managed_by?: string; display_name?: string; role?: string; description?: string; @@ -87,9 +77,165 @@ type RegistrySubagent = { routing_keywords?: string[]; }; +type AgentTreeNode = { + agent_id?: string; + display_name?: string; + role?: string; + type?: string; + transport?: string; + managed_by?: string; + node_id?: string; + enabled?: boolean; + children?: AgentTreeNode[]; +}; + +type NodeTree = { + node_id?: string; + node_name?: string; + online?: boolean; + source?: string; + readonly?: boolean; + root?: { + root?: AgentTreeNode; + }; +}; + +type NodeInfo = { + id?: string; + name?: string; + endpoint?: string; + version?: string; + online?: boolean; +}; + +type AgentTaskStats = { + total: number; + running: number; + failed: number; + waiting: number; + active: Array<{ id: string; status: string; title: string }>; +}; + +type GraphCardSpec = { + key: string; + branch: string; + agentID?: string; + transportType?: 'local' | 'remote'; + x: number; + y: number; + w: number; + h: number; + kind: 'node' | 'agent'; + title: string; + subtitle: string; + meta: string[]; + accent: string; + online?: boolean; + clickable?: boolean; + highlighted?: boolean; + dimmed?: boolean; + hidden?: boolean; + onClick?: () => void; +}; + +type GraphLineSpec = { + x1: number; + y1: number; + x2: number; + y2: number; + dashed?: boolean; + branch: string; + highlighted?: boolean; + dimmed?: boolean; + hidden?: boolean; +}; + +const cardWidth = 230; +const cardHeight = 112; +const clusterWidth = 350; +const topY = 24; +const mainY = 172; +const childStartY = 334; +const childGap = 132; + +function normalizeTitle(value?: string, fallback = '-'): string { + const trimmed = `${value || ''}`.trim(); + return trimmed || fallback; +} + +function summarizeTask(task?: string, label?: string): string { + const text = normalizeTitle(label || task, '-'); + return text.length > 52 ? `${text.slice(0, 49)}...` : text; +} + +function buildTaskStats(tasks: SubagentTask[]): Record { + return tasks.reduce>((acc, task) => { + const agentID = normalizeTitle(task.agent_id, ''); + if (!agentID) return acc; + if (!acc[agentID]) { + acc[agentID] = { total: 0, running: 0, failed: 0, waiting: 0, active: [] }; + } + const item = acc[agentID]; + item.total += 1; + if (task.status === 'running') item.running += 1; + if (task.status === 'failed') item.failed += 1; + if (task.waiting_for_reply) item.waiting += 1; + if (task.status === 'running' || task.waiting_for_reply) { + item.active.push({ + id: task.id, + status: task.status || '-', + title: summarizeTask(task.task, task.label), + }); + } + return acc; + }, {}); +} + +function GraphCard({ card }: { card: GraphCardSpec }) { + return ( + + + + ); +} + const Subagents: React.FC = () => { const { t } = useTranslation(); - const { q } = useAppContext(); + const { q, nodeTrees, nodes } = useAppContext(); const ui = useUI(); const [items, setItems] = useState([]); @@ -117,32 +263,27 @@ const Subagents: React.FC = () => { const [configSystemPromptFile, setConfigSystemPromptFile] = useState(''); const [configToolAllowlist, setConfigToolAllowlist] = useState(''); const [configRoutingKeywords, setConfigRoutingKeywords] = useState(''); - const [draftDescription, setDraftDescription] = useState(''); - const [pendingDrafts, setPendingDrafts] = useState([]); const [registryItems, setRegistryItems] = useState([]); const [promptFileContent, setPromptFileContent] = useState(''); const [promptFileFound, setPromptFileFound] = useState(false); + const [selectedTopologyBranch, setSelectedTopologyBranch] = useState(''); + const [topologyFilter, setTopologyFilter] = useState<'all' | 'running' | 'failed' | 'local' | 'remote'>('all'); const apiPath = '/webui/api/subagents_runtime'; const withAction = (action: string) => `${apiPath}${q}${q ? '&' : '?'}action=${encodeURIComponent(action)}`; const load = async () => { - const [tasksRes, draftsRes, registryRes] = await Promise.all([ + const [tasksRes, 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(''); @@ -155,7 +296,318 @@ const Subagents: React.FC = () => { load().catch(() => {}); }, [q]); + useEffect(() => { + const interval = window.setInterval(() => { + load().catch(() => {}); + }, 5000); + return () => window.clearInterval(interval); + }, [q]); + const selected = useMemo(() => items.find((x) => x.id === selectedId) || null, [items, selectedId]); + const parsedNodeTrees = useMemo(() => { + try { + const parsed = JSON.parse(nodeTrees); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }, [nodeTrees]); + const parsedNodes = useMemo(() => { + try { + const parsed = JSON.parse(nodes); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }, [nodes]); + const taskStats = useMemo(() => buildTaskStats(items), [items]); + const recentTaskByAgent = useMemo(() => { + return items.reduce>((acc, task) => { + const agentID = normalizeTitle(task.agent_id, ''); + if (!agentID) return acc; + const existing = acc[agentID]; + const currentScore = Math.max(task.updated || 0, task.created || 0); + const existingScore = existing ? Math.max(existing.updated || 0, existing.created || 0) : -1; + if (!existing || currentScore > existingScore) { + acc[agentID] = task; + } + return acc; + }, {}); + }, [items]); + const topologyGraph = useMemo(() => { + const localTree = parsedNodeTrees.find((tree) => normalizeTitle(tree.node_id, '') === 'local') || null; + const remoteTrees = parsedNodeTrees.filter((tree) => normalizeTitle(tree.node_id, '') !== 'local'); + const localRoot = localTree?.root?.root || { + agent_id: 'main', + display_name: 'Main Agent', + role: 'orchestrator', + type: 'router', + transport: 'local', + enabled: true, + children: registryItems + .filter((item) => item.agent_id && item.agent_id !== 'main' && item.managed_by === 'config.json') + .map((item) => ({ + agent_id: item.agent_id, + display_name: item.display_name, + role: item.role, + type: item.type, + transport: item.transport, + enabled: item.enabled, + children: [], + })), + }; + const localNode = parsedNodes.find((node) => normalizeTitle(node.id, '') === 'local') || { + id: 'local', + name: 'local', + online: true, + }; + const clusterCount = Math.max(1, remoteTrees.length + 1); + const localChildren = Array.isArray(localRoot.children) ? localRoot.children : []; + const remoteChildMax = remoteTrees.reduce((max, tree) => { + const count = Array.isArray(tree.root?.root?.children) ? tree.root?.root?.children?.length || 0 : 0; + return Math.max(max, count); + }, 0); + const maxChildren = Math.max(localChildren.length, remoteChildMax, 1); + const width = clusterCount * clusterWidth + 120; + const height = childStartY + maxChildren * childGap + 30; + const mainClusterX = 56; + const localNodeX = mainClusterX + 20; + const localMainX = mainClusterX + 20; + const localMainCenterX = localMainX + cardWidth / 2; + const localMainCenterY = mainY + cardHeight / 2; + const cards: GraphCardSpec[] = []; + const lines: GraphLineSpec[] = []; + const localBranch = 'local'; + const localBranchStats = { + running: 0, + failed: 0, + }; + + const localNodeCard: GraphCardSpec = { + key: 'node-local', + branch: localBranch, + transportType: 'local', + kind: 'node', + x: localNodeX, + y: topY, + w: cardWidth, + h: cardHeight, + title: normalizeTitle(localNode.name, 'local'), + subtitle: normalizeTitle(localNode.id, 'local'), + meta: [ + localNode.online ? t('online') : t('offline'), + localNode.endpoint ? `endpoint=${localNode.endpoint}` : 'endpoint=-', + localNode.version ? `version=${localNode.version}` : 'version=-', + `${t('childrenCount')}=${localChildren.length}`, + ], + accent: localNode.online ? 'bg-emerald-500' : 'bg-red-500', + online: !!localNode.online, + clickable: true, + onClick: () => setSelectedTopologyBranch(localBranch), + }; + const localMainStats = taskStats[normalizeTitle(localRoot.agent_id, 'main')] || { total: 0, running: 0, failed: 0, waiting: 0, active: [] }; + const localMainTask = recentTaskByAgent[normalizeTitle(localRoot.agent_id, 'main')]; + localBranchStats.running += localMainStats.running; + localBranchStats.failed += localMainStats.failed; + const localMainCard: GraphCardSpec = { + key: 'agent-main', + branch: localBranch, + agentID: normalizeTitle(localRoot.agent_id, 'main'), + transportType: 'local', + kind: 'agent', + x: localMainX, + y: mainY, + w: cardWidth, + h: cardHeight, + title: normalizeTitle(localRoot.display_name, 'Main Agent'), + subtitle: `${normalizeTitle(localRoot.agent_id, 'main')} · ${normalizeTitle(localRoot.role, '-')}`, + meta: [ + `total=${localMainStats.total} running=${localMainStats.running}`, + `waiting=${localMainStats.waiting} failed=${localMainStats.failed}`, + `transport=${normalizeTitle(localRoot.transport, 'local')} type=${normalizeTitle(localRoot.type, 'router')}`, + localMainStats.active[0] ? `task: ${localMainStats.active[0].title}` : t('noLiveTasks'), + ], + accent: 'bg-amber-400', + clickable: true, + onClick: () => { + setSelectedTopologyBranch(localBranch); + if (localMainTask?.id) setSelectedId(localMainTask.id); + }, + }; + cards.push(localNodeCard, localMainCard); + lines.push({ + x1: localNodeCard.x + cardWidth / 2, + y1: localNodeCard.y + cardHeight, + x2: localMainCard.x + cardWidth / 2, + y2: localMainCard.y, + branch: localBranch, + }); + + localChildren.forEach((child, idx) => { + const childX = mainClusterX + 20; + const childY = childStartY + idx * childGap; + const stats = taskStats[normalizeTitle(child.agent_id, '')] || { total: 0, running: 0, failed: 0, waiting: 0, active: [] }; + const task = recentTaskByAgent[normalizeTitle(child.agent_id, '')]; + localBranchStats.running += stats.running; + localBranchStats.failed += stats.failed; + cards.push({ + key: `local-child-${child.agent_id || idx}`, + branch: localBranch, + agentID: normalizeTitle(child.agent_id, ''), + transportType: 'local', + kind: 'agent', + x: childX, + y: childY, + w: cardWidth, + h: cardHeight, + title: normalizeTitle(child.display_name, normalizeTitle(child.agent_id, 'agent')), + subtitle: `${normalizeTitle(child.agent_id, '-')} · ${normalizeTitle(child.role, '-')}`, + meta: [ + `total=${stats.total} running=${stats.running}`, + `waiting=${stats.waiting} failed=${stats.failed}`, + `transport=${normalizeTitle(child.transport, 'local')} type=${normalizeTitle(child.type, 'worker')}`, + stats.active[0] ? `task: ${stats.active[0].title}` : task ? `last: ${summarizeTask(task.task, task.label)}` : t('noLiveTasks'), + ], + accent: stats.running > 0 ? 'bg-emerald-500' : stats.failed > 0 ? 'bg-red-500' : 'bg-sky-400', + clickable: true, + onClick: () => { + setSelectedTopologyBranch(localBranch); + if (task?.id) setSelectedId(task.id); + }, + }); + lines.push({ + x1: localMainCenterX, + y1: localMainCenterY, + x2: childX + cardWidth / 2, + y2: childY, + branch: localBranch, + }); + }); + + remoteTrees.forEach((tree, treeIndex) => { + const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`; + const baseX = 56 + (treeIndex + 1) * clusterWidth; + const nodeX = baseX + 20; + const rootX = baseX + 20; + const treeRoot = tree.root?.root; + const remoteNodeCard: GraphCardSpec = { + key: `node-${tree.node_id || treeIndex}`, + branch, + transportType: 'remote', + kind: 'node', + x: nodeX, + y: topY, + w: cardWidth, + h: cardHeight, + title: normalizeTitle(tree.node_name, tree.node_id || 'node'), + subtitle: normalizeTitle(tree.node_id, 'node'), + meta: [ + tree.online ? t('online') : t('offline'), + normalizeTitle(tree.source, '-'), + tree.readonly ? t('readonlyMirror') : t('localControl'), + `${t('childrenCount')}=${Array.isArray(treeRoot?.children) ? treeRoot?.children?.length || 0 : 0}`, + ], + accent: tree.online ? 'bg-emerald-500' : 'bg-red-500', + online: !!tree.online, + clickable: true, + onClick: () => setSelectedTopologyBranch(branch), + }; + cards.push(remoteNodeCard); + lines.push({ + x1: localMainCenterX, + y1: localMainCenterY, + x2: remoteNodeCard.x, + y2: remoteNodeCard.y + cardHeight / 2, + dashed: true, + branch, + }); + if (!treeRoot) return; + const rootCard: GraphCardSpec = { + key: `remote-root-${tree.node_id || treeIndex}`, + branch, + agentID: normalizeTitle(treeRoot.agent_id, ''), + transportType: 'remote', + kind: 'agent', + x: rootX, + y: mainY, + w: cardWidth, + h: cardHeight, + title: normalizeTitle(treeRoot.display_name, treeRoot.agent_id || 'main'), + subtitle: `${normalizeTitle(treeRoot.agent_id, '-')} · ${normalizeTitle(treeRoot.role, '-')}`, + meta: [ + `transport=${normalizeTitle(treeRoot.transport, 'node')} type=${normalizeTitle(treeRoot.type, 'router')}`, + `source=${normalizeTitle(treeRoot.managed_by, tree.source || '-')}`, + t('remoteTasksUnavailable'), + ], + accent: tree.online ? 'bg-fuchsia-400' : 'bg-zinc-500', + clickable: true, + onClick: () => setSelectedTopologyBranch(branch), + }; + cards.push(rootCard); + lines.push({ + x1: remoteNodeCard.x + cardWidth / 2, + y1: remoteNodeCard.y + cardHeight, + x2: rootCard.x + cardWidth / 2, + y2: rootCard.y, + branch, + }); + const children = Array.isArray(treeRoot.children) ? treeRoot.children : []; + children.forEach((child, idx) => { + const childX = baseX + 20; + const childY = childStartY + idx * childGap; + cards.push({ + key: `remote-child-${tree.node_id || treeIndex}-${child.agent_id || idx}`, + branch, + agentID: normalizeTitle(child.agent_id, ''), + transportType: 'remote', + kind: 'agent', + x: childX, + y: childY, + w: cardWidth, + h: cardHeight, + title: normalizeTitle(child.display_name, child.agent_id || 'agent'), + subtitle: `${normalizeTitle(child.agent_id, '-')} · ${normalizeTitle(child.role, '-')}`, + meta: [ + `transport=${normalizeTitle(child.transport, 'node')} type=${normalizeTitle(child.type, 'worker')}`, + `source=${normalizeTitle(child.managed_by, 'remote_webui')}`, + t('remoteTasksUnavailable'), + ], + accent: 'bg-violet-400', + clickable: true, + onClick: () => setSelectedTopologyBranch(branch), + }); + lines.push({ + x1: rootCard.x + cardWidth / 2, + y1: rootCard.y + cardHeight / 2, + x2: childX + cardWidth / 2, + y2: childY, + branch, + }); + }); + }); + + const highlightedBranch = selectedTopologyBranch.trim(); + const branchFilters = new Map(); + branchFilters.set(localBranch, topologyFilter === 'all' || topologyFilter === 'local' || (topologyFilter === 'running' && localBranchStats.running > 0) || (topologyFilter === 'failed' && localBranchStats.failed > 0)); + remoteTrees.forEach((tree, treeIndex) => { + const branch = `node:${normalizeTitle(tree.node_id, `remote-${treeIndex}`)}`; + branchFilters.set(branch, topologyFilter === 'all' || topologyFilter === 'remote'); + }); + const decoratedCards = cards.map((card) => ({ + ...card, + hidden: branchFilters.get(card.branch) === false, + highlighted: !highlightedBranch || card.branch === highlightedBranch, + dimmed: branchFilters.get(card.branch) === false ? true : !!highlightedBranch && card.branch !== highlightedBranch, + })); + const decoratedLines = lines.map((line) => ({ + ...line, + hidden: branchFilters.get(line.branch) === false, + highlighted: !highlightedBranch || line.branch === highlightedBranch, + dimmed: branchFilters.get(line.branch) === false ? true : !!highlightedBranch && line.branch !== highlightedBranch, + })); + + return { width, height, cards: decoratedCards, lines: decoratedLines }; + }, [parsedNodeTrees, parsedNodes, registryItems, taskStats, recentTaskByAgent, selectedTopologyBranch, topologyFilter, t]); const callAction = async (payload: Record) => { const r = await fetch(`${apiPath}${q}`, { @@ -322,52 +774,6 @@ const Subagents: React.FC = () => { 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 || ''); - setConfigSystemPromptFile(draft.system_prompt_file || ''); - setConfigToolAllowlist(Array.isArray(draft.tool_allowlist) ? draft.tool_allowlist.join(', ') : ''); - setConfigRoutingKeywords(Array.isArray(draft.routing_keywords) ? draft.routing_keywords.join(', ') : ''); - 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 || ''); - setConfigSystemPromptFile(draft.system_prompt_file || ''); - setConfigToolAllowlist(Array.isArray(draft.tool_allowlist) ? draft.tool_allowlist.join(', ') : ''); - setConfigRoutingKeywords(Array.isArray(draft.routing_keywords) ? draft.routing_keywords.join(', ') : ''); - }; - - 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 || ''); @@ -443,6 +849,61 @@ const Subagents: React.FC = () => {
+
+
+
+
{t('agentTopology')}
+
{t('agentTopologyHint')}
+
+
+ {(['all', 'running', 'failed', 'local', 'remote'] as const).map((filter) => ( + + ))} + {selectedTopologyBranch && ( + + )} +
+ {items.filter((item) => item.status === 'running').length} {t('runningTasks')} +
+
+
+
+ + {topologyGraph.lines.map((line, idx) => ( + line.hidden ? null : ( + + ) + ))} + {topologyGraph.cards.map((card) => ( + card.hidden ? null : + ))} + +
+
+
{t('subagentsRuntime')}
@@ -526,17 +987,28 @@ const Subagents: React.FC = () => { {registryItems.map((item) => (
{item.agent_id || '-'} · {item.role || '-'} · {item.enabled ? t('active') : t('paused')}
-
{item.type || '-'} · {item.display_name || '-'}
+
{item.type || '-'} · {item.transport || 'local'} · {item.display_name || '-'}
+ {(item.node_id || item.parent_agent_id || item.managed_by) && ( +
+ {item.node_id ? `node=${item.node_id}` : ''} + {item.node_id && item.parent_agent_id ? ' · ' : ''} + {item.parent_agent_id ? `parent=${item.parent_agent_id}` : ''} + {(item.node_id || item.parent_agent_id) && item.managed_by ? ' · ' : ''} + {item.managed_by ? `source=${item.managed_by}` : ''} +
+ )}
{item.system_prompt_file || '-'}
{item.prompt_file_found ? t('promptFileReady') : t('promptFileMissing')}
{item.system_prompt || item.description || '-'}
{(item.routing_keywords || []).join(', ') || '-'}
- - {item.agent_id !== 'main' && ( + {item.managed_by === 'config.json' && ( + + )} + {item.managed_by === 'config.json' && item.agent_id !== 'main' && ( )}
@@ -548,13 +1020,6 @@ const Subagents: React.FC = () => {
{t('configSubagentDraft')}
-