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 {