From 5051a069f770aad261b61a2cdf2e88dfdf566324 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 14:12:44 +0000 Subject: [PATCH] Run subagents through a depth-one tool loop Why: Subagents were a single lightweight call that dropped the hint and wrote their messages into the parent history. Profiles now decide tools, model, and turn cap, and the parent only keeps the summary plus confirmed artifacts. Evidence: go test ./... passed. New tests cover the six builtin specs, tool execution and whitelist, explore versus writer models, maxTurns, artifact parts, hint text, SpawnAgent pinning, depth-1 refusal, summary artifact lines, and history isolation. Impact: Squad steps share the general profile (up to 12 lightweight turns, no exec tools). Child runs do not attach the final-turn verifier. Profile fields stay in builtin files, so core.PromptModule is unchanged. The agent-native pin stays a34689c. Refs: feature/agent-harness-p3 Co-authored-by: Cursor --- internal/prompts/library/agent-sub-explore.md | 3 + internal/prompts/library/agent-sub-general.md | 3 + internal/prompts/library/agent-sub-plan.md | 3 + .../prompts/library/agent-sub-research.md | 3 + internal/prompts/library/agent-sub-verify.md | 3 + internal/prompts/library/agent-sub-writer.md | 3 + internal/prompts/parse.go | 48 ++++ internal/prompts/prompts_test.go | 52 ++++ internal/prompts/service.go | 48 +++- internal/runmgr/runmgr.go | 59 +++- internal/runmgr/subagent_p3_test.go | 106 ++++++++ internal/runtime/llm.go | 49 +--- internal/runtime/llm_direct.go | 33 ++- internal/runtime/sub_loop.go | 241 +++++++++++++++++ internal/runtime/sub_loop_test.go | 251 ++++++++++++++++++ internal/store/messages.go | 18 ++ 16 files changed, 870 insertions(+), 53 deletions(-) create mode 100644 internal/runmgr/subagent_p3_test.go create mode 100644 internal/runtime/sub_loop.go create mode 100644 internal/runtime/sub_loop_test.go diff --git a/internal/prompts/library/agent-sub-explore.md b/internal/prompts/library/agent-sub-explore.md index 0d19bfa..fd1ed77 100644 --- a/internal/prompts/library/agent-sub-explore.md +++ b/internal/prompts/library/agent-sub-explore.md @@ -4,6 +4,9 @@ layer: agent version: 1 title: 探索子代理(只读) description: 只读检索专家——定位文件/内容/事实,不做任何修改 +tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir +model: lightweight +maxTurns: 8 --- 你是只读探索子代理,职责是高效且彻底地定位目标信息。 diff --git a/internal/prompts/library/agent-sub-general.md b/internal/prompts/library/agent-sub-general.md index a8fe749..d62a373 100644 --- a/internal/prompts/library/agent-sub-general.md +++ b/internal/prompts/library/agent-sub-general.md @@ -4,6 +4,9 @@ layer: agent version: 1 title: 通用子代理 description: 独立完成子任务并返回结构化结果(默认子代理提示词) +tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review +model: lightweight +maxTurns: 12 --- 你是被派生的子代理,负责独立完成一个子任务。 diff --git a/internal/prompts/library/agent-sub-plan.md b/internal/prompts/library/agent-sub-plan.md index 44bbacb..91fdddb 100644 --- a/internal/prompts/library/agent-sub-plan.md +++ b/internal/prompts/library/agent-sub-plan.md @@ -4,6 +4,9 @@ layer: agent version: 1 title: 规划子代理(只出计划) description: 分析并产出可执行的分步实现计划,不执行实现 +tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir +model: lightweight +maxTurns: 6 --- 你是规划子代理:负责分析任务并产出详细、可执行的实现计划。你是规划者,不是实现者——不修改任何对象,只输出计划。 diff --git a/internal/prompts/library/agent-sub-research.md b/internal/prompts/library/agent-sub-research.md index ade8cda..465332b 100644 --- a/internal/prompts/library/agent-sub-research.md +++ b/internal/prompts/library/agent-sub-research.md @@ -4,6 +4,9 @@ layer: agent version: 1 title: 调研子代理 description: 围绕问题多源调研,输出带依据的结论 +tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir +model: lightweight +maxTurns: 10 --- 你是调研子代理,围绕给定问题收集信息并给出结论。 diff --git a/internal/prompts/library/agent-sub-verify.md b/internal/prompts/library/agent-sub-verify.md index c382769..f64a945 100644 --- a/internal/prompts/library/agent-sub-verify.md +++ b/internal/prompts/library/agent-sub-verify.md @@ -4,6 +4,9 @@ layer: agent version: 2 title: 验证子代理(对抗审查) description: 对交付物做对抗性验证,必须输出明确裁决 +tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, quality_review +model: lightweight +maxTurns: 8 --- 你是验证子代理,对指定交付物做对抗性验证:假设它有问题,努力找出问题。 diff --git a/internal/prompts/library/agent-sub-writer.md b/internal/prompts/library/agent-sub-writer.md index 364ea10..99fb3c1 100644 --- a/internal/prompts/library/agent-sub-writer.md +++ b/internal/prompts/library/agent-sub-writer.md @@ -4,6 +4,9 @@ layer: agent version: 2 title: 撰稿子代理 description: 把要点与调研结论整理为完整成文的交付文档 +tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review, run_python, register_artifact +model: chat +maxTurns: 12 --- 你是撰稿子代理,负责把前序要点与调研结论整理成完整的交付文档。 diff --git a/internal/prompts/parse.go b/internal/prompts/parse.go index 1026942..65815d0 100644 --- a/internal/prompts/parse.go +++ b/internal/prompts/parse.go @@ -107,6 +107,8 @@ func ParseModuleFile(name, content string) (core.PromptModule, error) { m.OutputContract = v case "triggers": // 值留空;列表项由后续 "- kind:" 行提供。 + case "tools", "model", "maxTurns": + // 子代理 profile 只从内置库文件读取,不写入 PromptModule。 default: return m, fmt.Errorf("%s: unknown field %q", name, k) } @@ -120,6 +122,52 @@ func ParseModuleFile(name, content string) (core.PromptModule, error) { return m, nil } +// SubagentSpec 是内置子代理 profile。用户覆盖正文时这三项保持内置值。 +type SubagentSpec struct { + Tools []string + Model string + MaxTurns int +} + +// ParseSubagentSpec 从库文件 frontmatter 取出 tools、model、maxTurns。 +// 标准模块字段可以同时出现。其他未知键报错。 +func ParseSubagentSpec(content string) (SubagentSpec, error) { + spec := SubagentSpec{Model: "lightweight", MaxTurns: 8} + if !strings.HasPrefix(content, "---\n") { + return spec, fmt.Errorf("subagent spec: missing frontmatter") + } + end := strings.Index(content[4:], "\n---\n") + if end < 0 { + return spec, fmt.Errorf("subagent spec: unterminated frontmatter") + } + for _, line := range strings.Split(content[4:4+end], "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "- ") { + continue + } + if line[0] == ' ' || line[0] == '\t' { + continue + } + k, v := splitKV(trimmed) + switch k { + case "id", "layer", "version", "title", "description", "priority", "requiresAttachments", "outputContract", "triggers": + case "tools": + spec.Tools = splitList(v) + case "model": + if v != "" { + spec.Model = v + } + case "maxTurns": + if n := atoi(v); n > 0 { + spec.MaxTurns = n + } + default: + return spec, fmt.Errorf("subagent spec: unknown field %q", k) + } + } + return spec, nil +} + func appendBlock(m *core.PromptModule, key, line string) { switch key { case "outputContract": diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index da8cb09..827258d 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -338,3 +338,55 @@ triggers: t.Error("unknown field should fail") } } + +func TestParseSubagentSpec(t *testing.T) { + got, err := ParseSubagentSpec("---\nid: agent.sub.explore\nlayer: agent\ntools: knowledge_search, time_now\nmodel: lightweight\nmaxTurns: 8\n---\n正文\n") + if err != nil { + t.Fatal(err) + } + if got.Model != "lightweight" || got.MaxTurns != 8 || len(got.Tools) != 2 || got.Tools[0] != "knowledge_search" { + t.Fatalf("spec = %+v", got) + } + if _, err := ParseSubagentSpec("---\ntools: knowledge_search\nbogus: 1\n---\n正文\n"); err == nil { + t.Fatal("unknown field should fail") + } +} + +func TestBuiltinSubagentSpecs(t *testing.T) { + svc := mustService(t) + want := map[string]struct { + model string + turns int + tool string + ban string + }{ + "agent.sub.explore": {"lightweight", 8, "knowledge_search", "run_python"}, + "agent.sub.research": {"lightweight", 10, "knowledge_search", "write_artifact"}, + "agent.sub.plan": {"lightweight", 6, "knowledge_search", "register_artifact"}, + "agent.sub.verify": {"lightweight", 8, "quality_review", "run_python"}, + "agent.sub.general": {"lightweight", 12, "write_artifact", "run_python"}, + "agent.sub.writer": {"chat", 12, "run_python", "delegate_task"}, + } + for id, w := range want { + spec, ok := svc.SubagentSpec(id) + if !ok { + t.Errorf("missing spec %s", id) + continue + } + if spec.Model != w.model || spec.MaxTurns != w.turns || !containsStr(spec.Tools, w.tool) || containsStr(spec.Tools, w.ban) { + t.Errorf("%s = %+v", id, spec) + } + } + if _, ok := svc.SubagentSpec("agent.sub.missing"); ok { + t.Error("unknown spec should be absent") + } +} + +func containsStr(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} diff --git a/internal/prompts/service.go b/internal/prompts/service.go index 36f1e92..ca68e52 100644 --- a/internal/prompts/service.go +++ b/internal/prompts/service.go @@ -57,8 +57,9 @@ type EnvData struct { // Service 提示词库服务:内置(embed)→ 用户(DB)覆盖链;触发匹配与 system 组装。 type Service struct { - st *store.Store - builtin []core.PromptModule + st *store.Store + builtin []core.PromptModule + subagents map[string]SubagentSpec } func NewService(st *store.Store, lib []core.PromptModule) (*Service, error) { @@ -69,7 +70,48 @@ func NewService(st *store.Store, lib []core.PromptModule) (*Service, error) { } seen[m.ID] = true } - return &Service{st: st, builtin: lib}, nil + specs, err := builtinSubagentSpecs() + if err != nil { + return nil, err + } + return &Service{st: st, builtin: lib, subagents: specs}, nil +} + +// SubagentSpec 返回内置子代理 profile。用户覆盖不能改 tools、model、maxTurns。 +func (s *Service) SubagentSpec(id string) (SubagentSpec, bool) { + if s == nil { + return SubagentSpec{}, false + } + spec, ok := s.subagents[id] + return spec, ok +} + +func builtinSubagentSpecs() (map[string]SubagentSpec, error) { + names, err := fs.Glob(libraryFS, "library/*.md") + if err != nil { + return nil, err + } + out := map[string]SubagentSpec{} + for _, name := range names { + raw, err := libraryFS.ReadFile(name) + if err != nil { + return nil, err + } + text := string(raw) + mod, err := ParseModuleFile(name, text) + if err != nil { + return nil, err + } + if mod.Layer != "agent" || !strings.HasPrefix(mod.ID, "agent.sub.") { + continue + } + spec, err := ParseSubagentSpec(text) + if err != nil { + return nil, err + } + out[mod.ID] = spec + } + return out, nil } // Effective 返回覆盖链合并后的模块全集(内置 + 用户行;用户覆盖内置同 ID)。 diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index d224211..7a00b58 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -130,6 +130,7 @@ type runCtx struct { inputPaths []string subTask string subHint string + subAgent string } func (c *runCtx) Context() context.Context { return c.ctx } @@ -137,7 +138,23 @@ func (c *runCtx) Run() core.Run { return c.run } func (c *runCtx) Profile() core.AgentProfile { return c.prof } func (c *runCtx) Settings() core.EffectiveSettings { return c.eff } func (c *runCtx) History() []core.Message { - hist, _ := c.mgr.st.LastMessages(c.run.ConversationID, 200) + hist, err := c.mgr.st.ListMessages(c.run.ConversationID) + if err != nil { + return nil + } + if sub, qerr := c.mgr.st.SubagentRunIDs(c.run.ConversationID); qerr == nil && len(sub) > 0 { + kept := hist[:0] + for _, m := range hist { + if m.RunID != "" && sub[m.RunID] { + continue + } + kept = append(kept, m) + } + hist = kept + } + if len(hist) > 200 { + hist = hist[len(hist)-200:] + } return hist } func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments } @@ -275,9 +292,17 @@ func waitHITL(ctx context.Context, ch <-chan hitlAnswer, timeout time.Duration) } func (c *runCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) { - return c.mgr.spawnSubagent(c.ctx, c.run, name, task, hint) + return c.SpawnAgent("agent.sub.general", name, task, hint) } +func (c *runCtx) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) { + return c.mgr.spawnSubagent(c.ctx, c.run, agentID, name, task, hint) +} + +func (c *runCtx) SubagentID() string { return c.subAgent } + +func (c *runCtx) Subhint() string { return c.subHint } + func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error { p.PlanID = c.run.ConversationID if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil { @@ -942,7 +967,13 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv // ---- 子代理(任务树)---- -func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, name, task, hint string) (core.AgentResultPayload, error) { +func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agentID, name, task, hint string) (core.AgentResultPayload, error) { + if parent.Kind == core.RunSubagent { + return core.AgentResultPayload{}, fmt.Errorf("子代理不能再派发子代理") + } + if strings.TrimSpace(agentID) == "" { + agentID = "agent.sub.general" + } child := core.Run{ ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, ProfileID: parent.ProfileID, @@ -961,7 +992,7 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, name prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID) eff := m.settings.Effective(parent.Owner, parent.ProjectID) - rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint} + rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID} m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing) runner := m.subRunner @@ -1014,6 +1045,9 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, name payload.Summary = truncate(fullText, 160) } } + if line := artifactSummary(subParts); line != "" && status == core.RunCompleted { + payload.Summary = strings.TrimSpace(payload.Summary + "\n" + line) + } m.mu.Lock() delete(m.active, child.ID) @@ -1062,6 +1096,23 @@ func partsText(parts []core.Part) string { return strings.TrimSpace(sb.String()) } +func artifactSummary(parts []core.Part) string { + var bits []string + seen := map[string]bool{} + for _, p := range parts { + if p.Type != core.PartArtifactRef || p.Artifact == nil || p.Artifact.ArtifactID == "" || seen[p.Artifact.ArtifactID] { + continue + } + seen[p.Artifact.ArtifactID] = true + name := p.Artifact.Name + if name == "" { + name = p.Artifact.ArtifactID + } + bits = append(bits, "工件:"+p.Artifact.ArtifactID+" "+name) + } + return strings.Join(bits, ";") +} + func truncate(s string, n int) string { if len(s) <= n { return s diff --git a/internal/runmgr/subagent_p3_test.go b/internal/runmgr/subagent_p3_test.go new file mode 100644 index 0000000..ce5c46a --- /dev/null +++ b/internal/runmgr/subagent_p3_test.go @@ -0,0 +1,106 @@ +package runmgr + +import ( + "strings" + "testing" + + "agent-system-backend/internal/bus" + "agent-system-backend/internal/profiles" + "agent-system-backend/internal/runtime" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + core "agent-system-core" +) + +type captureRunner struct { + id string + parts []core.Part +} + +func (c *captureRunner) Name() string { return "capture" } + +func (c *captureRunner) Execute(rc runtime.RunCtx) error { + if src, ok := rc.(interface{ SubagentID() string }); ok { + c.id = src.SubagentID() + } + return rc.SetAssistantParts(c.parts) +} + +func newSpawnEnv(t *testing.T, runner runtime.Runner) (*runCtx, *store.Store) { + t.Helper() + c, st := newExecEnv(t) + c.mgr.profiles = profiles.New(st) + c.mgr.settings = settings.NewService(st) + c.mgr.subRunner = runner + c.mgr.active = map[string]*runHandle{} + if c.mgr.events == nil { + c.mgr.events = newEventBuffer(st, bus.New()) + } + c.run.Kind = core.RunConversation + c.run.ID = "run_parent" + c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)} + return c, st +} + +func TestSubagentCannotSpawnAgain(t *testing.T) { + c, st := newSpawnEnv(t, &captureRunner{}) + c.run.Kind = core.RunSubagent + _, err := c.SpawnAgent("agent.sub.explore", "再派一个", "继续", "") + if err == nil || !strings.Contains(err.Error(), "子代理不能再派发子代理") { + t.Fatalf("err = %v", err) + } + ids, qerr := st.SubagentRunIDs(c.run.ConversationID) + if qerr != nil { + t.Fatal(qerr) + } + if len(ids) != 0 { + t.Fatalf("created runs = %v", ids) + } +} + +func TestSubagentSummaryAndHistory(t *testing.T) { + runner := &captureRunner{parts: []core.Part{ + {Type: core.PartText, Text: "正文很长,超过摘要窗口也要保留工件编号。"}, + {Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "art_0123456789ab", Name: "说明.md"}}, + }} + c, st := newSpawnEnv(t, runner) + if err := st.AppendMessage(&core.Message{ + ConversationID: c.run.ConversationID, + Role: core.RoleUser, + Parts: []core.Part{{Type: core.PartText, Text: "请写说明"}}, + }); err != nil { + t.Fatal(err) + } + res, err := c.SpawnSubagent("通用", "写说明", "简短") + if err != nil { + t.Fatal(err) + } + if runner.id != "agent.sub.general" { + t.Fatalf("agent = %q", runner.id) + } + if !strings.Contains(res.Summary, "工件:art_0123456789ab 说明.md") { + t.Fatalf("summary = %q", res.Summary) + } + if !strings.Contains(res.Text, "正文很长") { + t.Fatalf("text = %q", res.Text) + } + hist := c.History() + for _, m := range hist { + if m.RunID != "" { + t.Fatalf("history kept sub-run message %+v", m) + } + if m.Role != core.RoleUser { + t.Fatalf("history = %+v", hist) + } + } + if len(hist) != 1 { + t.Fatalf("history len = %d", len(hist)) + } + all, err := st.ListMessages(c.run.ConversationID) + if err != nil { + t.Fatal(err) + } + if len(all) != 2 { + t.Fatalf("list len = %d", len(all)) + } +} diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index dde4753..a532a1e 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -240,7 +240,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { decls = append(decls, agentllm.ToolDecl{Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)}) } decls = append(decls, - agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要"), Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string","description":"子代理角色名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"}},"required":["name","task"]}`)}, + agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"}},"required":["name","task"]}`)}, agentllm.ToolDecl{Name: toolPlan, Description: l.toolDesc(prof.Owner, toolPlan, "创建或更新当前会话的计划(步骤列表)"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)}, agentllm.ToolDecl{Name: toolTodos, Description: l.toolDesc(prof.Owner, toolTodos, "更新当前会话的待办清单"), Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)}, ) @@ -291,51 +291,12 @@ func (l *LLMRunner) Execute(rc RunCtx) error { return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools) } -// SubLLMRunner 子代理执行器:轻量模型单轮完成任务并返回结果。 +// SubLLMRunner 子代理执行器:按内置 profile 跑一轮带工具的内核循环。 type SubLLMRunner struct { - // Prompts 提示词库(nil = 使用内联回退文案,兼容测试与骨架)。 + // Prompts 提示词库(nil = 使用内联回退文案与只读回退 spec)。 Prompts *prompts.Service -} - -func (l *SubLLMRunner) Name() string { return "llm-subagent" } - -// subagentPrompt 子代理 system:库 agent.sub.general 优先;派生方 hint(如小队角色) -// 已含角色要求时(hint 以库角色 ID 标记)换用对应角色模块。 -const subagentFallback = "你是被派生的子代理。独立完成给定子任务,输出完整、结构化的结果;需要分点时分条列出,不要为了简短而省略关键内容。" - -func (l *SubLLMRunner) subagentPrompt(rc RunCtx) string { - if l.Prompts == nil { - return subagentFallback - } - if body, err := l.Prompts.AgentPrompt(rc.Profile().Owner, "agent.sub.general"); err == nil && strings.TrimSpace(body) != "" { - return body - } - return subagentFallback -} - -func (l *SubLLMRunner) Execute(rc RunCtx) error { - route := ResolveLightweightModel(rc.Profile(), rc.Settings()) - if route.Model == "" || route.Base == "" { - return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base) - } - rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing}) - client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} - msgs := []agentllm.Message{ - {Role: agentllm.RoleSystem, Content: l.subagentPrompt(rc)}, - {Role: agentllm.RoleUser, Content: rc.Subtask()}, - } - var sb strings.Builder - for e := range client.Stream(rc.Context(), route.Model, msgs, nil, agentllm.StreamOptions{}) { - if e.Kind == agentllm.KindTextDelta { - sb.WriteString(e.Text) - rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: truncateRun(sb.String(), 80)}) - } - if e.Kind == agentllm.KindError { - return fmt.Errorf("subagent stream: %s", e.ErrorMessage) - } - } - rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: sb.String()}}) - return nil + // Stream 提供方边界注入点(nil = 按 profile 的模型档构造客户端)。 + Stream agentllm.StreamFunc } func currentUserText(rc RunCtx) string { diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 03d8b4d..9cc9ca4 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -343,13 +343,22 @@ func (st *directRun) runTool(name, args string) agenttools.Result { out = summary } case toolDelegate: - var a struct{ Name, Task, Hint string } + var a struct{ Agent, Name, Task, Hint string } _ = json.Unmarshal([]byte(call.Arguments), &a) - res, serr := st.rc.SpawnSubagent(orDefault(a.Name, "子代理"), a.Task, a.Hint) + var res core.AgentResultPayload + var serr error + if sp, ok := st.rc.(interface { + SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) + }); ok { + res, serr = sp.SpawnAgent(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), a.Task, a.Hint) + } else { + res, serr = st.rc.SpawnSubagent(orDefault(a.Name, "子代理"), a.Task, a.Hint) + } if serr != nil { out = "子代理失败:" + serr.Error() } else { out = orDefault(res.Summary, "子代理完成") + st.pinSubagentArtifacts(res.Summary + "\n" + res.Text) } case toolPlan: var p core.PlanSnapshot @@ -495,6 +504,26 @@ func (st *directRun) commitCard() string { return fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID) } +func (st *directRun) pinSubagentArtifacts(text string) { + seen := map[string]bool{} + for _, ref := range st.deliverables { + if ref != nil { + seen[ref.ArtifactID] = true + } + } + for _, id := range artIDRe.FindAllString(text, -1) { + if seen[id] { + continue + } + art, _, ok := st.rc.Artifact(id) + if !ok { + continue + } + seen[id] = true + st.deliverables = append(st.deliverables, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}) + } +} + func errString(err error) string { if err == nil { return "" diff --git a/internal/runtime/sub_loop.go b/internal/runtime/sub_loop.go new file mode 100644 index 0000000..103abe8 --- /dev/null +++ b/internal/runtime/sub_loop.go @@ -0,0 +1,241 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + nruntime "agent-native/runtime" + agentllm "agent-native/runtime/llm" + agenttools "agent-native/runtime/tools" + "agent-system-backend/internal/prompts" + core "agent-system-core" +) + +const subagentFallback = "你是被派生的子代理。独立完成给定子任务,输出完整、结构化的结果;需要分点时分条列出,不要为了简短而省略关键内容。" + +const defaultSubagentID = "agent.sub.general" + +const subagentToolCap = 20 + +func fallbackSubagentSpec() prompts.SubagentSpec { + return prompts.SubagentSpec{ + Tools: []string{"knowledge_search", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"}, + Model: "lightweight", + MaxTurns: 8, + } +} + +func (l *SubLLMRunner) Name() string { return "llm-subagent" } + +func (l *SubLLMRunner) Execute(rc RunCtx) error { + id := defaultSubagentID + if src, ok := rc.(interface{ SubagentID() string }); ok { + if v := strings.TrimSpace(src.SubagentID()); v != "" { + id = v + } + } + spec := fallbackSubagentSpec() + if l.Prompts != nil { + if got, ok := l.Prompts.SubagentSpec(id); ok { + spec = got + } + } + if spec.MaxTurns <= 0 { + spec.MaxTurns = 8 + } + route := ResolveLightweightModel(rc.Profile(), rc.Settings()) + if spec.Model == "chat" { + route = ResolveChatModel(rc.Profile(), rc.Settings()) + } + stream := l.Stream + if stream == nil { + if route.Model == "" || route.Base == "" { + return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base) + } + client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} + stream = client.Stream + } + decls := subagentDecls(rc, spec) + st := &subRun{rc: rc} + reg := agenttools.NewRegistry("") + for _, d := range decls { + name := d.Name + reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result { + return st.runTool(name, string(args)) + }}) + } + wrapped := func(ctx context.Context, model string, messages []agentllm.Message, _ []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + in := stream(ctx, model, messages, decls, opts) + out := make(chan agentllm.Event) + go func() { + defer close(out) + for ev := range in { + if ev.Kind == agentllm.KindMessageEnd { + if ev.Message.Role == "" { + ev.Message.Role = agentllm.RoleAssistant + } + if len(ev.Message.ToolCalls) > 0 && ev.Message.StopReason == "" { + ev.Message.StopReason = agentllm.StopToolCalls + } + } + select { + case out <- ev: + case <-ctx.Done(): + return + } + } + }() + return out + } + _ = rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing}) + user := agentllm.Message{Role: agentllm.RoleUser, Content: subagentUserText(rc)} + res, err := nruntime.Run(rc.Context(), nruntime.Config{ + Model: route.Model, + SystemPrompt: l.subagentPrompt(rc, id), + Tools: reg, + Stream: wrapped, + MaxTurns: spec.MaxTurns, + Budget: &nruntime.Budget{MaxToolCalls: subagentToolCap}, + RepeatGuard: true, + ContinueOnLength: true, + OnEvent: st.onEvent, + }, user) + if len(st.parts) == 0 { + st.parts = append(st.parts, core.Part{Type: core.PartText, Text: "(子代理未返回内容)"}) + } + _ = rc.SetAssistantParts(st.parts) + if err != nil { + return err + } + if res.Err != nil { + return res.Err + } + return nil +} + +func (l *SubLLMRunner) subagentPrompt(rc RunCtx, id string) string { + if l.Prompts == nil { + return subagentFallback + } + if body, err := l.Prompts.AgentPrompt(rc.Profile().Owner, id); err == nil && strings.TrimSpace(body) != "" { + return body + } + return subagentFallback +} + +func subagentUserText(rc RunCtx) string { + text := rc.Subtask() + if src, ok := rc.(interface{ Subhint() string }); ok { + if hint := strings.TrimSpace(src.Subhint()); hint != "" { + text += "\n\n执行提示:" + hint + } + } + return text +} + +func subagentDecls(rc RunCtx, spec prompts.SubagentSpec) []agentllm.ToolDecl { + allow := map[string]bool{} + for _, name := range spec.Tools { + allow[name] = true + } + var decls []agentllm.ToolDecl + for _, tool := range rc.Tools() { + if !allow[tool.Name] || !rc.Profile().Tools.ToolAllowed(tool.Name) { + continue + } + decls = append(decls, agentllm.ToolDecl{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters}) + } + return decls +} + +type subSlot struct { + call agentllm.ToolCall + start time.Time + summary string + status string + artifact *core.ArtifactRef +} + +type subRun struct { + rc RunCtx + parts []core.Part + queued []agentllm.ToolCall + inflight *subSlot + turnText strings.Builder +} + +func (st *subRun) onEvent(ev nruntime.Event) { + switch ev.Type { + case "turn_start": + st.turnText.Reset() + case "text_delta": + st.turnText.WriteString(ev.Text) + _ = st.rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: st.rc.Run().ID, Note: truncateRun(st.turnText.String(), 80)}) + case "message_end": + st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...) + hasCalls := len(ev.Message.ToolCalls) > 0 + if body := strings.TrimSpace(st.turnText.String()); body != "" { + st.parts = append(st.parts, core.Part{Type: core.PartText, Text: body, Process: hasCalls}) + } + case "tool_execution_start": + call := agentllm.ToolCall{Name: ev.Tool, Arguments: ev.Detail} + if len(st.queued) > 0 { + call = st.queued[0] + st.queued = st.queued[1:] + } + st.inflight = &subSlot{call: call, start: time.Now()} + _ = st.rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: st.rc.Run().ID, Note: "调用 " + call.Name}) + case "tool_execution_end": + slot := st.inflight + if slot == nil { + return + } + end := time.Now() + st.parts = append(st.parts, + core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: slot.call.ID, Name: slot.call.Name, Arguments: slot.call.Arguments, Duration: durationMs(slot.start, end)}}, + core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary}}, + ) + if slot.artifact != nil { + st.parts = append(st.parts, core.Part{Type: core.PartArtifactRef, Artifact: slot.artifact}) + } + st.inflight = nil + } +} + +func (st *subRun) runTool(name, args string) agenttools.Result { + call := agentllm.ToolCall{Name: name, Arguments: args} + if st.inflight != nil && st.inflight.call.Name != "" { + call = st.inflight.call + } + res := st.rc.ExecuteTool(core.ToolCallRequest{CallID: call.ID, Name: call.Name, Arguments: json.RawMessage(call.Arguments)}) + summary := res.Output + status := res.Status + if status == "" { + status = "ok" + } + var ref *core.ArtifactRef + if res.Artifact != nil { + ref = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind} + } + if st.inflight != nil { + st.inflight.summary = summary + st.inflight.status = status + st.inflight.artifact = ref + } + if status != "ok" { + return agenttools.Result{Content: summary, IsError: true} + } + return agenttools.Result{Content: summary} +} + +func subagentProfileID(agent string) string { + switch agent { + case "explore", "research", "plan", "verify", "general", "writer": + return "agent.sub." + agent + default: + return defaultSubagentID + } +} diff --git a/internal/runtime/sub_loop_test.go b/internal/runtime/sub_loop_test.go new file mode 100644 index 0000000..d7d2a34 --- /dev/null +++ b/internal/runtime/sub_loop_test.go @@ -0,0 +1,251 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + agentllm "agent-native/runtime/llm" + "agent-system-backend/internal/prompts" + core "agent-system-core" +) + +type subRC struct { + fakeRunCtx + id string + hint string + task string + tools []core.ToolDesc +} + +func (s *subRC) SubagentID() string { return s.id } +func (s *subRC) Subhint() string { return s.hint } +func (s *subRC) Subtask() string { return s.task } +func (s *subRC) Tools() []core.ToolDesc { return s.tools } + +func subProfile() core.AgentProfile { + return core.AgentProfile{Model: core.ModelRoute{Chat: "test-model", Lightweight: "lite-model"}} +} + +func subTools() []core.ToolDesc { + names := []string{ + "knowledge_search", "web_search", "delegate_task", "squad_orchestrate", + "run_python", "register_artifact", "write_artifact", "quality_review", + "fs_read_file", "fs_list_dir", + } + out := make([]core.ToolDesc, 0, len(names)) + for _, name := range names { + out = append(out, core.ToolDesc{Name: name, Description: name, Parameters: []byte(`{"type":"object"}`)}) + } + return out +} + +func mustPrompts(t *testing.T) *prompts.Service { + t.Helper() + lib, err := prompts.DefaultLibrary() + if err != nil { + t.Fatal(err) + } + svc, err := prompts.NewService(nil, lib) + if err != nil { + t.Fatal(err) + } + return svc +} + +func TestSubagentLoopRunsToolThenFinal(t *testing.T) { + turns := [][]agentllm.Event{ + textDeltaTurn("先查一下。", toolCall("knowledge_search", `{"q":"x"}`)), + textDeltaTurn("查完了。"), + } + var decls []agentllm.ToolDecl + var user string + base := scriptStream(turns, nil) + rc := &subRC{ + fakeRunCtx: fakeRunCtx{ctx: context.Background(), prof: subProfile()}, + id: "agent.sub.explore", + hint: "只要路径", + task: "找出配置文件", + tools: subTools(), + } + r := &SubLLMRunner{ + Prompts: mustPrompts(t), + Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + if model != "lite-model" { + t.Errorf("explore model = %q", model) + } + if decls == nil { + decls = tools + } + for _, m := range msgs { + if m.Role == agentllm.RoleUser { + user = m.Content + } + } + return base(ctx, model, msgs, tools, opts) + }, + } + if err := r.Execute(rc); err != nil { + t.Fatal(err) + } + if rc.toolRuns != 1 { + t.Fatalf("tool runs = %d", rc.toolRuns) + } + if !strings.Contains(user, "找出配置文件") || !strings.Contains(user, "执行提示:只要路径") { + t.Fatalf("user message = %q", user) + } + names := map[string]bool{} + for _, d := range decls { + names[d.Name] = true + } + if !names["knowledge_search"] || names["delegate_task"] || names["squad_orchestrate"] || names["run_python"] { + t.Fatalf("decls = %v", names) + } + var sawFinal, sawProcess bool + for _, p := range rc.assistant { + if p.Type == core.PartText && p.Text == "查完了。" && !p.Process { + sawFinal = true + } + if p.Type == core.PartToolCall { + sawProcess = true + } + } + if !sawFinal || !sawProcess { + t.Fatalf("parts = %+v", rc.assistant) + } +} + +func TestSubagentWriterUsesChatModel(t *testing.T) { + var model string + base := scriptStream([][]agentllm.Event{textDeltaTurn("写好了。")}, nil) + rc := &subRC{ + fakeRunCtx: fakeRunCtx{ctx: context.Background(), prof: subProfile()}, + id: "agent.sub.writer", + task: "写一段", + tools: subTools(), + } + r := &SubLLMRunner{ + Prompts: mustPrompts(t), + Stream: func(ctx context.Context, m string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + model = m + return base(ctx, m, msgs, tools, opts) + }, + } + if err := r.Execute(rc); err != nil { + t.Fatal(err) + } + if model != "test-model" { + t.Fatalf("writer model = %q", model) + } +} + +func TestSubagentMaxTurns(t *testing.T) { + turns := make([][]agentllm.Event, 7) + for i := range turns { + turns[i] = textDeltaTurn("", toolCall("knowledge_search", `{}`)) + } + calls := 0 + base := scriptStream(turns, nil) + rc := &subRC{ + fakeRunCtx: fakeRunCtx{ctx: context.Background(), prof: subProfile()}, + id: "agent.sub.plan", + task: "列步骤", + tools: subTools(), + } + r := &SubLLMRunner{ + Prompts: mustPrompts(t), + Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + calls++ + return base(ctx, model, msgs, tools, opts) + }, + } + err := r.Execute(rc) + if err == nil || !strings.Contains(err.Error(), "max turns") { + t.Fatalf("err = %v", err) + } + if calls != 6 { + t.Fatalf("stream calls = %d", calls) + } +} + +func TestSubagentArtifactPart(t *testing.T) { + rc := &subRC{ + fakeRunCtx: fakeRunCtx{ + ctx: context.Background(), + prof: subProfile(), + execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "note.md", Kind: "document"}, + }, + id: "agent.sub.general", + task: "记一笔", + tools: subTools(), + } + r := &SubLLMRunner{ + Prompts: mustPrompts(t), + Stream: scriptStream([][]agentllm.Event{ + textDeltaTurn("", toolCall("write_artifact", `{}`)), + textDeltaTurn("记下了。"), + }, nil), + } + if err := r.Execute(rc); err != nil { + t.Fatal(err) + } + var saw bool + for _, p := range rc.assistant { + if p.Type == core.PartArtifactRef && p.Artifact != nil && p.Artifact.ArtifactID == "art_0123456789ab" && p.Artifact.Name == "note.md" { + saw = true + } + } + if !saw { + t.Fatalf("parts = %+v", rc.assistant) + } +} + +type spawnRC struct { + fakeRunCtx + agent string + called int +} + +func (s *spawnRC) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) { + s.called++ + s.agent = agentID + return core.AgentResultPayload{Summary: "写好了\n工件:art_0123456789ab 说明.md;工件:art_ffffffffffff 没有"}, nil +} + +func TestDelegatePinsExistingArtifact(t *testing.T) { + for _, tc := range []struct { + name string + args string + agent string + }{ + {name: "writer", args: `{"agent":"writer","name":"写手","task":"写说明"}`, agent: "agent.sub.writer"}, + {name: "default", args: `{"name":"助手","task":"看一下"}`, agent: "agent.sub.general"}, + } { + t.Run(tc.name, func(t *testing.T) { + rc := &spawnRC{fakeRunCtx: fakeRunCtx{ + ctx: context.Background(), + prof: subProfile(), + execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "说明.md", Kind: "document"}, + }} + r := &LLMRunner{Stream: scriptStream([][]agentllm.Event{ + textDeltaTurn("", agentllm.ToolCall{ID: "c-del", Name: "delegate_task", Arguments: tc.args}), + textDeltaTurn("完成。"), + }, nil)} + if err := r.Execute(rc); err != nil { + t.Fatal(err) + } + if rc.called != 1 || rc.agent != tc.agent { + t.Fatalf("spawn agent=%q called=%d", rc.agent, rc.called) + } + var pinned []string + for _, p := range rc.assistant { + if p.Type == core.PartArtifactRef && p.Artifact != nil { + pinned = append(pinned, p.Artifact.ArtifactID) + } + } + if len(pinned) != 1 || pinned[0] != "art_0123456789ab" { + t.Fatalf("pinned = %v", pinned) + } + }) + } +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 91614e7..ceb0637 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -81,6 +81,24 @@ func (s *Store) LastMessages(conversationID string, n int) ([]core.Message, erro return all, nil } +// SubagentRunIDs 返回会话里子代理 run 的 id。主对话历史用它排除子 run 消息。 +func (s *Store) SubagentRunIDs(conversationID string) (map[core.ID]bool, error) { + rows, err := s.db.Query(s.q(`SELECT id FROM runs WHERE conversation_id=? AND kind=?`), conversationID, string(core.RunSubagent)) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[core.ID]bool{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[core.ID(id)] = true + } + return out, rows.Err() +} + // ---- Runs(owner 冗余自会话;GetRun 面向 API 带租户过滤,内部路径用 getRunByID)---- func (s *Store) CreateRun(r *core.Run) error { -- 2.54.0