Run subagents through a depth-one tool loop #45
@@ -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
|
||||
---
|
||||
你是撰稿子代理,负责把前序要点与调研结论整理成完整的交付文档。
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user