Run subagents through a depth-one tool loop #45

Merged
weichen merged 1 commits from feature/agent-harness-p3 into main 2026-10-01 14:50:49 +00:00
16 changed files with 870 additions and 53 deletions
@@ -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
---
你是只读探索子代理,职责是高效且彻底地定位目标信息。
@@ -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
---
你是被派生的子代理,负责独立完成一个子任务。
@@ -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
---
你是规划子代理:负责分析任务并产出详细、可执行的实现计划。你是规划者,不是实现者——不修改任何对象,只输出计划。
@@ -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
---
你是调研子代理,围绕给定问题收集信息并给出结论。
@@ -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
---
你是验证子代理,对指定交付物做对抗性验证:假设它有问题,努力找出问题。
@@ -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
---
你是撰稿子代理,负责把前序要点与调研结论整理成完整的交付文档。
+48
View File
@@ -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":
+52
View File
@@ -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
}
+45 -3
View File
@@ -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)。
+55 -4
View File
@@ -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
+106
View File
@@ -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))
}
}
+5 -44
View File
@@ -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 {
+31 -2
View File
@@ -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 ""
+241
View File
@@ -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
}
}
+251
View File
@@ -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)
}
})
}
}
+18
View File
@@ -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 {