diff --git a/docs/master-subagent-development.md b/docs/master-subagent-development.md new file mode 100644 index 0000000..1216e2f --- /dev/null +++ b/docs/master-subagent-development.md @@ -0,0 +1,282 @@ +# 主 Agent + 子 Agent 架构开发文档 + +## 1. 目标与范围 + +本文档定义在 ClawGo 中实现“单一主 Agent 对话入口 + 可动态创建子 Agent 执行任务”的开发方案。 + +目标: + +- 由主 Agent 统一接收用户对话并调度子 Agent。 +- 子 Agent 可按职责分工(如 coding、docs、testing)。 +- 子 Agent 具备各自独立记忆与会话上下文。 +- 所有 Agent 共享同一工作空间(文件系统)。 +- 支持主 Agent 在一次对话中创建、调用、监控子 Agent 并汇总结果。 + +非目标: + +- 不在本阶段引入独立进程/独立容器隔离。 +- 不在本阶段实现跨机器分布式调度。 + +--- + +## 2. 现状(基于当前代码) + +已具备能力: + +- `spawn` 工具可创建后台子任务。 +- `subagents` 工具可 list/info/kill/steer/resume。 +- `Orchestrator` + `pipeline_*` 具备任务依赖编排模型。 + +关键差距: + +- 目前子任务偏“临时任务实例”,缺少“长期角色子 Agent”概念。 +- `spawn.role` 参数未完整进入任务状态与调度决策。 +- 子任务会话键未与 agent/task 强绑定,存在上下文串线风险。 +- `pipeline_*` 工具已实现但尚未在主循环注册使用。 +- 记忆目前以工作区全局为主,缺少“子 Agent 命名空间隔离”。 + +--- + +## 3. 总体架构 + +### 3.1 角色分层 + +- 主 Agent(Orchestrator Agent) + - 职责:需求理解、任务拆分、分派、进度追踪、结果汇总、用户回复。 + - 不直接承载全部执行细节。 +- 子 Agent(Worker Agent) + - 职责:按角色执行具体任务(代码实现、文档编写、测试验证等)。 + - 具备独立的会话与记忆上下文。 + +### 3.2 共享与隔离策略 + +- 共享:workspace 文件系统、工具注册中心、pipeline shared_state。 +- 隔离:子 Agent 的 session history、长期记忆、每日记忆。 + +### 3.3 运行模型 + +- 主 Agent 发起任务后,创建/复用子 Agent profile。 +- 通过 `pipeline_create` 建立任务 DAG。 +- 通过 `pipeline_dispatch` 调度 dependency-ready 任务到子 Agent。 +- 子 Agent 完成后回填结果,主 Agent 汇总并回复用户。 + +--- + +## 4. 数据模型设计 + +## 4.1 SubagentProfile(新增,持久化) + +建议存储位置:`workspace/agents/profiles/.json` + +字段: + +- `agent_id`: 全局唯一 ID(如 `coder`, `writer`, `tester`)。 +- `name`: 展示名。 +- `role`: 角色(coding/docs/testing/research...)。 +- `system_prompt`: 角色系统提示词模板。 +- `tool_allowlist`: 可调用工具白名单。 +- `memory_namespace`: 记忆命名空间,默认同 `agent_id`。 +- `status`: `active|disabled`。 +- `created_at` / `updated_at`。 + +## 4.2 SubagentTask(扩展现有) + +文件:`pkg/tools/subagent.go` + +已有字段基础上强化: + +- `Role`:落库并展示。 +- `AgentID`:绑定具体子 Agent profile。 +- `SessionKey`:固定记录实际执行 session key。 +- `MemoryNamespace`:记录使用的记忆命名空间。 + +## 4.3 记忆目录结构(新增约定) + +- 主 Agent(保持现状): + - `workspace/MEMORY.md` + - `workspace/memory/YYYY-MM-DD.md` +- 子 Agent ``: + - `workspace/agents//MEMORY.md` + - `workspace/agents//memory/YYYY-MM-DD.md` + +--- + +## 5. 工具与接口设计 + +## 5.1 现有工具接入调整 + +- `spawn` + - 新增/强化参数:`agent_id`、`role`。 + - 优先级:`agent_id` > `role`(role 可映射默认 profile)。 +- `subagents` + - `list/info` 输出包含:`agent_id`、`role`、`session_key`、`memory_namespace`。 + +## 5.2 新增工具建议 + +- `subagent_profile` + - action: `create|get|list|update|disable|enable|delete` + - 用途:由主 Agent 在对话中动态创建/管理子 Agent。 +- `subagent_dispatch`(可选) + - 对单个 profile 下发任务,内部调用 spawn 并处理 profile 绑定。 + +## 5.3 Pipeline 工具注册 + +需在 `pkg/agent/loop.go` 中注册: + +- `pipeline_create` +- `pipeline_status` +- `pipeline_state_set` +- `pipeline_dispatch` + +使主 Agent 能直接调用现有编排能力。 + +--- + +## 6. 执行流程(主对话驱动) + +1. 用户下达复合目标(如“先实现功能,再生成文档”)。 +2. 主 Agent 判断是否已有对应 profile: + - 无则 `subagent_profile.create`。 + - 有则复用。 +3. 主 Agent 生成 pipeline 任务图(含依赖)。 +4. 主 Agent 调用 `pipeline_dispatch` 派发可运行任务。 +5. 子 Agent 执行并回填结果到任务状态/共享状态。 +6. 主 Agent 轮询 `pipeline_status`,直到完成或失败。 +7. 主 Agent 汇总结果,面向用户输出最终响应。 + +--- + +## 7. 会话与记忆隔离实现要点 + +## 7.1 Session Key 规范 + +禁止使用进程级固定 key。 + +建议格式: + +- `subagent::` + +效果: + +- 同一子 Agent 不同任务可隔离上下文。 +- 可追溯某次任务完整对话与工具链。 + +## 7.2 ContextBuilder 多命名空间支持 + +为 `MemoryStore` 增加 namespace 参数,按 namespace 读取不同路径: + +- namespace=`main` -> 当前全局路径(兼容)。 +- namespace=`` -> `workspace/agents//...` + +在子 Agent 执行入口按任务绑定 namespace 构建上下文。 + +## 7.3 Memory Tool 命名空间透传 + +`memory_search` / `memory_get` / `memory_write` 增加可选参数: + +- `namespace`(默认 `main`) + +子 Agent 默认注入自身 namespace,避免写入全局记忆。 + +--- + +## 8. 调度与并发控制 + +- 同会话调度仍受 `SessionScheduler` 约束。 +- pipeline 维度使用依赖图保证顺序正确。 +- 对写冲突资源建议引入 `resource_keys`(文件路径、模块名)串行化。 +- 子 Agent 失败后可支持: + - `resume` + - 自动重试(指数退避) + - 主 Agent 降级接管 + +--- + +## 9. 安全与治理 + +- 子 Agent 继承 `AGENTS.md` 安全策略。 +- 外部/破坏性动作必须经主 Agent 显式确认。 +- 子 Agent 工具权限建议最小化(allowlist)。 +- 审计日志需记录: + - 谁创建了哪个子 Agent + - 子 Agent 执行了什么任务 + - 任务结果和错误 + +--- + +## 10. 实施顺序(不含工期) + +### 阶段 A:打通主调度闭环 + +- 修正子任务 session key 规范化。 +- 将 `Role`、`AgentID` 写入任务结构与 `subagents` 输出。 +- 在主循环注册 `pipeline_*` 工具并验证端到端可调度。 + +### 阶段 B:子 Agent 记忆隔离 + +- 实现 `SubagentProfile` 持久化与管理工具。 +- `MemoryStore` 增加 namespace。 +- memory 系列工具支持 namespace。 + +### 阶段 C:稳定性与治理增强 + +- 增加资源冲突控制(resource keys)。 +- 增加失败重试、超时、配额与审计维度。 +- 增加 WebUI 可视化(profile/task/pipeline/memory namespace)。 + +--- + +## 13. 当前实现状态(代码已落地) + +### 13.1 已完成 + +- 主循环已注册: + - `subagent_profile` + - `pipeline_create` + - `pipeline_status` + - `pipeline_state_set` + - `pipeline_dispatch` +- `spawn` 已支持: + - `agent_id` + - `role` + - `agent_id > role` 解析优先级 + - 当仅提供 `role` 时,按 role 自动映射已存在 profile(最近更新优先) +- 子任务会话隔离: + - `session_key` 使用 `subagent::` +- 子任务记忆隔离: + - `memory namespace` 已接入 `ContextBuilder` 与 `memory_search/get/write` + - 子 Agent 运行时会自动注入其 namespace +- 子任务治理信息增强: + - `subagents list/info/log` 可显示 `agent_id/role/session_key/memory namespace/tool allowlist` +- 安全治理: + - `tool_allowlist` 已在执行期强制拦截 + - `parallel` 工具的内部子调用也会被白名单校验 + - disabled profile 会阻止 `spawn` + +### 13.2 待继续增强 + +- `tool_allowlist` 目前为精确匹配(支持 `*`/`all`),尚未支持分组策略(如“只读文件工具组”)。 +- WebUI 尚未提供 profile 的图形化管理入口。 + +--- + +## 11. 验收标准 + +- 用户可通过自然语言要求主 Agent 创建指定角色子 Agent。 +- 主 Agent 可在一次对话内完成“创建 -> 派发 -> 监控 -> 汇总”。 +- 子 Agent 记忆互不污染,主 Agent 与子 Agent 记忆边界清晰。 +- 同一 workspace 下可稳定并发执行多子任务,无明显上下文串线。 +- 任务失败可追踪、可恢复,且审计信息完整。 + +--- + +## 12. 与现有代码的对应关系 + +- 子任务管理:`pkg/tools/subagent.go` +- 子任务工具:`pkg/tools/subagents_tool.go` +- 子任务创建:`pkg/tools/spawn.go` +- 流水线编排:`pkg/tools/orchestrator.go` +- 流水线工具:`pkg/tools/pipeline_tools.go` +- 主循环工具注册:`pkg/agent/loop.go` +- 上下文与记忆:`pkg/agent/context.go`, `pkg/agent/memory.go` +- memory 工具:`pkg/tools/memory*.go` diff --git a/pkg/agent/context.go b/pkg/agent/context.go index d3bbba2..8cc2fea 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -93,6 +93,10 @@ func (cb *ContextBuilder) buildToolsSection() string { } func (cb *ContextBuilder) BuildSystemPrompt() string { + return cb.BuildSystemPromptWithMemoryNamespace("main") +} + +func (cb *ContextBuilder) BuildSystemPromptWithMemoryNamespace(memoryNamespace string) string { parts := []string{} // Core identity section @@ -111,7 +115,11 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { } // Memory context - memoryContext := cb.memory.GetMemoryContext() + memStore := cb.memory + if ns := normalizeMemoryNamespace(memoryNamespace); ns != "main" { + memStore = NewMemoryStoreWithNamespace(cb.workspace, ns) + } + memoryContext := memStore.GetMemoryContext() if memoryContext != "" { parts = append(parts, memoryContext) } @@ -165,9 +173,13 @@ func (cb *ContextBuilder) shouldLoadBootstrap() bool { } func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID, responseLanguage string) []providers.Message { + return cb.BuildMessagesWithMemoryNamespace(history, summary, currentMessage, media, channel, chatID, responseLanguage, "main") +} + +func (cb *ContextBuilder) BuildMessagesWithMemoryNamespace(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID, responseLanguage, memoryNamespace string) []providers.Message { messages := []providers.Message{} - systemPrompt := cb.BuildSystemPrompt() + systemPrompt := cb.BuildSystemPromptWithMemoryNamespace(memoryNamespace) // Add Current Session info if provided if channel != "" && chatID != "" { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0b1a472..6328d29 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -172,6 +172,13 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers spawnTool := tools.NewSpawnTool(subagentManager) toolsRegistry.Register(spawnTool) toolsRegistry.Register(tools.NewSubagentsTool(subagentManager)) + if store := subagentManager.ProfileStore(); store != nil { + toolsRegistry.Register(tools.NewSubagentProfileTool(store)) + } + toolsRegistry.Register(tools.NewPipelineCreateTool(orchestrator)) + toolsRegistry.Register(tools.NewPipelineStatusTool(orchestrator)) + toolsRegistry.Register(tools.NewPipelineStateSetTool(orchestrator)) + toolsRegistry.Register(tools.NewPipelineDispatchTool(orchestrator, subagentManager)) toolsRegistry.Register(tools.NewSessionsTool( func(limit int) []tools.SessionInfo { sessions := alSessionListForTool(sessionsManager, limit) @@ -268,9 +275,19 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } // Inject recursive run logic so subagents can use full tool-calling flows. - subagentManager.SetRunFunc(func(ctx context.Context, task, channel, chatID string) (string, error) { - sessionKey := fmt.Sprintf("subagent:%d", os.Getpid()) // Use PID/randomized key to reduce session key collisions. - return loop.ProcessDirect(ctx, task, sessionKey) + subagentManager.SetRunFunc(func(ctx context.Context, task *tools.SubagentTask) (string, error) { + if task == nil { + return "", fmt.Errorf("subagent task is nil") + } + sessionKey := strings.TrimSpace(task.SessionKey) + if sessionKey == "" { + sessionKey = fmt.Sprintf("subagent:%s", strings.TrimSpace(task.ID)) + } + taskInput := task.Task + if p := strings.TrimSpace(task.SystemPrompt); p != "" { + taskInput = fmt.Sprintf("Role Profile Prompt:\n%s\n\nTask:\n%s", p, task.Task) + } + return loop.ProcessDirectWithOptions(ctx, taskInput, sessionKey, task.OriginChannel, task.OriginChatID, task.MemoryNS, task.ToolAllowlist) }) return loop @@ -682,12 +699,40 @@ func (al *AgentLoop) prepareOutbound(msg bus.InboundMessage, response string) (b } func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) { + return al.ProcessDirectWithOptions(ctx, content, sessionKey, "cli", "direct", "main", nil) +} + +func (al *AgentLoop) ProcessDirectWithOptions(ctx context.Context, content, sessionKey, channel, chatID, memoryNamespace string, toolAllowlist []string) (string, error) { + channel = strings.TrimSpace(channel) + if channel == "" { + channel = "cli" + } + chatID = strings.TrimSpace(chatID) + if chatID == "" { + chatID = "direct" + } + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + sessionKey = "main" + } + ns := normalizeMemoryNamespace(memoryNamespace) + var metadata map[string]string + if ns != "main" { + metadata = map[string]string{ + "memory_namespace": ns, + "memory_ns": ns, + } + } + ctx = withMemoryNamespaceContext(ctx, ns) + ctx = withToolAllowlistContext(ctx, toolAllowlist) + msg := bus.InboundMessage{ - Channel: "cli", + Channel: channel, SenderID: "user", - ChatID: "direct", + ChatID: chatID, Content: content, SessionKey: sessionKey, + Metadata: metadata, } return al.processPlannedMessage(ctx, msg) @@ -701,6 +746,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) if msg.SessionKey == "" { msg.SessionKey = "main" } + memoryNamespace := resolveInboundMemoryNamespace(msg) + ctx = withMemoryNamespaceContext(ctx, memoryNamespace) release, err := al.acquireSessionResources(ctx, &msg) if err != nil { return "", err @@ -733,7 +780,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) preferredLang, lastLang := al.sessions.GetLanguagePreferences(msg.SessionKey) responseLang := DetectResponseLanguage(msg.Content, preferredLang, lastLang) - messages := al.contextBuilder.BuildMessages( + messages := al.contextBuilder.BuildMessagesWithMemoryNamespace( history, summary, msg.Content, @@ -741,6 +788,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) msg.Channel, msg.ChatID, responseLang, + memoryNamespace, ) iteration := 0 @@ -1554,12 +1602,174 @@ func withToolContextArgs(toolName string, args map[string]interface{}, channel, } func (al *AgentLoop) executeToolCall(ctx context.Context, toolName string, args map[string]interface{}, currentChannel, currentChatID string) (string, error) { + if err := ensureToolAllowedByContext(ctx, toolName, args); err != nil { + return "", err + } + args = withToolMemoryNamespaceArgs(toolName, args, memoryNamespaceFromContext(ctx)) if shouldSuppressSelfMessageSend(toolName, args, currentChannel, currentChatID) { return "Suppressed message tool self-send in current chat; assistant will reply via normal outbound.", nil } return al.tools.Execute(ctx, toolName, args) } +func withToolMemoryNamespaceArgs(toolName string, args map[string]interface{}, namespace string) map[string]interface{} { + ns := normalizeMemoryNamespace(namespace) + if ns == "main" { + return args + } + switch strings.TrimSpace(toolName) { + case "memory_search", "memory_get", "memory_write": + default: + return args + } + + if raw, ok := args["namespace"].(string); ok && strings.TrimSpace(raw) != "" { + return args + } + next := make(map[string]interface{}, len(args)+1) + for k, v := range args { + next[k] = v + } + next["namespace"] = ns + return next +} + +type agentContextKey string + +const memoryNamespaceContextKey agentContextKey = "memory_namespace" +const toolAllowlistContextKey agentContextKey = "tool_allowlist" + +func withMemoryNamespaceContext(ctx context.Context, namespace string) context.Context { + ns := normalizeMemoryNamespace(namespace) + if ns == "main" { + return ctx + } + return context.WithValue(ctx, memoryNamespaceContextKey, ns) +} + +func memoryNamespaceFromContext(ctx context.Context) string { + if ctx == nil { + return "main" + } + raw, _ := ctx.Value(memoryNamespaceContextKey).(string) + return normalizeMemoryNamespace(raw) +} + +func withToolAllowlistContext(ctx context.Context, allowlist []string) context.Context { + normalized := normalizeToolAllowlist(allowlist) + if len(normalized) == 0 { + return ctx + } + return context.WithValue(ctx, toolAllowlistContextKey, normalized) +} + +func toolAllowlistFromContext(ctx context.Context) map[string]struct{} { + if ctx == nil { + return nil + } + raw, _ := ctx.Value(toolAllowlistContextKey).(map[string]struct{}) + if len(raw) == 0 { + return nil + } + return raw +} + +func ensureToolAllowedByContext(ctx context.Context, toolName string, args map[string]interface{}) error { + allow := toolAllowlistFromContext(ctx) + if len(allow) == 0 { + return nil + } + + name := strings.ToLower(strings.TrimSpace(toolName)) + if name == "" { + return fmt.Errorf("tool name is empty") + } + if !isToolNameAllowed(allow, name) { + return fmt.Errorf("tool '%s' is not allowed by subagent profile", toolName) + } + + if name == "parallel" { + if err := validateParallelAllowlistArgs(allow, args); err != nil { + return err + } + } + return nil +} + +func validateParallelAllowlistArgs(allow map[string]struct{}, args map[string]interface{}) error { + callsRaw, ok := args["calls"].([]interface{}) + if !ok { + return nil + } + for i, call := range callsRaw { + m, ok := call.(map[string]interface{}) + if !ok { + continue + } + tool, _ := m["tool"].(string) + name := strings.ToLower(strings.TrimSpace(tool)) + if name == "" { + continue + } + if !isToolNameAllowed(allow, name) { + return fmt.Errorf("tool 'parallel' contains disallowed call[%d]: %s", i, tool) + } + } + return nil +} + +func normalizeToolAllowlist(in []string) map[string]struct{} { + if len(in) == 0 { + return nil + } + out := make(map[string]struct{}, len(in)) + for _, item := range in { + name := strings.ToLower(strings.TrimSpace(item)) + if name == "" { + continue + } + out[name] = struct{}{} + } + if len(out) == 0 { + return nil + } + return out +} + +func isToolNameAllowed(allow map[string]struct{}, name string) bool { + if len(allow) == 0 { + return true + } + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return false + } + if _, ok := allow["*"]; ok { + return true + } + if _, ok := allow["all"]; ok { + return true + } + _, ok := allow[name] + return ok +} + +func resolveInboundMemoryNamespace(msg bus.InboundMessage) string { + if msg.Channel == "system" { + return "main" + } + if msg.Metadata == nil { + return "main" + } + if v := strings.TrimSpace(msg.Metadata["memory_namespace"]); v != "" { + return normalizeMemoryNamespace(v) + } + if v := strings.TrimSpace(msg.Metadata["memory_ns"]); v != "" { + return normalizeMemoryNamespace(v) + } + return "main" +} + func shouldSuppressSelfMessageSend(toolName string, args map[string]interface{}, currentChannel, currentChatID string) bool { if strings.TrimSpace(toolName) != "message" { return false diff --git a/pkg/agent/loop_allowlist_test.go b/pkg/agent/loop_allowlist_test.go new file mode 100644 index 0000000..2239d89 --- /dev/null +++ b/pkg/agent/loop_allowlist_test.go @@ -0,0 +1,44 @@ +package agent + +import ( + "context" + "testing" +) + +func TestEnsureToolAllowedByContext(t *testing.T) { + ctx := context.Background() + + if err := ensureToolAllowedByContext(ctx, "write_file", map[string]interface{}{}); err != nil { + t.Fatalf("expected unrestricted context to allow tool, got: %v", err) + } + + restricted := withToolAllowlistContext(ctx, []string{"read_file", "memory_search"}) + if err := ensureToolAllowedByContext(restricted, "read_file", map[string]interface{}{}); err != nil { + t.Fatalf("expected allowed tool to pass, got: %v", err) + } + if err := ensureToolAllowedByContext(restricted, "write_file", map[string]interface{}{}); err == nil { + t.Fatalf("expected disallowed tool to fail") + } +} + +func TestEnsureToolAllowedByContextParallelNested(t *testing.T) { + restricted := withToolAllowlistContext(context.Background(), []string{"parallel", "read_file"}) + + okArgs := map[string]interface{}{ + "calls": []interface{}{ + map[string]interface{}{"tool": "read_file", "arguments": map[string]interface{}{"path": "README.md"}}, + }, + } + if err := ensureToolAllowedByContext(restricted, "parallel", okArgs); err != nil { + t.Fatalf("expected parallel with allowed nested tool to pass, got: %v", err) + } + + badArgs := map[string]interface{}{ + "calls": []interface{}{ + map[string]interface{}{"tool": "write_file", "arguments": map[string]interface{}{"path": "README.md", "content": "x"}}, + }, + } + if err := ensureToolAllowedByContext(restricted, "parallel", badArgs); err == nil { + t.Fatalf("expected parallel with disallowed nested tool to fail") + } +} diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 1f15e80..c232358 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" ) @@ -18,17 +19,28 @@ import ( // - Daily notes: memory/YYYY-MM-DD.md // It also supports legacy locations for backward compatibility. type MemoryStore struct { - workspace string - memoryDir string - memoryFile string + workspace string + namespace string + memoryDir string + memoryFile string legacyMemoryFile string } // NewMemoryStore creates a new MemoryStore with the given workspace path. // It ensures the memory directory exists. func NewMemoryStore(workspace string) *MemoryStore { - memoryDir := filepath.Join(workspace, "memory") - memoryFile := filepath.Join(workspace, "MEMORY.md") + return NewMemoryStoreWithNamespace(workspace, "main") +} + +func NewMemoryStoreWithNamespace(workspace, namespace string) *MemoryStore { + ns := normalizeMemoryNamespace(namespace) + baseDir := workspace + if ns != "main" { + baseDir = filepath.Join(workspace, "agents", ns) + } + + memoryDir := filepath.Join(baseDir, "memory") + memoryFile := filepath.Join(baseDir, "MEMORY.md") legacyMemoryFile := filepath.Join(memoryDir, "MEMORY.md") // Ensure memory directory exists @@ -36,6 +48,7 @@ func NewMemoryStore(workspace string) *MemoryStore { return &MemoryStore{ workspace: workspace, + namespace: ns, memoryDir: memoryDir, memoryFile: memoryFile, legacyMemoryFile: legacyMemoryFile, @@ -169,3 +182,28 @@ func (ms *MemoryStore) GetMemoryContext() string { } return fmt.Sprintf("# Memory\n\n%s", result) } + +func normalizeMemoryNamespace(namespace string) string { + namespace = strings.TrimSpace(strings.ToLower(namespace)) + if namespace == "" || namespace == "main" { + return "main" + } + var sb strings.Builder + for _, r := range namespace { + switch { + case r >= 'a' && r <= 'z': + sb.WriteRune(r) + case r >= '0' && r <= '9': + sb.WriteRune(r) + case r == '-' || r == '_' || r == '.': + sb.WriteRune(r) + case r == ' ': + sb.WriteRune('-') + } + } + out := strings.Trim(sb.String(), "-_.") + if out == "" { + return "main" + } + return out +} diff --git a/pkg/agent/session_planner.go b/pkg/agent/session_planner.go index f238436..6ad51d7 100644 --- a/pkg/agent/session_planner.go +++ b/pkg/agent/session_planner.go @@ -215,10 +215,14 @@ func (al *AgentLoop) memoryHintForTask(ctx context.Context, task plannedTask) st if al == nil || al.tools == nil { return "" } - res, err := al.tools.Execute(ctx, "memory_search", map[string]interface{}{ + args := map[string]interface{}{ "query": task.Content, "maxResults": 2, - }) + } + if ns := memoryNamespaceFromContext(ctx); ns != "main" { + args["namespace"] = ns + } + res, err := al.tools.Execute(ctx, "memory_search", args) if err != nil { return "" } diff --git a/pkg/api/server.go b/pkg/api/server.go index 13fdbbe..57e0db1 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -28,6 +28,7 @@ import ( cfgpkg "clawgo/pkg/config" "clawgo/pkg/nodes" + "clawgo/pkg/tools" ) type Server struct { @@ -100,6 +101,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/webui/api/skills", s.handleWebUISkills) mux.HandleFunc("/webui/api/sessions", s.handleWebUISessions) mux.HandleFunc("/webui/api/memory", s.handleWebUIMemory) + mux.HandleFunc("/webui/api/subagent_profiles", s.handleWebUISubagentProfiles) mux.HandleFunc("/webui/api/task_audit", s.handleWebUITaskAudit) mux.HandleFunc("/webui/api/task_queue", s.handleWebUITaskQueue) mux.HandleFunc("/webui/api/tasks", s.handleWebUITasks) @@ -1850,6 +1852,172 @@ func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "sessions": out}) } +func (s *Server) handleWebUISubagentProfiles(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + workspace := strings.TrimSpace(s.workspacePath) + if workspace == "" { + http.Error(w, "workspace path not set", http.StatusInternalServerError) + return + } + store := tools.NewSubagentProfileStore(workspace) + + switch r.Method { + case http.MethodGet: + agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) + if agentID != "" { + profile, ok, err := store.Get(agentID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "found": ok, "profile": profile}) + return + } + profiles, err := store.List() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profiles": profiles}) + case http.MethodDelete: + agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) + if agentID == "" { + http.Error(w, "agent_id required", http.StatusBadRequest) + return + } + if err := store.Delete(agentID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "deleted": true, "agent_id": agentID}) + case http.MethodPost: + var body struct { + Action string `json:"action"` + AgentID string `json:"agent_id"` + Name string `json:"name"` + Role string `json:"role"` + SystemPrompt string `json:"system_prompt"` + MemoryNamespace string `json:"memory_namespace"` + Status string `json:"status"` + ToolAllowlist []string `json:"tool_allowlist"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + action := strings.ToLower(strings.TrimSpace(body.Action)) + if action == "" { + action = "upsert" + } + agentID := strings.TrimSpace(body.AgentID) + if agentID == "" { + http.Error(w, "agent_id required", http.StatusBadRequest) + return + } + + switch action { + case "create": + if _, ok, err := store.Get(agentID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else if ok { + http.Error(w, "subagent profile already exists", http.StatusConflict) + return + } + profile, err := store.Upsert(tools.SubagentProfile{ + AgentID: agentID, + Name: body.Name, + Role: body.Role, + SystemPrompt: body.SystemPrompt, + MemoryNamespace: body.MemoryNamespace, + Status: body.Status, + ToolAllowlist: body.ToolAllowlist, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profile": profile}) + case "update": + existing, ok, err := store.Get(agentID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !ok || existing == nil { + http.Error(w, "subagent profile not found", http.StatusNotFound) + return + } + next := *existing + next.Name = body.Name + next.Role = body.Role + next.SystemPrompt = body.SystemPrompt + next.MemoryNamespace = body.MemoryNamespace + if body.Status != "" { + next.Status = body.Status + } + if body.ToolAllowlist != nil { + next.ToolAllowlist = body.ToolAllowlist + } + profile, err := store.Upsert(next) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profile": profile}) + case "enable", "disable": + existing, ok, err := store.Get(agentID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !ok || existing == nil { + http.Error(w, "subagent profile not found", http.StatusNotFound) + return + } + if action == "enable" { + existing.Status = "active" + } else { + existing.Status = "disabled" + } + profile, err := store.Upsert(*existing) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profile": profile}) + case "delete": + if err := store.Delete(agentID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "deleted": true, "agent_id": agentID}) + case "upsert": + profile, err := store.Upsert(tools.SubagentProfile{ + AgentID: agentID, + Name: body.Name, + Role: body.Role, + SystemPrompt: body.SystemPrompt, + MemoryNamespace: body.MemoryNamespace, + Status: body.Status, + ToolAllowlist: body.ToolAllowlist, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "profile": profile}) + default: + http.Error(w, "unsupported action", http.StatusBadRequest) + } + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + func (s *Server) handleWebUIMemory(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) diff --git a/pkg/tools/memory.go b/pkg/tools/memory.go index 387d9be..841c63d 100644 --- a/pkg/tools/memory.go +++ b/pkg/tools/memory.go @@ -54,6 +54,11 @@ func (t *MemorySearchTool) Parameters() map[string]interface{} { "type": "string", "description": "Search query keywords (e.g., 'docker deploy project')", }, + "namespace": map[string]interface{}{ + "type": "string", + "description": "Optional memory namespace. Use main for workspace memory, or subagent id for isolated memory.", + "default": "main", + }, "maxResults": map[string]interface{}{ "type": "integer", "description": "Maximum number of results to return", @@ -76,6 +81,7 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interfac if !ok || query == "" { return "", fmt.Errorf("query is required") } + namespace := parseMemoryNamespaceArg(args) maxResults := 5 if m, ok := args["maxResults"].(float64); ok { @@ -90,8 +96,8 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interfac return "Please provide search keywords.", nil } - files := t.getMemoryFiles() - + files := t.getMemoryFiles(namespace) + resultsChan := make(chan []searchResult, len(files)) var wg sync.WaitGroup @@ -137,13 +143,16 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interfac } if len(allResults) == 0 { - return fmt.Sprintf("No memory found for query: %s", query), nil + return fmt.Sprintf("No memory found for query: %s (namespace=%s)", query, namespace), nil } var sb strings.Builder - sb.WriteString(fmt.Sprintf("Found %d memories for '%s':\n\n", len(allResults), query)) + sb.WriteString(fmt.Sprintf("Found %d memories for '%s' (namespace=%s):\n\n", len(allResults), query, namespace)) for _, res := range allResults { - relPath, _ := filepath.Rel(t.workspace, res.file) + relPath, err := filepath.Rel(t.workspace, res.file) + if err != nil || strings.HasPrefix(relPath, "..") { + relPath = res.file + } lineEnd := res.lineNum + countLines(res.content) - 1 if lineEnd < res.lineNum { lineEnd = res.lineNum @@ -154,23 +163,25 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interfac return sb.String(), nil } -func (t *MemorySearchTool) getMemoryFiles() []string { +func (t *MemorySearchTool) getMemoryFiles(namespace string) []string { var files []string + base := memoryNamespaceBaseDir(t.workspace, namespace) + // Check workspace MEMORY.md first - mainMem := filepath.Join(t.workspace, "MEMORY.md") + mainMem := filepath.Join(base, "MEMORY.md") if _, err := os.Stat(mainMem); err == nil { files = append(files, mainMem) } // Backward-compatible location: memory/MEMORY.md - legacyMem := filepath.Join(t.workspace, "memory", "MEMORY.md") + legacyMem := filepath.Join(base, "memory", "MEMORY.md") if _, err := os.Stat(legacyMem); err == nil { files = append(files, legacyMem) } // Recursively include memory/**/*.md - memDir := filepath.Join(t.workspace, "memory") + memDir := filepath.Join(base, "memory") _ = filepath.WalkDir(memDir, func(path string, d os.DirEntry, err error) error { if err != nil || d == nil || d.IsDir() { return nil diff --git a/pkg/tools/memory_get.go b/pkg/tools/memory_get.go index e5173e7..a0df901 100644 --- a/pkg/tools/memory_get.go +++ b/pkg/tools/memory_get.go @@ -33,6 +33,11 @@ func (t *MemoryGetTool) Parameters() map[string]interface{} { "type": "string", "description": "Relative path to MEMORY.md or memory/*.md", }, + "namespace": map[string]interface{}{ + "type": "string", + "description": "Optional memory namespace. Use main for workspace memory, or subagent id for isolated memory.", + "default": "main", + }, "from": map[string]interface{}{ "type": "integer", "description": "Start line (1-indexed)", @@ -54,6 +59,10 @@ func (t *MemoryGetTool) Execute(ctx context.Context, args map[string]interface{} if rawPath == "" { return "", fmt.Errorf("path is required") } + if filepath.IsAbs(rawPath) { + return "", fmt.Errorf("absolute path is not allowed") + } + namespace := parseMemoryNamespaceArg(args) from := 1 if v, ok := args["from"].(float64); ok && int(v) > 0 { @@ -67,8 +76,9 @@ func (t *MemoryGetTool) Execute(ctx context.Context, args map[string]interface{} lines = 500 } - fullPath := filepath.Clean(filepath.Join(t.workspace, rawPath)) - if !t.isAllowedMemoryPath(fullPath) { + baseDir := memoryNamespaceBaseDir(t.workspace, namespace) + fullPath := filepath.Clean(filepath.Join(baseDir, rawPath)) + if !t.isAllowedMemoryPath(fullPath, namespace) { return "", fmt.Errorf("path not allowed: %s", rawPath) } @@ -101,17 +111,21 @@ func (t *MemoryGetTool) Execute(ctx context.Context, args map[string]interface{} return fmt.Sprintf("No content in range for %s (from=%d, lines=%d)", rawPath, from, lines), nil } - rel, _ := filepath.Rel(t.workspace, fullPath) + rel, err := filepath.Rel(t.workspace, fullPath) + if err != nil || strings.HasPrefix(rel, "..") { + rel = fullPath + } return fmt.Sprintf("Source: %s#L%d-L%d\n%s", rel, from, end, content), nil } -func (t *MemoryGetTool) isAllowedMemoryPath(fullPath string) bool { - workspaceMemory := filepath.Join(t.workspace, "MEMORY.md") +func (t *MemoryGetTool) isAllowedMemoryPath(fullPath, namespace string) bool { + baseDir := memoryNamespaceBaseDir(t.workspace, namespace) + workspaceMemory := filepath.Join(baseDir, "MEMORY.md") if fullPath == workspaceMemory { return true } - memoryDir := filepath.Join(t.workspace, "memory") + memoryDir := filepath.Join(baseDir, "memory") rel, err := filepath.Rel(memoryDir, fullPath) if err != nil { return false diff --git a/pkg/tools/memory_namespace.go b/pkg/tools/memory_namespace.go new file mode 100644 index 0000000..a18f434 --- /dev/null +++ b/pkg/tools/memory_namespace.go @@ -0,0 +1,38 @@ +package tools + +import ( + "path/filepath" + "strings" +) + +func normalizeMemoryNamespace(in string) string { + v := normalizeSubagentIdentifier(in) + if v == "" { + return "main" + } + return v +} + +func memoryNamespaceBaseDir(workspace, namespace string) string { + ns := normalizeMemoryNamespace(namespace) + if ns == "main" { + return workspace + } + return filepath.Join(workspace, "agents", ns) +} + +func parseMemoryNamespaceArg(args map[string]interface{}) string { + if args == nil { + return "main" + } + raw, _ := args["namespace"].(string) + return normalizeMemoryNamespace(raw) +} + +func isPathUnder(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return !strings.HasPrefix(rel, "..") +} diff --git a/pkg/tools/memory_namespace_test.go b/pkg/tools/memory_namespace_test.go new file mode 100644 index 0000000..0821b8c --- /dev/null +++ b/pkg/tools/memory_namespace_test.go @@ -0,0 +1,103 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestMemoryWriteToolNamespaceIsolation(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + tool := NewMemoryWriteTool(workspace) + + _, err := tool.Execute(context.Background(), map[string]interface{}{ + "content": "main note", + "kind": "daily", + "namespace": "main", + }) + if err != nil { + t.Fatalf("main write failed: %v", err) + } + + _, err = tool.Execute(context.Background(), map[string]interface{}{ + "content": "coder note", + "kind": "daily", + "namespace": "coder", + }) + if err != nil { + t.Fatalf("namespace write failed: %v", err) + } + + today := time.Now().Format("2006-01-02") + ".md" + mainPath := filepath.Join(workspace, "memory", today) + coderPath := filepath.Join(workspace, "agents", "coder", "memory", today) + + mainData, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("read main daily file failed: %v", err) + } + if !strings.Contains(string(mainData), "main note") { + t.Fatalf("main daily memory missing expected content: %s", string(mainData)) + } + + coderData, err := os.ReadFile(coderPath) + if err != nil { + t.Fatalf("read namespaced daily file failed: %v", err) + } + if !strings.Contains(string(coderData), "coder note") { + t.Fatalf("namespaced daily memory missing expected content: %s", string(coderData)) + } +} + +func TestMemorySearchToolNamespaceIsolation(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + write := NewMemoryWriteTool(workspace) + search := NewMemorySearchTool(workspace) + + _, _ = write.Execute(context.Background(), map[string]interface{}{ + "content": "main_unique_keyword_123", + "kind": "longterm", + "importance": "high", + "namespace": "main", + }) + _, _ = write.Execute(context.Background(), map[string]interface{}{ + "content": "coder_unique_keyword_456", + "kind": "longterm", + "importance": "high", + "namespace": "coder", + }) + + mainRes, err := search.Execute(context.Background(), map[string]interface{}{ + "query": "main_unique_keyword_123", + "namespace": "main", + "maxResults": float64(3), + }) + if err != nil { + t.Fatalf("main namespace search failed: %v", err) + } + if !strings.Contains(mainRes, "main_unique_keyword_123") { + t.Fatalf("expected main namespace result to include keyword, got: %s", mainRes) + } + + coderRes, err := search.Execute(context.Background(), map[string]interface{}{ + "query": "coder_unique_keyword_456", + "namespace": "coder", + "maxResults": float64(3), + }) + if err != nil { + t.Fatalf("coder namespace search failed: %v", err) + } + if !strings.Contains(coderRes, "coder_unique_keyword_456") { + t.Fatalf("expected coder namespace result to include keyword, got: %s", coderRes) + } + if strings.Contains(coderRes, "main_unique_keyword_123") { + t.Fatalf("namespace isolation violated, coder search leaked main data: %s", coderRes) + } +} diff --git a/pkg/tools/memory_write.go b/pkg/tools/memory_write.go index 9d8bc03..c3f6692 100644 --- a/pkg/tools/memory_write.go +++ b/pkg/tools/memory_write.go @@ -33,6 +33,11 @@ func (t *MemoryWriteTool) Parameters() map[string]interface{} { "type": "string", "description": "Memory text to write", }, + "namespace": map[string]interface{}{ + "type": "string", + "description": "Optional memory namespace. Use main for workspace memory, or subagent id for isolated memory.", + "default": "main", + }, "kind": map[string]interface{}{ "type": "string", "description": "Target memory kind: longterm or daily", @@ -68,6 +73,8 @@ func (t *MemoryWriteTool) Execute(ctx context.Context, args map[string]interface if content == "" { return "error: content is required", nil } + namespace := parseMemoryNamespaceArg(args) + baseDir := memoryNamespaceBaseDir(t.workspace, namespace) kind, _ := args["kind"].(string) kind = strings.ToLower(strings.TrimSpace(kind)) @@ -100,16 +107,16 @@ func (t *MemoryWriteTool) Execute(ctx context.Context, args map[string]interface switch kind { case "longterm", "memory", "permanent": - path := filepath.Join(t.workspace, "MEMORY.md") + path := filepath.Join(baseDir, "MEMORY.md") if appendMode { return t.appendWithTimestamp(path, formatted) } if err := os.WriteFile(path, []byte(formatted+"\n"), 0644); err != nil { return "", err } - return fmt.Sprintf("Wrote long-term memory: %s", path), nil + return fmt.Sprintf("Wrote long-term memory: %s (namespace=%s)", path, namespace), nil case "daily", "log", "today": - memDir := filepath.Join(t.workspace, "memory") + memDir := filepath.Join(baseDir, "memory") if err := os.MkdirAll(memDir, 0755); err != nil { return "", err } @@ -120,7 +127,7 @@ func (t *MemoryWriteTool) Execute(ctx context.Context, args map[string]interface if err := os.WriteFile(path, []byte(formatted+"\n"), 0644); err != nil { return "", err } - return fmt.Sprintf("Wrote daily memory: %s", path), nil + return fmt.Sprintf("Wrote daily memory: %s (namespace=%s)", path, namespace), nil default: return "", fmt.Errorf("invalid kind '%s', expected longterm or daily", kind) } diff --git a/pkg/tools/pipeline_tools.go b/pkg/tools/pipeline_tools.go index ece69ad..f0b5311 100644 --- a/pkg/tools/pipeline_tools.go +++ b/pkg/tools/pipeline_tools.go @@ -280,7 +280,20 @@ func (t *PipelineDispatchTool) Execute(ctx context.Context, args map[string]inte if task.Role != "" { label = fmt.Sprintf("%s:%s", task.Role, task.ID) } - if _, err := t.spawn.Spawn(ctx, payload, label, "tool", "tool", pipelineID, task.ID); err != nil { + agentID := strings.TrimSpace(task.Role) + if agentID == "" { + agentID = strings.TrimSpace(task.ID) + } + if _, err := t.spawn.Spawn(ctx, SubagentSpawnOptions{ + Task: payload, + Label: label, + Role: task.Role, + AgentID: agentID, + OriginChannel: "tool", + OriginChatID: "tool", + PipelineID: pipelineID, + PipelineTask: task.ID, + }); err != nil { lines = append(lines, fmt.Sprintf("- %s failed: %v", task.ID, err)) continue } diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index 06e5944..63a1413 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -45,6 +45,10 @@ func (t *SpawnTool) Parameters() map[string]interface{} { "type": "string", "description": "Optional role for this subagent, e.g. research/coding/testing", }, + "agent_id": map[string]interface{}{ + "type": "string", + "description": "Optional logical agent ID. If omitted, role will be used as fallback.", + }, "pipeline_id": map[string]interface{}{ "type": "string", "description": "Optional pipeline ID for orchestrated multi-agent workflow", @@ -81,10 +85,13 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (s label, _ := args["label"].(string) role, _ := args["role"].(string) + agentID, _ := args["agent_id"].(string) pipelineID, _ := args["pipeline_id"].(string) taskID, _ := args["task_id"].(string) if label == "" && role != "" { label = role + } else if label == "" && agentID != "" { + label = agentID } if t.manager == nil { @@ -106,7 +113,16 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (s } } - result, err := t.manager.Spawn(ctx, task, label, originChannel, originChatID, pipelineID, taskID) + result, err := t.manager.Spawn(ctx, SubagentSpawnOptions{ + Task: task, + Label: label, + Role: role, + AgentID: agentID, + OriginChannel: originChannel, + OriginChatID: originChatID, + PipelineID: pipelineID, + PipelineTask: taskID, + }) if err != nil { return "", fmt.Errorf("failed to spawn subagent: %w", err) } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index cca87f7..9801005 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -18,6 +18,11 @@ type SubagentTask struct { Task string Label string Role string + AgentID string + SessionKey string + MemoryNS string + SystemPrompt string + ToolAllowlist []string PipelineID string PipelineTask string SharedState map[string]interface{} @@ -41,9 +46,22 @@ type SubagentManager struct { workspace string nextID int runFunc SubagentRunFunc + profileStore *SubagentProfileStore +} + +type SubagentSpawnOptions struct { + Task string + Label string + Role string + AgentID string + OriginChannel string + OriginChatID string + PipelineID string + PipelineTask string } func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus, orc *Orchestrator) *SubagentManager { + store := NewSubagentProfileStore(workspace) return &SubagentManager{ tasks: make(map[string]*SubagentTask), cancelFuncs: make(map[string]context.CancelFunc), @@ -53,21 +71,94 @@ func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *b orc: orc, workspace: workspace, nextID: 1, + profileStore: store, } } -func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID, pipelineID, pipelineTask string) (string, error) { +func (sm *SubagentManager) Spawn(ctx context.Context, opts SubagentSpawnOptions) (string, error) { + task := strings.TrimSpace(opts.Task) + if task == "" { + return "", fmt.Errorf("task is required") + } + label := strings.TrimSpace(opts.Label) + role := strings.TrimSpace(opts.Role) + agentID := normalizeSubagentIdentifier(opts.AgentID) + originalRole := role + var profile *SubagentProfile + if sm.profileStore != nil { + if agentID != "" { + if p, ok, err := sm.profileStore.Get(agentID); err != nil { + return "", err + } else if ok { + profile = p + } + } else if role != "" { + if p, ok, err := sm.profileStore.FindByRole(role); err != nil { + return "", err + } else if ok { + profile = p + agentID = normalizeSubagentIdentifier(p.AgentID) + } + } + } + if agentID == "" { + agentID = normalizeSubagentIdentifier(role) + } + if agentID == "" { + agentID = "default" + } + memoryNS := agentID + systemPrompt := "" + toolAllowlist := []string(nil) + if profile == nil && sm.profileStore != nil { + if p, ok, err := sm.profileStore.Get(agentID); err != nil { + return "", 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) + } + if label == "" { + label = strings.TrimSpace(profile.Name) + } + if role == "" { + role = strings.TrimSpace(profile.Role) + } + if ns := normalizeSubagentIdentifier(profile.MemoryNamespace); ns != "" { + memoryNS = ns + } + systemPrompt = strings.TrimSpace(profile.SystemPrompt) + toolAllowlist = append([]string(nil), profile.ToolAllowlist...) + } + if role == "" { + role = originalRole + } + originChannel := strings.TrimSpace(opts.OriginChannel) + originChatID := strings.TrimSpace(opts.OriginChatID) + pipelineID := strings.TrimSpace(opts.PipelineID) + pipelineTask := strings.TrimSpace(opts.PipelineTask) + sm.mu.Lock() defer sm.mu.Unlock() taskID := fmt.Sprintf("subagent-%d", sm.nextID) sm.nextID++ + sessionKey := buildSubagentSessionKey(agentID, taskID) now := time.Now().UnixMilli() subagentTask := &SubagentTask{ ID: taskID, Task: task, Label: label, + Role: role, + AgentID: agentID, + SessionKey: sessionKey, + MemoryNS: memoryNS, + SystemPrompt: systemPrompt, + ToolAllowlist: toolAllowlist, PipelineID: pipelineID, PipelineTask: pipelineTask, OriginChannel: originChannel, @@ -82,9 +173,12 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel go sm.runTask(taskCtx, subagentTask) - desc := fmt.Sprintf("Spawned subagent for task: %s", task) + desc := fmt.Sprintf("Spawned subagent for task: %s (agent=%s)", task, agentID) if label != "" { - desc = fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task) + 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) @@ -115,7 +209,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { // Fall back to one-shot chat when RunFunc is not injected. if sm.runFunc != nil { - result, err := sm.runFunc(ctx, task.Task, task.OriginChannel, task.OriginChatID) + result, err := sm.runFunc(ctx, task) sm.mu.Lock() if err != nil { task.Status = "failed" @@ -135,7 +229,19 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { sm.mu.Unlock() } else { // Original one-shot logic + if sm.provider == nil { + sm.mu.Lock() + task.Status = "failed" + task.Result = "Error: no llm provider configured for subagent execution" + task.Updated = time.Now().UnixMilli() + if sm.orc != nil && task.PipelineID != "" && task.PipelineTask != "" { + _ = sm.orc.MarkTaskDone(task.PipelineID, task.PipelineTask, task.Result, fmt.Errorf("no llm provider configured for subagent execution")) + } + sm.mu.Unlock() + return + } systemPrompt := "You are a subagent. Follow workspace AGENTS.md and complete the task independently." + rolePrompt := strings.TrimSpace(task.SystemPrompt) if ws := strings.TrimSpace(sm.workspace); ws != "" { if data, err := os.ReadFile(filepath.Join(ws, "AGENTS.md")); err == nil { txt := strings.TrimSpace(string(data)) @@ -144,6 +250,9 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { } } } + if rolePrompt != "" { + systemPrompt += "\n\nRole-specific profile prompt:\n" + rolePrompt + } messages := []providers.Message{ { Role: "system", @@ -192,17 +301,23 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { Channel: "system", SenderID: fmt.Sprintf("subagent:%s", task.ID), ChatID: fmt.Sprintf("%s:%s", task.OriginChannel, task.OriginChatID), - SessionKey: fmt.Sprintf("subagent:%s", task.ID), + SessionKey: task.SessionKey, Content: announceContent, Metadata: map[string]string{ - "trigger": "subagent", - "subagent_id": task.ID, + "trigger": "subagent", + "subagent_id": task.ID, + "agent_id": task.AgentID, + "role": task.Role, + "session_key": task.SessionKey, + "memory_ns": task.MemoryNS, + "pipeline_id": task.PipelineID, + "pipeline_task": task.PipelineTask, }, }) } } -type SubagentRunFunc func(ctx context.Context, task, channel, chatID string) (string, error) +type SubagentRunFunc func(ctx context.Context, task *SubagentTask) (string, error) func (sm *SubagentManager) SetRunFunc(f SubagentRunFunc) { sm.mu.Lock() @@ -210,6 +325,12 @@ func (sm *SubagentManager) SetRunFunc(f SubagentRunFunc) { sm.runFunc = f } +func (sm *SubagentManager) ProfileStore() *SubagentProfileStore { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.profileStore +} + func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { sm.mu.Lock() defer sm.mu.Unlock() @@ -280,7 +401,16 @@ func (sm *SubagentManager) ResumeTask(ctx context.Context, taskID string) (strin } else { label = label + "-resumed" } - _, err := sm.Spawn(ctx, t.Task, label, t.OriginChannel, t.OriginChatID, t.PipelineID, t.PipelineTask) + _, err := sm.Spawn(ctx, SubagentSpawnOptions{ + Task: t.Task, + Label: label, + Role: t.Role, + AgentID: t.AgentID, + OriginChannel: t.OriginChannel, + OriginChatID: t.OriginChatID, + PipelineID: t.PipelineID, + PipelineTask: t.PipelineTask, + }) if err != nil { return "", false } @@ -302,3 +432,40 @@ func (sm *SubagentManager) pruneArchivedLocked() { } } } + +func normalizeSubagentIdentifier(in string) string { + in = strings.TrimSpace(strings.ToLower(in)) + if in == "" { + return "" + } + var sb strings.Builder + for _, r := range in { + switch { + case r >= 'a' && r <= 'z': + sb.WriteRune(r) + case r >= '0' && r <= '9': + sb.WriteRune(r) + case r == '-' || r == '_' || r == '.': + sb.WriteRune(r) + case r == ' ': + sb.WriteRune('-') + } + } + out := strings.Trim(sb.String(), "-_.") + if out == "" { + return "" + } + return out +} + +func buildSubagentSessionKey(agentID, taskID string) string { + a := normalizeSubagentIdentifier(agentID) + if a == "" { + a = "default" + } + t := normalizeSubagentIdentifier(taskID) + if t == "" { + t = "task" + } + return fmt.Sprintf("subagent:%s:%s", a, t) +} diff --git a/pkg/tools/subagent_profile.go b/pkg/tools/subagent_profile.go new file mode 100644 index 0000000..99f3083 --- /dev/null +++ b/pkg/tools/subagent_profile.go @@ -0,0 +1,425 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type SubagentProfile struct { + AgentID string `json:"agent_id"` + Name string `json:"name"` + Role string `json:"role,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + ToolAllowlist []string `json:"tool_allowlist,omitempty"` + MemoryNamespace string `json:"memory_namespace,omitempty"` + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type SubagentProfileStore struct { + workspace string + mu sync.RWMutex +} + +func NewSubagentProfileStore(workspace string) *SubagentProfileStore { + return &SubagentProfileStore{workspace: strings.TrimSpace(workspace)} +} + +func (s *SubagentProfileStore) profilesDir() string { + return filepath.Join(s.workspace, "agents", "profiles") +} + +func (s *SubagentProfileStore) profilePath(agentID string) string { + id := normalizeSubagentIdentifier(agentID) + return filepath.Join(s.profilesDir(), id+".json") +} + +func (s *SubagentProfileStore) List() ([]SubagentProfile, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + 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)) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].UpdatedAt != out[j].UpdatedAt { + return out[i].UpdatedAt > out[j].UpdatedAt + } + return out[i].AgentID < out[j].AgentID + }) + return out, nil +} + +func (s *SubagentProfileStore) Get(agentID string) (*SubagentProfile, bool, error) { + id := normalizeSubagentIdentifier(agentID) + if id == "" { + return nil, false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + + b, err := os.ReadFile(s.profilePath(id)) + 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 + } + norm := normalizeSubagentProfile(p) + return &norm, true, nil +} + +func (s *SubagentProfileStore) FindByRole(role string) (*SubagentProfile, bool, error) { + target := strings.ToLower(strings.TrimSpace(role)) + if target == "" { + return nil, false, nil + } + items, err := s.List() + if err != nil { + return nil, false, err + } + for _, p := range items { + if strings.ToLower(strings.TrimSpace(p.Role)) == target { + cp := p + return &cp, true, nil + } + } + return nil, false, nil +} + +func (s *SubagentProfileStore) Upsert(profile SubagentProfile) (*SubagentProfile, error) { + p := normalizeSubagentProfile(profile) + if p.AgentID == "" { + return nil, fmt.Errorf("agent_id is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UnixMilli() + path := s.profilePath(p.AgentID) + existing := SubagentProfile{} + if b, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(b, &existing) + } + existing = normalizeSubagentProfile(existing) + if existing.CreatedAt > 0 { + p.CreatedAt = existing.CreatedAt + } else if p.CreatedAt <= 0 { + p.CreatedAt = now + } + p.UpdatedAt = now + + if err := os.MkdirAll(s.profilesDir(), 0755); err != nil { + return nil, err + } + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return nil, err + } + if err := os.WriteFile(path, b, 0644); err != nil { + return nil, err + } + return &p, nil +} + +func (s *SubagentProfileStore) Delete(agentID string) error { + id := normalizeSubagentIdentifier(agentID) + if id == "" { + return fmt.Errorf("agent_id is required") + } + s.mu.Lock() + defer s.mu.Unlock() + + err := os.Remove(s.profilePath(id)) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func normalizeSubagentProfile(in SubagentProfile) SubagentProfile { + p := in + p.AgentID = normalizeSubagentIdentifier(p.AgentID) + p.Name = strings.TrimSpace(p.Name) + if p.Name == "" { + p.Name = p.AgentID + } + p.Role = strings.TrimSpace(p.Role) + p.SystemPrompt = strings.TrimSpace(p.SystemPrompt) + p.MemoryNamespace = normalizeSubagentIdentifier(p.MemoryNamespace) + if p.MemoryNamespace == "" { + p.MemoryNamespace = p.AgentID + } + p.Status = normalizeProfileStatus(p.Status) + p.ToolAllowlist = normalizeToolAllowlist(p.ToolAllowlist) + return p +} + +func normalizeProfileStatus(s string) string { + v := strings.ToLower(strings.TrimSpace(s)) + switch v { + case "active", "disabled": + return v + default: + return "active" + } +} + +func normalizeStringList(in []string) []string { + if len(in) == 0 { + return nil + } + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, item := range in { + v := strings.TrimSpace(item) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} + +func normalizeToolAllowlist(in []string) []string { + items := normalizeStringList(in) + if len(items) == 0 { + return nil + } + for i := range items { + items[i] = strings.ToLower(strings.TrimSpace(items[i])) + } + items = normalizeStringList(items) + sort.Strings(items) + return items +} + +func parseStringList(raw interface{}) []string { + items, ok := raw.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + s, _ := item.(string) + s = strings.TrimSpace(s) + if s == "" { + continue + } + out = append(out, s) + } + return normalizeStringList(out) +} + +type SubagentProfileTool struct { + store *SubagentProfileStore +} + +func NewSubagentProfileTool(store *SubagentProfileStore) *SubagentProfileTool { + return &SubagentProfileTool{store: store} +} + +func (t *SubagentProfileTool) Name() string { return "subagent_profile" } + +func (t *SubagentProfileTool) Description() string { + return "Manage subagent profiles: create/list/get/update/enable/disable/delete." +} + +func (t *SubagentProfileTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{"type": "string", "description": "create|list|get|update|enable|disable|delete"}, + "agent_id": map[string]interface{}{ + "type": "string", + "description": "Unique subagent id, e.g. coder/writer/tester", + }, + "name": map[string]interface{}{"type": "string"}, + "role": map[string]interface{}{"type": "string"}, + "system_prompt": map[string]interface{}{"type": "string"}, + "memory_namespace": map[string]interface{}{"type": "string"}, + "status": map[string]interface{}{"type": "string", "description": "active|disabled"}, + "tool_allowlist": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + }, + }, + "required": []string{"action"}, + } +} + +func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + _ = ctx + if t.store == nil { + return "subagent profile store not available", nil + } + action, _ := args["action"].(string) + action = strings.ToLower(strings.TrimSpace(action)) + agentID, _ := args["agent_id"].(string) + agentID = normalizeSubagentIdentifier(agentID) + + switch action { + case "list": + items, err := t.store.List() + if err != nil { + return "", err + } + if len(items) == 0 { + return "No subagent profiles.", nil + } + var sb strings.Builder + sb.WriteString("Subagent Profiles:\n") + for i, p := range items { + sb.WriteString(fmt.Sprintf("- #%d %s [%s] role=%s memory_ns=%s\n", i+1, p.AgentID, p.Status, p.Role, p.MemoryNamespace)) + } + return strings.TrimSpace(sb.String()), nil + case "get": + if agentID == "" { + return "agent_id is required", nil + } + p, ok, err := t.store.Get(agentID) + if err != nil { + return "", err + } + if !ok { + return "subagent profile not found", nil + } + b, _ := json.MarshalIndent(p, "", " ") + return string(b), nil + case "create": + if agentID == "" { + return "agent_id is required", nil + } + if _, ok, err := t.store.Get(agentID); err != nil { + return "", err + } else if ok { + return "subagent profile already exists", nil + } + p := SubagentProfile{ + AgentID: agentID, + Name: stringArg(args, "name"), + Role: stringArg(args, "role"), + SystemPrompt: stringArg(args, "system_prompt"), + MemoryNamespace: stringArg(args, "memory_namespace"), + Status: stringArg(args, "status"), + ToolAllowlist: parseStringList(args["tool_allowlist"]), + } + saved, err := t.store.Upsert(p) + if err != nil { + return "", err + } + return fmt.Sprintf("Created subagent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil + case "update": + if agentID == "" { + return "agent_id is required", nil + } + existing, ok, err := t.store.Get(agentID) + if err != nil { + return "", err + } + if !ok { + return "subagent profile not found", nil + } + next := *existing + if _, ok := args["name"]; ok { + next.Name = stringArg(args, "name") + } + if _, ok := args["role"]; ok { + next.Role = stringArg(args, "role") + } + if _, ok := args["system_prompt"]; ok { + next.SystemPrompt = stringArg(args, "system_prompt") + } + if _, ok := args["memory_namespace"]; ok { + next.MemoryNamespace = stringArg(args, "memory_namespace") + } + if _, ok := args["status"]; ok { + next.Status = stringArg(args, "status") + } + if _, ok := args["tool_allowlist"]; ok { + next.ToolAllowlist = parseStringList(args["tool_allowlist"]) + } + saved, err := t.store.Upsert(next) + if err != nil { + return "", err + } + return fmt.Sprintf("Updated subagent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil + case "enable", "disable": + if agentID == "" { + return "agent_id is required", nil + } + existing, ok, err := t.store.Get(agentID) + if err != nil { + return "", err + } + if !ok { + return "subagent profile not found", nil + } + if action == "enable" { + existing.Status = "active" + } else { + existing.Status = "disabled" + } + saved, err := t.store.Upsert(*existing) + if err != nil { + return "", err + } + return fmt.Sprintf("Subagent profile %s set to %s", saved.AgentID, saved.Status), nil + case "delete": + if agentID == "" { + return "agent_id is required", nil + } + if err := t.store.Delete(agentID); err != nil { + return "", err + } + return fmt.Sprintf("Deleted subagent profile: %s", agentID), nil + default: + return "unsupported action", nil + } +} + +func stringArg(args map[string]interface{}, key string) string { + v, _ := args[key].(string) + return strings.TrimSpace(v) +} diff --git a/pkg/tools/subagent_profile_test.go b/pkg/tools/subagent_profile_test.go new file mode 100644 index 0000000..ddb3e96 --- /dev/null +++ b/pkg/tools/subagent_profile_test.go @@ -0,0 +1,116 @@ +package tools + +import ( + "context" + "strings" + "testing" +) + +func TestSubagentProfileStoreNormalization(t *testing.T) { + t.Parallel() + + store := NewSubagentProfileStore(t.TempDir()) + saved, err := store.Upsert(SubagentProfile{ + AgentID: "Coder Agent", + Name: " ", + Role: "coding", + MemoryNamespace: "My Namespace", + ToolAllowlist: []string{" Read_File ", "memory_search", "READ_FILE"}, + Status: "ACTIVE", + }) + if err != nil { + t.Fatalf("upsert failed: %v", err) + } + + if saved.AgentID != "coder-agent" { + t.Fatalf("unexpected agent_id: %s", saved.AgentID) + } + if saved.Name != "coder-agent" { + t.Fatalf("unexpected default name: %s", saved.Name) + } + if saved.MemoryNamespace != "my-namespace" { + t.Fatalf("unexpected memory namespace: %s", saved.MemoryNamespace) + } + if len(saved.ToolAllowlist) != 2 { + t.Fatalf("unexpected allowlist size: %d (%v)", len(saved.ToolAllowlist), saved.ToolAllowlist) + } + for _, tool := range saved.ToolAllowlist { + if tool != strings.ToLower(tool) { + t.Fatalf("tool allowlist should be lowercase, got: %s", tool) + } + } +} + +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) { + return "ok", nil + }) + store := manager.ProfileStore() + if store == nil { + t.Fatalf("expected profile store to be available") + } + if _, err := store.Upsert(SubagentProfile{ + AgentID: "writer", + Status: "disabled", + }); err != nil { + t.Fatalf("failed to seed profile: %v", err) + } + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "Write docs", + AgentID: "writer", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err == nil { + t.Fatalf("expected disabled profile to block spawn") + } +} + +func TestSubagentManagerSpawnResolvesProfileByRole(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + manager := NewSubagentManager(nil, workspace, nil, nil) + store := manager.ProfileStore() + if store == nil { + t.Fatalf("expected profile store to be available") + } + if _, err := store.Upsert(SubagentProfile{ + AgentID: "coder", + Role: "coding", + Status: "active", + ToolAllowlist: []string{"read_file"}, + }); err != nil { + t.Fatalf("failed to seed profile: %v", err) + } + + _, err := manager.Spawn(context.Background(), SubagentSpawnOptions{ + Task: "Implement feature", + Role: "coding", + OriginChannel: "cli", + OriginChatID: "direct", + }) + if err != nil { + t.Fatalf("spawn failed: %v", err) + } + + tasks := manager.ListTasks() + if len(tasks) != 1 { + t.Fatalf("expected one task, got %d", len(tasks)) + } + task := tasks[0] + if task.AgentID != "coder" { + t.Fatalf("expected agent_id to resolve to profile agent_id 'coder', got: %s", task.AgentID) + } + if task.Role != "coding" { + t.Fatalf("expected task role to remain 'coding', got: %s", task.Role) + } + if len(task.ToolAllowlist) != 1 || task.ToolAllowlist[0] != "read_file" { + t.Fatalf("expected allowlist from profile, got: %v", task.ToolAllowlist) + } +} diff --git a/pkg/tools/subagents_tool.go b/pkg/tools/subagents_tool.go index 8d5fb2c..e5d1018 100644 --- a/pkg/tools/subagents_tool.go +++ b/pkg/tools/subagents_tool.go @@ -62,7 +62,8 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} sb.WriteString("Subagents:\n") sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created }) for i, task := range tasks { - sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s\n", i+1, task.ID, task.Status, task.Label)) + sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s session=%s allowlist=%d\n", + i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, len(task.ToolAllowlist))) } return strings.TrimSpace(sb.String()), nil case "info": @@ -75,7 +76,8 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} var sb strings.Builder sb.WriteString("Subagents Summary:\n") for i, task := range tasks { - sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s steering=%d\n", i+1, task.ID, task.Status, task.Label, len(task.Steering))) + sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s steering=%d allowlist=%d\n", + i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, len(task.Steering), len(task.ToolAllowlist))) } return strings.TrimSpace(sb.String()), nil } @@ -87,7 +89,9 @@ 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\nCreated: %d\nUpdated: %d\nSteering Count: %d\nTask: %s\nResult:\n%s", task.ID, task.Status, task.Label, task.Created, task.Updated, len(task.Steering), task.Task, task.Result), nil + return fmt.Sprintf("ID: %s\nStatus: %s\nLabel: %s\nAgent ID: %s\nRole: %s\nSession Key: %s\nMemory Namespace: %s\nTool Allowlist: %v\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, + task.ToolAllowlist, task.Created, task.Updated, len(task.Steering), task.Task, task.Result), nil case "kill": if strings.EqualFold(strings.TrimSpace(id), "all") { tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes) @@ -134,6 +138,7 @@ 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\n", task.AgentID, task.Role, task.SessionKey, task.ToolAllowlist)) if len(task.Steering) > 0 { sb.WriteString("Steering Messages:\n") for _, m := range task.Steering { diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 495b087..9fe47d2 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -15,6 +15,7 @@ import TaskAudit from './pages/TaskAudit'; import EKG from './pages/EKG'; import Tasks from './pages/Tasks'; import LogCodes from './pages/LogCodes'; +import SubagentProfiles from './pages/SubagentProfiles'; export default function App() { return ( @@ -35,6 +36,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index cc4ec31..b0b4e35 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, ListTodo, BrainCircuit, Hash } from 'lucide-react'; +import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, ListTodo, BrainCircuit, Hash, Bot } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; import NavItem from './NavItem'; @@ -26,6 +26,7 @@ const Sidebar: React.FC = () => { { 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' }, ], }, { diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 1d56bba..a903e2c 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -16,6 +16,12 @@ const resources = { memory: 'Memory', taskAudit: 'Task Audit', tasks: 'Tasks', + subagentProfiles: 'Subagent Profiles', + newProfile: 'New Profile', + toolAllowlist: 'Tool Allowlist', + memoryNamespace: 'Memory Namespace', + subagentDeleteConfirmTitle: 'Delete Subagent Profile', + subagentDeleteConfirmMessage: 'Delete subagent profile "{{id}}" permanently?', sidebarCore: 'Core', sidebarSystem: 'System', sidebarOps: 'Operations', @@ -440,6 +446,12 @@ const resources = { memory: '记忆文件', taskAudit: '任务审计', tasks: '任务管理', + subagentProfiles: '子代理档案', + newProfile: '新建档案', + toolAllowlist: '工具白名单', + memoryNamespace: '记忆命名空间', + subagentDeleteConfirmTitle: '删除子代理档案', + subagentDeleteConfirmMessage: '确认永久删除子代理档案 "{{id}}"?', sidebarCore: '核心', sidebarSystem: '系统', sidebarOps: '运维', diff --git a/webui/src/pages/SubagentProfiles.tsx b/webui/src/pages/SubagentProfiles.tsx new file mode 100644 index 0000000..bf494ea --- /dev/null +++ b/webui/src/pages/SubagentProfiles.tsx @@ -0,0 +1,314 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAppContext } from '../context/AppContext'; +import { useUI } from '../context/UIContext'; + +type SubagentProfile = { + agent_id: string; + name?: string; + role?: string; + system_prompt?: string; + tool_allowlist?: string[]; + memory_namespace?: string; + status?: 'active' | 'disabled' | string; + created_at?: number; + updated_at?: number; +}; + +const emptyDraft: SubagentProfile = { + agent_id: '', + name: '', + role: '', + system_prompt: '', + memory_namespace: '', + status: 'active', + tool_allowlist: [], +}; + +const SubagentProfiles: React.FC = () => { + const { t } = useTranslation(); + const { q } = useAppContext(); + const ui = useUI(); + + const [items, setItems] = useState([]); + const [selectedId, setSelectedId] = useState(''); + const [draft, setDraft] = useState(emptyDraft); + const [saving, setSaving] = useState(false); + + const selected = useMemo( + () => items.find((p) => p.agent_id === selectedId) || null, + [items, selectedId], + ); + + const load = async () => { + const r = await fetch(`/webui/api/subagent_profiles${q}`); + if (!r.ok) throw new Error(await r.text()); + const j = await r.json(); + const profiles = Array.isArray(j.profiles) ? j.profiles : []; + setItems(profiles); + if (profiles.length === 0) { + setSelectedId(''); + setDraft(emptyDraft); + return; + } + const keep = profiles.find((p: SubagentProfile) => p.agent_id === selectedId); + const next = keep || profiles[0]; + setSelectedId(next.agent_id || ''); + setDraft({ + agent_id: next.agent_id || '', + name: next.name || '', + role: next.role || '', + system_prompt: next.system_prompt || '', + memory_namespace: next.memory_namespace || '', + status: (next.status as string) || 'active', + tool_allowlist: Array.isArray(next.tool_allowlist) ? next.tool_allowlist : [], + }); + }; + + useEffect(() => { + load().catch(() => {}); + }, [q]); + + const onSelect = (p: SubagentProfile) => { + setSelectedId(p.agent_id || ''); + setDraft({ + agent_id: p.agent_id || '', + name: p.name || '', + role: p.role || '', + system_prompt: p.system_prompt || '', + memory_namespace: p.memory_namespace || '', + status: (p.status as string) || 'active', + tool_allowlist: Array.isArray(p.tool_allowlist) ? p.tool_allowlist : [], + }); + }; + + const onNew = () => { + setSelectedId(''); + setDraft(emptyDraft); + }; + + const parseAllowlist = (text: string): string[] => { + return text + .split(',') + .map((x) => x.trim()) + .filter((x) => x.length > 0); + }; + + const allowlistText = (draft.tool_allowlist || []).join(', '); + + const save = async () => { + const agentId = String(draft.agent_id || '').trim(); + if (!agentId) { + await ui.notify({ title: t('requestFailed'), message: 'agent_id is required' }); + return; + } + + setSaving(true); + try { + const action = selected ? 'update' : 'create'; + const r = await fetch(`/webui/api/subagent_profiles${q}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action, + agent_id: agentId, + name: draft.name || '', + role: draft.role || '', + system_prompt: draft.system_prompt || '', + memory_namespace: draft.memory_namespace || '', + status: draft.status || 'active', + tool_allowlist: draft.tool_allowlist || [], + }), + }); + if (!r.ok) { + await ui.notify({ title: t('requestFailed'), message: await r.text() }); + return; + } + await load(); + setSelectedId(agentId); + } finally { + setSaving(false); + } + }; + + const setStatus = async (status: 'active' | 'disabled') => { + const agentId = String(draft.agent_id || '').trim(); + if (!agentId) return; + const action = status === 'active' ? 'enable' : 'disable'; + const r = await fetch(`/webui/api/subagent_profiles${q}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, agent_id: agentId }), + }); + if (!r.ok) { + await ui.notify({ title: t('requestFailed'), message: await r.text() }); + return; + } + await load(); + }; + + const remove = async () => { + const agentId = String(draft.agent_id || '').trim(); + if (!agentId) return; + const ok = await ui.confirmDialog({ + title: t('subagentDeleteConfirmTitle'), + message: t('subagentDeleteConfirmMessage', { id: agentId }), + danger: true, + confirmText: t('delete'), + }); + if (!ok) return; + const delQ = `${q}${q ? '&' : '?'}agent_id=${encodeURIComponent(agentId)}`; + const r = await fetch(`/webui/api/subagent_profiles${delQ}`, { method: 'DELETE' }); + if (!r.ok) { + await ui.notify({ title: t('requestFailed'), message: await r.text() }); + return; + } + await load(); + }; + + return ( +
+
+

{t('subagentProfiles')}

+
+ + +
+
+ +
+
+
+ {t('subagentProfiles')} +
+
+ {items.map((it) => ( + + ))} + {items.length === 0 && ( +
No subagent profiles.
+ )} +
+
+ +
+
+
+
{t('id')}
+ setDraft({ ...draft, agent_id: e.target.value })} + className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded disabled:opacity-60" + placeholder="coder" + /> +
+
+
{t('name')}
+ setDraft({ ...draft, name: e.target.value })} + className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" + placeholder="Code Agent" + /> +
+
+
Role
+ setDraft({ ...draft, role: e.target.value })} + className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" + placeholder="coding" + /> +
+
+
{t('status')}
+ +
+
+
{t('memoryNamespace')}
+ setDraft({ ...draft, memory_namespace: e.target.value })} + className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" + placeholder="coder" + /> +
+
+
{t('toolAllowlist')}
+ setDraft({ ...draft, tool_allowlist: parseAllowlist(e.target.value) })} + className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" + placeholder="read_file, list_files, memory_search" + /> +
+
+
System Prompt
+