From 268f4a9abf9fdf23de487cb7a2edbfbaa60ff6af Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 09:34:53 +0000 Subject: [PATCH] Route conversations through a squad entry when selected Why: Squad steps previously ran without tools, and the product had no way to make the squad the conversation entry without losing streaming, cards, or the deliverable guard. Evidence: go test ./... -count=1 passed, including streaming, cardflow, false-deliverable rejection, tool allowlist, model routing, usage, cancel, and turn and tool budgets. Impact: runtime.entry defaults to direct. squad uses one kernel step for simple work and the leader for the rest. squad_orchestrate is hidden in that mode. The default is not switched. Refs: deliverable-quality-system B3-B5 Co-authored-by: Cursor --- deps/agent-native | 2 +- deps/agent-squad | 2 +- eval/README.md | 2 + eval/switch.go | 45 ++ eval/switch_test.go | 27 + internal/app/app.go | 1 + .../prompts/library/agent-squad-analyst.md | 12 + .../prompts/library/agent-squad-assistant.md | 12 + internal/prompts/prompts_test.go | 2 +- internal/runmgr/entry_test.go | 28 ++ internal/runmgr/runmgr.go | 60 ++- internal/runtime/hostbridge.go | 185 +++++++ internal/runtime/llm.go | 27 +- internal/runtime/squad_runner.go | 201 ++++++++ internal/runtime/squad_runner_test.go | 463 ++++++++++++++++++ internal/settings/settings.go | 10 +- internal/squadadapter/entry.go | 230 +++++++++ 17 files changed, 1285 insertions(+), 24 deletions(-) create mode 100644 eval/switch.go create mode 100644 eval/switch_test.go create mode 100644 internal/prompts/library/agent-squad-analyst.md create mode 100644 internal/prompts/library/agent-squad-assistant.md create mode 100644 internal/runmgr/entry_test.go create mode 100644 internal/runtime/hostbridge.go create mode 100644 internal/runtime/squad_runner.go create mode 100644 internal/runtime/squad_runner_test.go create mode 100644 internal/squadadapter/entry.go diff --git a/deps/agent-native b/deps/agent-native index f9ab2ef..cfc840c 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit f9ab2ef698ff253b8914a081e8651da65de3b38d +Subproject commit cfc840c11d1e85b8f780dbb4030e9f62f670fa3b diff --git a/deps/agent-squad b/deps/agent-squad index 9a402e4..d38f838 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit 9a402e468ec758b0f7d5af952e57ebde9248925d +Subproject commit d38f838dbca0587470050b42afb91ecc1df49321 diff --git a/eval/README.md b/eval/README.md index 094687b..21a37b9 100644 --- a/eval/README.md +++ b/eval/README.md @@ -7,3 +7,5 @@ ``` cd modules/backend && go test ./eval/ ./internal/quality/ -count=1 ``` + +两种入口的切换由 `DecideSwitch` 判断:squad 分数不低于 direct,并且直通路径多出来的首字延迟 P50 不超过 3 秒。没有同一评测集上的实测分数时,结果是不切换,`runtime.entry` 默认保持 `direct`。 diff --git a/eval/switch.go b/eval/switch.go new file mode 100644 index 0000000..47e6cbf --- /dev/null +++ b/eval/switch.go @@ -0,0 +1,45 @@ +package eval + +import ( + "fmt" + "time" +) + +// EntryScore 是一种入口在同一评测集上的结果。 +type EntryScore struct { + Name string + Score int + FirstTokenP50 time.Duration +} + +// SwitchDecision 判断能不能把默认入口改成 squad。 +// 两条都要满足:squad 分数不低于 direct,直通路径多出来的首字延迟 P50 不超过 3 秒。 +type SwitchDecision struct { + Met bool + Reason string +} + +func DecideSwitch(direct, squad *EntryScore) SwitchDecision { + if direct == nil || squad == nil { + return SwitchDecision{Met: false, Reason: "还没有两种入口在同一评测集上的实测分数,默认保持 direct"} + } + extra := squad.FirstTokenP50 - direct.FirstTokenP50 + if extra < 0 { + extra = 0 + } + if squad.Score < direct.Score { + return SwitchDecision{Met: false, Reason: fmt.Sprintf("squad 分数 %d 低于 direct %d", squad.Score, direct.Score)} + } + if extra > 3*time.Second { + return SwitchDecision{Met: false, Reason: fmt.Sprintf("直通路径额外首字延迟 %s,超过 3 秒", extra)} + } + return SwitchDecision{Met: true, Reason: fmt.Sprintf("squad %d 不低于 direct %d,额外首字延迟 %s", squad.Score, direct.Score, extra)} +} + +// Report 写成可以附在切换 PR 上的短报告。未达标时明确保持 direct。 +func Report(dec SwitchDecision) string { + if dec.Met { + return "切换条件已满足:" + dec.Reason + "。可以把 runtime.entry 的默认值改为 squad,并保留 direct 作为回退。合入前需要产品确认。" + } + return "切换条件未满足:" + dec.Reason + "。runtime.entry 默认值保持 direct。" +} diff --git a/eval/switch_test.go b/eval/switch_test.go new file mode 100644 index 0000000..fd6b4ba --- /dev/null +++ b/eval/switch_test.go @@ -0,0 +1,27 @@ +package eval + +import ( + "strings" + "testing" + "time" +) + +func TestSwitchStaysDirectWithoutScores(t *testing.T) { + dec := DecideSwitch(nil, nil) + if dec.Met || !strings.Contains(Report(dec), "保持 direct") { + t.Fatalf("%+v %s", dec, Report(dec)) + } +} + +func TestSwitchRequiresScoreAndLatency(t *testing.T) { + direct := &EntryScore{Name: "direct", Score: 80, FirstTokenP50: time.Second} + slow := &EntryScore{Name: "squad", Score: 90, FirstTokenP50: 5 * time.Second} + if DecideSwitch(direct, slow).Met { + t.Fatal("extra latency above 3s must block the switch") + } + ok := &EntryScore{Name: "squad", Score: 80, FirstTokenP50: 3 * time.Second} + dec := DecideSwitch(direct, ok) + if !dec.Met || !strings.Contains(Report(dec), "改为 squad") { + t.Fatalf("%+v %s", dec, Report(dec)) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 2261cd7..001ae40 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -168,6 +168,7 @@ func Run(opt Options) error { subRunner := &runtime.SubLLMRunner{Prompts: promptSvc} mockRunner := &runtime.MockRunner{} mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{}) + mgr.SetSquadRunner(&runtime.SquadRunner{}) // 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。 gate := intentgate.New() diff --git a/internal/prompts/library/agent-squad-analyst.md b/internal/prompts/library/agent-squad-analyst.md new file mode 100644 index 0000000..1633152 --- /dev/null +++ b/internal/prompts/library/agent-squad-analyst.md @@ -0,0 +1,12 @@ +--- +id: agent.squad.analyst +layer: agent +version: 1 +title: 小队·分析员 +description: 把材料写成洞察报告 +--- +你是小队的分析员。把同一份材料写成洞察,而不是会议纪要。 + +- 开头用一句话写出判断。 +- 正文按现象、影响、隐性顾虑、建议四层展开。每层都要引用材料里的原话作为依据。 +- 结尾写下一步行动:负责人、时间和完成标志。材料没写的写成「未指明」。 diff --git a/internal/prompts/library/agent-squad-assistant.md b/internal/prompts/library/agent-squad-assistant.md new file mode 100644 index 0000000..3e0e096 --- /dev/null +++ b/internal/prompts/library/agent-squad-assistant.md @@ -0,0 +1,12 @@ +--- +id: agent.squad.assistant +layer: agent +version: 1 +title: 小队·助手 +description: 简单任务的单步完成者 +--- +你是小队的助手,负责把一件已经分诊为简单的事一次做完。 + +- 结论放在开头。需要成文时按交付物工作流生成文件,lint 无 error 后再 register_artifact。 +- 不要在回复里声称文件已经生成,除非这一轮真的登记了工件。 +- 问答和改写直接给结果,不要拆成多角色流程。 diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index 9a63ee0..da8cb09 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -47,7 +47,7 @@ func TestDefaultLibraryParses(t *testing.T) { // 关键模块在位:基座五块 + 环境 + 纪要技能 + 通用子代理。 for _, id := range []string{"base.identity", "base.behavior", "base.caution", "base.tools", "base.style", "base.quality", "env.runtime", "task.doc-minutes", "task.insight", "method.distill", "method.insight", "method.timeline", - "agent.sub.general", "aux.session-compact", "tool.tavily_search", "tool.web_search"} { + "agent.sub.general", "agent.squad.assistant", "agent.squad.analyst", "aux.session-compact", "tool.tavily_search", "tool.web_search"} { if !seen[id] { t.Errorf("required module missing: %s", id) } diff --git a/internal/runmgr/entry_test.go b/internal/runmgr/entry_test.go new file mode 100644 index 0000000..2da2857 --- /dev/null +++ b/internal/runmgr/entry_test.go @@ -0,0 +1,28 @@ +package runmgr + +import ( + "testing" + + "agent-system-backend/internal/runtime" + core "agent-system-core" +) + +type nameRunner struct{ name string } + +func (n nameRunner) Name() string { return n.name } +func (n nameRunner) Execute(rc runtime.RunCtx) error { return nil } + +func TestSelectConversationRunner(t *testing.T) { + m := &Manager{runner: nameRunner{name: "direct"}, mock: nameRunner{name: "mock"}, squad: nameRunner{name: "squad"}} + if got := selectConversationRunner(m, core.EffectiveSettings{}); got.Name() != "direct" { + t.Fatalf("default = %s", got.Name()) + } + squadEff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad"}} + if got := selectConversationRunner(m, squadEff); got.Name() != "squad" { + t.Fatalf("squad entry = %s", got.Name()) + } + mockEff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad", "mock": true}} + if got := selectConversationRunner(m, mockEff); got.Name() != "mock" { + t.Fatalf("mock wins = %s", got.Name()) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 6e64ec2..5e8dc9e 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "path/filepath" "strings" "sync" "time" @@ -32,6 +33,7 @@ type Manager struct { subRunner runtime.Runner // subagent runner(LLM 轻任务) mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式) mockSub runtime.Runner // 子代理脚本回放 + squad runtime.Runner // runtime.entry=squad 时的对话入口 gate IntentGate cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退) events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store) @@ -52,13 +54,15 @@ type Manager struct { var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run") type runHandle struct { - cancel context.CancelFunc - hitlCh chan hitlAnswer - assistant []core.Part - ctxObs *core.ContextObservation - submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点) - streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45) - started time.Time + cancel context.CancelFunc + hitlCh chan hitlAnswer + assistant []core.Part + ctxObs *core.ContextObservation + submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点) + streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45) + started time.Time + promptTokens int + completionTokens int } type hitlAnswer struct { @@ -136,7 +140,23 @@ func (c *runCtx) History() []core.Message { } func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments } func (c *runCtx) WorkspaceInputs() []string { return c.inputPaths } -func (c *runCtx) Subtask() string { return c.subTask } + +// ConversationSandbox 是会话工作区根。宿主工具把内核步骤里生成的文件复制到这里再登记。 +func (c *runCtx) ConversationSandbox() string { + if c.mgr.workspaceRoot == "" || c.run.Owner == "" || c.run.ConversationID == "" { + return "" + } + return filepath.Join(c.mgr.workspaceRoot, c.run.Owner, c.run.ConversationID) +} + +// AddUsage 累加本 run 的模型用量,finishRun 写入 run.Usage。 +func (c *runCtx) AddUsage(u core.Usage) { + c.mgr.mu.Lock() + c.h.promptTokens += u.PromptTokens + c.h.completionTokens += u.CompletionTokens + c.mgr.mu.Unlock() +} +func (c *runCtx) Subtask() string { return c.subTask } // Tools 模型可见工具清单:Hidden 工具(fs 预留能力)在 runtime.fsTools 未开时过滤 // (与 /api/tools 列表同门禁;registry.Execute 另有执行侧 fail-closed)。 @@ -566,6 +586,20 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([] // ---- run 生命周期 ---- +// SetSquadRunner 注入 runtime.entry=squad 时使用的对话入口。 +func (m *Manager) SetSquadRunner(r runtime.Runner) { m.squad = r } + +// selectConversationRunner 按设置选择入口。mock 优先;squad 仅在注入了入口时生效。 +func selectConversationRunner(m *Manager, eff core.EffectiveSettings) runtime.Runner { + if eff.GetBool("runtime.mock", false) { + return m.mock + } + if eff.GetString("runtime.entry", "direct") == "squad" && m.squad != nil { + return m.squad + } + return m.runner +} + // StartConversationRun 为一条已落库的用户消息启动 conversation run。 // attachments 为已解析的附件文档(server 层经 ingest 处理)。 // inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。 @@ -595,10 +629,7 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core. m.active[run.ID] = h m.mu.Unlock() - runner := m.runner - if eff.GetBool("runtime.mock", false) { - runner = m.mock - } + runner := selectConversationRunner(m, eff) inputPaths, _ := m.stageInputs(conv, eff, inputs) rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths} go m.execute(rc, runner) @@ -645,6 +676,11 @@ func (m *Manager) execute(rc *runCtx, runner runtime.Runner) { } func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, errPart *core.ErrorPart) { + m.mu.Lock() + usage.PromptTokens = rc.h.promptTokens + usage.CompletionTokens = rc.h.completionTokens + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + m.mu.Unlock() now := time.Now().UTC() rc.run.EndedAt = &now rc.run.Status = status diff --git a/internal/runtime/hostbridge.go b/internal/runtime/hostbridge.go new file mode 100644 index 0000000..2b4e40a --- /dev/null +++ b/internal/runtime/hostbridge.go @@ -0,0 +1,185 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "agent-native/adapters/native" + "agent-native/runtime/tools" + core "agent-system-core" +) + +// kernelToolNames 是内核工作区自带的工具。同名宿主工具不注入,避免步骤因冲突失败。 +var kernelToolNames = map[string]bool{ + "read_file": true, "write_file": true, "edit_file": true, "multi_edit": true, + "list_dir": true, "grep": true, "glob": true, "load_skill": true, + "run_python": true, "run_command": true, "web_search": true, "tavily_search": true, + "fetch_url": true, "delegate_task": true, +} + +type producedSink interface { + add(ref *core.ArtifactRef) +} + +type artifactList struct { + items []*core.ArtifactRef +} + +func (a *artifactList) add(ref *core.ArtifactRef) { + if ref != nil { + a.items = append(a.items, ref) + } +} + +// bridgeHostTools 把画像允许的产品工具包成内核宿主工具。 +// register_artifact 会先把内核步骤目录里的文件复制到会话工作区。 +func bridgeHostTools(rc RunCtx, produced *artifactList) []tools.Tool { + if produced == nil { + produced = &artifactList{} + } + policy := rc.Profile().Tools + var out []tools.Tool + for _, desc := range rc.Tools() { + if desc.Hidden || kernelToolNames[desc.Name] || !policy.ToolAllowed(desc.Name) { + continue + } + out = append(out, &bridgedTool{rc: rc, desc: desc, produced: produced}) + } + if rc.Settings().GetBool("ui.cardflow", false) { + for _, desc := range cardToolDescs() { + if policy.ToolAllowed(desc.Name) { + out = append(out, &bridgedTool{rc: rc, desc: desc, produced: produced, card: true}) + } + } + } + return out +} + +type bridgedTool struct { + rc RunCtx + desc core.ToolDesc + produced *artifactList + card bool +} + +func (b *bridgedTool) Name() string { return b.desc.Name } +func (b *bridgedTool) Description() string { return b.desc.Description } +func (b *bridgedTool) Parameters() json.RawMessage { return b.desc.Parameters } + +func (b *bridgedTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) { + if b.desc.Name == "register_artifact" { + stageStepFile(ctx, conversationSandbox(b.rc), args) + } + if b.card { + out, err := executeCardTool(b.rc, b.desc.Name, args) + if err != nil { + return tools.Result{Content: err.Error(), IsError: true}, nil + } + return tools.Result{Content: out}, nil + } + res := b.rc.ExecuteTool(core.ToolCallRequest{Name: b.desc.Name, Arguments: args}) + if res.Artifact != nil { + b.produced.add(&core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}) + } + if res.Status != "ok" { + return tools.Result{Content: res.Output, IsError: true}, nil + } + return tools.Result{Content: res.Output}, nil +} + +func conversationSandbox(rc RunCtx) string { + s, ok := rc.(interface{ ConversationSandbox() string }) + if !ok { + return "" + } + return s.ConversationSandbox() +} + +func stageStepFile(ctx context.Context, sandbox string, args json.RawMessage) { + step := native.WorkDir(ctx) + if step == "" || sandbox == "" { + return + } + var a struct { + Path string `json:"path"` + } + if json.Unmarshal(args, &a) != nil || strings.TrimSpace(a.Path) == "" || strings.Contains(a.Path, "..") { + return + } + src := filepath.Join(step, filepath.FromSlash(a.Path)) + dst := filepath.Join(sandbox, filepath.FromSlash(a.Path)) + if _, err := os.Stat(dst); err == nil { + return + } + body, err := os.ReadFile(src) + if err != nil { + return + } + if os.MkdirAll(filepath.Dir(dst), 0o755) != nil { + return + } + _ = os.WriteFile(dst, body, 0o644) +} + +func cardToolDescs() []core.ToolDesc { + return []core.ToolDesc{ + {Name: toolCard, Description: "提交产物卡片", Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"summary":{"type":"string"},"body":{"type":"string"},"scope":{"type":"string"}},"required":["title","body"]}`)}, + {Name: toolCardBegin, Description: "开始流式产物卡", Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"summary":{"type":"string"},"scope":{"type":"string"}},"required":["title"]}`)}, + {Name: toolCardAppend, Description: "追加流式产物卡", Parameters: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}`)}, + {Name: toolCardCommit, Description: "收尾流式产物卡", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}, + } +} + +func executeCardTool(rc RunCtx, name string, args json.RawMessage) (string, error) { + switch name { + case toolCard: + var a struct{ Title, Summary, Body, Scope string } + if err := json.Unmarshal(args, &a); err != nil || strings.TrimSpace(a.Title) == "" || strings.TrimSpace(a.Body) == "" { + return "", fmt.Errorf("submit_card 参数错误") + } + card := core.ActivityCard{Kind: core.CardKindResult, Title: a.Title, Status: core.CardDone, Tone: "ok", Body: []core.UINode{{Type: "markdown", Markdown: a.Body}}} + if a.Scope == "project" { + card.ProjectID = core.ID(rc.Run().ProjectID) + } + created, err := rc.SubmitCard(card) + if err != nil { + return "", err + } + return "已作为卡片发出(" + created.Title + ")", nil + case toolCardBegin: + var a struct{ Title, Summary, Scope string } + if err := json.Unmarshal(args, &a); err != nil || strings.TrimSpace(a.Title) == "" { + return "", fmt.Errorf("card_begin 参数错误") + } + card := core.ActivityCard{Kind: core.CardKindResult, Title: a.Title, Status: core.CardActive, Tone: "ok"} + if a.Scope == "project" { + card.ProjectID = core.ID(rc.Run().ProjectID) + } + created, err := rc.BeginStreamCard(card) + if err != nil { + return "", err + } + return "流式卡已开始(" + created.Title + ")", nil + case toolCardAppend: + var a struct{ Text string } + if err := json.Unmarshal(args, &a); err != nil { + return "", err + } + if err := rc.AppendStreamCard(a.Text); err != nil { + return "", err + } + return "已追加", nil + case toolCardCommit: + card, err := rc.CommitStreamCard() + if err != nil { + return "", err + } + return "已作为卡片发出(" + card.Title + ")", nil + default: + return "", nil + } +} diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index e21fa8b..db89c41 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -231,12 +231,16 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } // 声明工具:编排型内置 + 注册表中画像允许的工具;描述文案库覆盖优先。 - decls := []agentllm.ToolDecl{ - {Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)}, - {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"]}`)}, - {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"]}`)}, - {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"]}`)}, + // runtime.entry=squad 时对话本身就是小队入口,不再提供 squad_orchestrate。 + decls := []agentllm.ToolDecl{} + if squadToolEnabled(eff) { + 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: 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"]}`)}, + ) cardflow := eff.GetBool("ui.cardflow", false) if cardflow { decls = append(decls, agentllm.ToolDecl{ @@ -286,6 +290,11 @@ func (l *LLMRunner) Execute(rc RunCtx) error { if prof.Budget.MaxTokens != nil && *prof.Budget.MaxTokens > 0 { streamOpts.MaxTokens = *prof.Budget.MaxTokens } + defer func() { + if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok { + sink.AddUsage(usageTotal) + } + }() toolCallsMade := 0 thinkingIdx, textIdx := 0, 0 deliverableGuardUsed := false @@ -644,6 +653,14 @@ func currentUserText(rc RunCtx) string { func toolList(rc RunCtx) []core.ToolDesc { return rc.Tools() } +// squadToolEnabled 在小队已经是对话入口时关掉 squad_orchestrate,避免再套一层编排。 +func squadToolEnabled(eff core.EffectiveSettings) bool { + if eff.GetString("runtime.entry", "direct") == "squad" { + return false + } + return eff.GetBool("runtime.squad", true) +} + func orDefault(s, def string) string { if strings.TrimSpace(s) == "" { return def diff --git a/internal/runtime/squad_runner.go b/internal/runtime/squad_runner.go new file mode 100644 index 0000000..18e257a --- /dev/null +++ b/internal/runtime/squad_runner.go @@ -0,0 +1,201 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "agent-native/exec" + agentllm "agent-native/runtime/llm" + "agent-squad/squad" + "agent-system-backend/internal/squadadapter" + core "agent-system-core" +) + +// SquadRunner 是 runtime.entry=squad 时的对话入口。 +// 小队先分诊:简单任务由 assistant 单步完成,复杂任务交给 leader。步骤都跑在内核上。 +type SquadRunner struct { + Stream agentllm.StreamFunc + Router squad.Router + Checker squad.Checker + Hitl squad.HitlPolicy +} + +func (s *SquadRunner) Name() string { return "squad" } + +func (s *SquadRunner) Execute(rc RunCtx) error { + if rc.Context().Err() != nil { + return rc.Context().Err() + } + prof := rc.Profile() + maxTurns := 12 + if prof.Budget.MaxTurns != nil && *prof.Budget.MaxTurns > 0 { + maxTurns = *prof.Budget.MaxTurns + } + maxTools := 0 + if prof.Budget.MaxToolCalls != nil && *prof.Budget.MaxToolCalls > 0 { + maxTools = *prof.Budget.MaxToolCalls + } + produced := &artifactList{} + var streamed strings.Builder + var toolParts []core.Part + textIdx := 0 + onEvent := func(ev exec.ProcessEvent) { + switch ev.Type { + case "text_delta": + if ev.Text == "" { + return + } + streamed.WriteString(ev.Text) + _ = rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: textIdx, Text: ev.Text}) + case "reasoning_delta": + if ev.Text != "" { + _ = rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Text: ev.Text}) + } + case "tool_execution_start": + _ = rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail}) + toolParts = append(toolParts, core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail}}) + case "tool_execution_end": + _ = rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)}) + toolParts = append(toolParts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)}}) + case "usage": + if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok { + sink.AddUsage(core.Usage{PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens}) + } + } + } + router := s.Router + if router == nil { + router = newModelRouter(rc) + } + res, err := squadadapter.RunEntry(rc, squadadapter.EntryOptions{ + DataDir: rc.Settings().GetString("storage.dataDir", "./data"), + Goal: entryGoal(rc), + HostTools: bridgeHostTools(rc, produced), + OnEvent: onEvent, + Stream: s.Stream, + Router: router, + Checker: s.Checker, + Hitl: s.Hitl, + MaxTurns: maxTurns, + MaxToolCalls: maxTools, + Model: ResolveChatModel(prof, rc.Settings()).Model, + }) + if rc.Context().Err() != nil { + return rc.Context().Err() + } + if err != nil { + return err + } + reply := strings.TrimSpace(streamed.String()) + if reply == "" { + reply = strings.TrimSpace(res.Summary) + } + if reply == "" { + reply = "已完成。" + } + if claimsFalseDeliverable(reply, produced.items, rc) { + return fmt.Errorf("声称已生成文件,但没有登记对应工件") + } + if strings.TrimSpace(res.Body) != "" { + if _, werr := rc.WriteArtifact(res.Name, "document", "text/markdown", []byte(res.Body)); werr != nil { + return werr + } + } + parts := append([]core.Part{}, toolParts...) + parts = append(parts, core.Part{Type: core.PartText, Text: reply}) + for _, ref := range produced.items { + parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: ref}) + } + return rc.SetAssistantParts(parts) +} + +func entryGoal(rc RunCtx) string { + var b strings.Builder + b.WriteString(currentUserText(rc)) + if listed, ok := rc.(interface{ WorkspaceInputs() []string }); ok { + if paths := listed.WorkspaceInputs(); len(paths) > 0 { + b.WriteString("\n工作区 inputs:") + b.WriteString(strings.Join(paths, "、")) + } + } + names := make([]string, 0, len(rc.Attachments())) + for _, a := range rc.Attachments() { + if a.Meta.Filename != "" { + names = append(names, a.Meta.Filename) + } + } + if len(names) > 0 { + b.WriteString("\n附件:") + b.WriteString(strings.Join(names, "、")) + } + return b.String() +} + +type modelRouter struct { + stream agentllm.StreamFunc + model string +} + +func newModelRouter(rc RunCtx) modelRouter { + route := ResolveLightweightModel(rc.Profile(), rc.Settings()) + if route.Model == "" || route.Base == "" { + return modelRouter{} + } + client := &agentllm.Client{BaseURL: route.Base, APIKey: route.Key} + return modelRouter{stream: client.Stream, model: route.Model} +} + +func (r modelRouter) Route(ctx context.Context, goal squad.Goal) (*squad.RouteDecision, error) { + if r.stream == nil || r.model == "" { + return &squad.RouteDecision{Mode: squad.RoutePlan, Family: "none", Reason: "未配置分诊模型,按复杂任务处理"}, nil + } + prompt := `只输出一个 JSON 对象,不要解释。字段 mode 取 direct 或 plan,family 取 minutes、report、proposal 或 none,reason 用一句话。简单问答和单步改写用 direct。需要成文、多步调研或会议材料用 plan。目标:` + goal.Text + var text string + for ev := range r.stream(ctx, r.model, []agentllm.Message{{Role: agentllm.RoleUser, Content: prompt}}, nil, agentllm.StreamOptions{}) { + switch ev.Kind { + case agentllm.KindTextDelta: + text += ev.Text + case agentllm.KindMessageEnd: + if ev.Message.Content != "" { + text = ev.Message.Content + } + case agentllm.KindError: + return &squad.RouteDecision{Mode: squad.RoutePlan, Family: "none", Reason: "分诊调用失败,按复杂任务处理"}, nil + } + } + return parseRoute(text), nil +} + +func parseRoute(text string) *squad.RouteDecision { + dec := &squad.RouteDecision{Mode: squad.RoutePlan, Family: "none", Reason: "分诊结果无法解析,按复杂任务处理"} + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start < 0 || end <= start { + return dec + } + var raw struct { + Mode string `json:"mode"` + Family string `json:"family"` + Reason string `json:"reason"` + } + if json.Unmarshal([]byte(text[start:end+1]), &raw) != nil { + return dec + } + switch raw.Mode { + case string(squad.RouteDirect): + dec.Mode = squad.RouteDirect + case string(squad.RoutePlan): + dec.Mode = squad.RoutePlan + default: + return dec + } + if raw.Family != "" { + dec.Family = raw.Family + } + if raw.Reason != "" { + dec.Reason = raw.Reason + } + return dec +} diff --git a/internal/runtime/squad_runner_test.go b/internal/runtime/squad_runner_test.go new file mode 100644 index 0000000..f1bb76f --- /dev/null +++ b/internal/runtime/squad_runner_test.go @@ -0,0 +1,463 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + agentllm "agent-native/runtime/llm" + "agent-squad/squad" + core "agent-system-core" +) + +const squadGoodDoc = "# 应先把结论写进标题\n\n一句话:范围已经明确,按会议记录执行。\n\n依据来自附件里的原话。\n" + +type fixedRoute struct{ mode squad.RouteMode } + +func (r fixedRoute) Route(ctx context.Context, goal squad.Goal) (*squad.RouteDecision, error) { + return &squad.RouteDecision{Mode: r.mode, Family: "minutes", Reason: "测试"}, nil +} + +type failCheck struct{} + +func (failCheck) Check(req squad.CheckRequest) *squad.QualityReport { + return &squad.QualityReport{Passed: false, Score: 10, Violations: []string{"缺结论"}} +} + +type entryFake struct { + ctx context.Context + prof core.AgentProfile + settings core.EffectiveSettings + text string + inputs []string + files []string + tools []core.ToolDesc + sandbox string + events []core.StreamEventType + parts []core.Part + hitl int + wrote []string + called []string + usage core.Usage + cards int +} + +func (f *entryFake) Context() context.Context { + if f.ctx == nil { + return context.Background() + } + return f.ctx +} +func (f *entryFake) Run() core.Run { + return core.Run{ID: "run_t", Owner: "owner", ConversationID: "conv"} +} +func (f *entryFake) Profile() core.AgentProfile { return f.prof } +func (f *entryFake) Settings() core.EffectiveSettings { return f.settings } +func (f *entryFake) Emit(typ core.StreamEventType, payload any) error { + f.events = append(f.events, typ) + return nil +} +func (f *entryFake) SetAssistantParts(parts []core.Part) error { f.parts = parts; return nil } +func (f *entryFake) AskHITL(card core.HITLCard) (core.HITLResponse, error) { + f.hitl++ + return core.HITLResponse{ChoiceID: "approve"}, nil +} +func (f *entryFake) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) { + return core.AgentResultPayload{}, errors.New("no spawn") +} +func (f *entryFake) UpdatePlan(p core.PlanSnapshot) error { return nil } +func (f *entryFake) UpdateTodos(l core.TodoList) error { return nil } +func (f *entryFake) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { + f.wrote = append(f.wrote, name) + return core.Artifact{ID: "art_aaaaaaaaaaaa", Name: name, Kind: kind}, nil +} +func (f *entryFake) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil } +func (f *entryFake) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { + f.called = append(f.called, call.Name) + if call.Name == "register_artifact" { + return core.ToolCallResult{Status: "ok", Output: "已登记", Artifact: &core.Artifact{ID: "art_bbbbbbbbbbbb", Name: "纪要.docx", Kind: "document"}} + } + return core.ToolCallResult{Status: "ok", Output: "ok"} +} +func (f *entryFake) History() []core.Message { + return []core.Message{{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: f.text}}}} +} +func (f *entryFake) Attachments() []core.ParsedDocument { + return []core.ParsedDocument{{Meta: core.DocMeta{Filename: "notes.docx"}}} +} +func (f *entryFake) Tools() []core.ToolDesc { return f.tools } +func (f *entryFake) Subtask() string { return "" } +func (f *entryFake) CreateCard(card core.ActivityCard) (core.ActivityCard, error) { + f.cards++ + card.ID = "card_1" + return card, nil +} +func (f *entryFake) SetCardStatus(id string, status core.CardStatus, tone, note string) error { + return nil +} +func (f *entryFake) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) { + return f.CreateCard(card) +} +func (f *entryFake) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) { + return f.CreateCard(card) +} +func (f *entryFake) AppendStreamCard(text string) error { return nil } +func (f *entryFake) CommitStreamCard() (core.ActivityCard, error) { + f.cards++ + return core.ActivityCard{ID: "card_1", Title: "流式"}, nil +} +func (f *entryFake) Card(id string) *core.ActivityCard { return nil } +func (f *entryFake) Artifact(id string) (core.Artifact, []byte, bool) { + return core.Artifact{}, nil, false +} +func (f *entryFake) SetContextObservation(o core.ContextObservation) {} +func (f *entryFake) WorkspaceInputs() []string { return f.inputs } +func (f *entryFake) ConversationSandbox() string { return f.sandbox } +func (f *entryFake) AddUsage(u core.Usage) { + f.usage.PromptTokens += u.PromptTokens + f.usage.CompletionTokens += u.CompletionTokens +} + +func writeThenStop(model *string, calls *int) agentllm.StreamFunc { + var mu sync.Mutex + n := 0 + body, _ := json.Marshal(map[string]string{"path": "output.md", "content": squadGoodDoc}) + return func(ctx context.Context, m string, messages []agentllm.Message, td []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + ch := make(chan agentllm.Event, 4) + go func() { + defer close(ch) + if ctx.Err() != nil { + ch <- agentllm.Event{Kind: agentllm.KindError, ErrorMessage: ctx.Err().Error()} + return + } + mu.Lock() + n++ + step := n + mu.Unlock() + if model != nil { + *model = m + } + if calls != nil { + *calls = step + } + if step%2 == 1 { + ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: "结论应先写进标题。"} + ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ + Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, + ToolCalls: []agentllm.ToolCall{{ID: "w", Name: "write_file", Arguments: string(body)}}, + }, Usage: agentllm.Usage{PromptTokens: 4, CompletionTokens: 2}} + return + } + ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: "写完了。"} + ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ + Role: agentllm.RoleAssistant, Content: "写完了。", StopReason: agentllm.StopEnd, + }, Usage: agentllm.Usage{PromptTokens: 3, CompletionTokens: 1}} + }() + return ch + } +} + +func TestSquadEntryStreamsAndRegisters(t *testing.T) { + dir := t.TempDir() + var model string + rc := &entryFake{ + text: "整理会议结论", + inputs: []string{filepath.Join(dir, "inputs", "notes.docx")}, + sandbox: dir, + settings: core.EffectiveSettings{ + "storage": map[string]any{"dataDir": dir}, + "models": map[string]any{"chat": map[string]any{"model": "base", "baseUrl": "http://test", "apiKey": "k"}}, + "ui": map[string]any{"cardflow": true}, + }, + prof: core.AgentProfile{Model: core.ModelRoute{Chat: "profile-model"}}, + tools: []core.ToolDesc{{Name: "register_artifact", Description: "登记", Parameters: json.RawMessage(`{"type":"object"}`)}}, + } + cardBody, _ := json.Marshal(map[string]string{"title": "纪要", "body": "结论应先写。"}) + regBody, _ := json.Marshal(map[string]string{"path": "output.md"}) + runner := &SquadRunner{ + Router: fixedRoute{mode: squad.RouteDirect}, + Checker: squad.MinimalChecker{}, + Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled}, + Stream: scriptedCalls(t, &model, [][]agentllm.Event{ + { + {Kind: agentllm.KindTextDelta, Text: "结论应先写进标题。"}, + {Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{ + {ID: "c", Name: "submit_card", Arguments: string(cardBody)}, + }}, Usage: agentllm.Usage{PromptTokens: 2, CompletionTokens: 1}}, + }, + { + {Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{ + {ID: "r", Name: "register_artifact", Arguments: string(regBody)}, + }}, Usage: agentllm.Usage{PromptTokens: 2, CompletionTokens: 1}}, + }, + { + {Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{ + {ID: "w", Name: "write_file", Arguments: mustDocArgs()}, + }}, Usage: agentllm.Usage{PromptTokens: 2, CompletionTokens: 1}}, + }, + { + {Kind: agentllm.KindTextDelta, Text: "写完了。"}, + {Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "写完了。", StopReason: agentllm.StopEnd}, Usage: agentllm.Usage{PromptTokens: 1, CompletionTokens: 1}}, + }, + }), + } + if err := runner.Execute(rc); err != nil { + t.Fatal(err) + } + if model != "profile-model" { + t.Fatalf("model = %s", model) + } + if !containsEvent(rc.events, core.EvMessageDelta) || !containsEvent(rc.events, core.EvToolCall) { + t.Fatalf("events = %v", rc.events) + } + if rc.cards == 0 || !containsStr(rc.called, "register_artifact") { + t.Fatalf("cards=%d tools=%v", rc.cards, rc.called) + } + if rc.usage.PromptTokens == 0 { + t.Fatal("usage not recorded") + } + if len(rc.wrote) == 0 { + t.Fatal("deliverable was not registered") + } + task := filepath.Join(dir, "squad-entry", "run_t") + found := false + _ = filepath.Walk(task, func(path string, info os.FileInfo, err error) error { + if err == nil && strings.HasSuffix(path, "TASK.md") { + b, _ := os.ReadFile(path) + if strings.Contains(string(b), "inputs") && strings.Contains(string(b), "notes.docx") { + found = true + } + } + return nil + }) + if !found { + t.Fatal("goal did not carry inputs and attachment name") + } +} + +func TestSquadEntryFalseDeliverableAndBudgets(t *testing.T) { + dir := t.TempDir() + rc := baseEntry(dir) + claim := &SquadRunner{ + Router: fixedRoute{mode: squad.RouteDirect}, Checker: squad.MinimalChecker{}, + Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled}, + Stream: scriptedCalls(t, nil, [][]agentllm.Event{ + {{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{{ID: "w", Name: "write_file", Arguments: mustDocArgs()}}}}}, + {{Kind: agentllm.KindTextDelta, Text: "已生成 纪要.docx"}, {Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "已生成 纪要.docx", StopReason: agentllm.StopEnd}}}, + }), + } + if err := claim.Execute(rc); err == nil || !strings.Contains(err.Error(), "没有登记") { + t.Fatalf("false claim err = %v", err) + } + + var calls int + turns := 1 + rc2 := baseEntry(dir) + rc2.prof.Budget.MaxTurns = &turns + limited := &SquadRunner{ + Router: fixedRoute{mode: squad.RouteDirect}, Checker: squad.MinimalChecker{}, + Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled}, + Stream: writeThenStop(nil, &calls), + } + // 第一轮只写了别的文件,第二轮才写 output.md;轮次上限为 1 时交付物缺失。 + limited.Stream = oneTurnOtherFile(&calls) + if err := limited.Execute(rc2); err == nil { + t.Fatal("turn cap should stop before the deliverable") + } + if calls != 1 { + t.Fatalf("model calls = %d", calls) + } + + tools := 1 + rc3 := baseEntry(dir) + rc3.prof.Budget.MaxToolCalls = &tools + var toolCalls int + capped := &SquadRunner{ + Router: fixedRoute{mode: squad.RouteDirect}, Checker: squad.MinimalChecker{}, + Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled}, + Stream: twoTools(&toolCalls), + } + if err := capped.Execute(rc3); err == nil || !strings.Contains(err.Error(), "budget_exceeded") { + t.Fatalf("tool cap err = %v", err) + } +} + +func TestSquadEntryHITLAndCancel(t *testing.T) { + dir := t.TempDir() + rc := baseEntry(dir) + runner := &SquadRunner{ + Router: fixedRoute{mode: squad.RouteDirect}, Checker: failCheck{}, + Stream: writeThenStop(nil, nil), + } + if err := runner.Execute(rc); err != nil { + t.Fatal(err) + } + if rc.hitl == 0 { + t.Fatal("quality hold did not ask the user") + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + rc2 := baseEntry(dir) + rc2.ctx = ctx + if err := (&SquadRunner{}).Execute(rc2); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel err = %v", err) + } +} + +func TestHostBridgeHonorsAllowlist(t *testing.T) { + rc := &entryFake{tools: []core.ToolDesc{ + {Name: "register_artifact"}, + {Name: "read_file"}, + {Name: "knowledge_search"}, + }, settings: core.EffectiveSettings{}} + rc.prof.Tools.Deny = []string{"register_artifact"} + names := map[string]bool{} + for _, tool := range bridgeHostTools(rc, nil) { + names[tool.Name()] = true + } + if names["register_artifact"] || names["read_file"] || !names["knowledge_search"] { + t.Fatalf("bridged = %v", names) + } +} + +func TestParseRoute(t *testing.T) { + dec := parseRoute("好的 {\"mode\":\"direct\",\"family\":\"report\",\"reason\":\"一句就够\"}") + if dec.Mode != squad.RouteDirect || dec.Family != "report" { + t.Fatalf("%+v", dec) + } + if parseRoute("不是 json").Mode != squad.RoutePlan { + t.Fatal("unparsed route should stay on the plan path") + } +} + +func TestSquadToolHiddenWhenEntryIsSquad(t *testing.T) { + eff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad", "squad": true}} + if squadToolEnabled(eff) { + t.Fatal("squad entry must not expose squad_orchestrate") + } + if !squadToolEnabled(core.EffectiveSettings{"runtime": map[string]any{"squad": true}}) { + t.Fatal("direct entry keeps the tool") + } +} + +func baseEntry(dir string) *entryFake { + return &entryFake{ + text: "整理会议结论", + settings: core.EffectiveSettings{ + "storage": map[string]any{"dataDir": dir}, + "models": map[string]any{"chat": map[string]any{"model": "m", "baseUrl": "http://test", "apiKey": "k"}}, + }, + prof: core.AgentProfile{Model: core.ModelRoute{Chat: "m"}}, + } +} + +func mustDocArgs() string { + b, _ := json.Marshal(map[string]string{"path": "output.md", "content": squadGoodDoc}) + return string(b) +} + +func scriptedCalls(t *testing.T, model *string, turns [][]agentllm.Event) agentllm.StreamFunc { + t.Helper() + var mu sync.Mutex + i := 0 + return func(ctx context.Context, m string, messages []agentllm.Message, td []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + ch := make(chan agentllm.Event, 8) + go func() { + defer close(ch) + mu.Lock() + n := i + if i < len(turns) { + i++ + } + mu.Unlock() + if model != nil { + *model = m + } + if n >= len(turns) { + ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "done", StopReason: agentllm.StopEnd}} + return + } + for _, ev := range turns[n] { + ch <- ev + } + }() + return ch + } +} + +func oneTurnOtherFile(calls *int) agentllm.StreamFunc { + body, _ := json.Marshal(map[string]string{"path": "note.md", "content": "not the deliverable"}) + var once sync.Once + return func(ctx context.Context, model string, messages []agentllm.Message, td []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + ch := make(chan agentllm.Event, 2) + go func() { + defer close(ch) + once.Do(func() { + if calls != nil { + *calls = 1 + } + }) + if calls != nil && *calls == 1 { + *calls = 1 + } + ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ + Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, + ToolCalls: []agentllm.ToolCall{{ID: "w", Name: "write_file", Arguments: string(body)}}, + }} + }() + return ch + } +} + +func twoTools(calls *int) agentllm.StreamFunc { + var mu sync.Mutex + n := 0 + return func(ctx context.Context, model string, messages []agentllm.Message, td []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + ch := make(chan agentllm.Event, 2) + go func() { + defer close(ch) + mu.Lock() + n++ + step := n + mu.Unlock() + if calls != nil { + *calls = step + } + name := "write_file" + args := mustDocArgs() + if step > 1 { + body, _ := json.Marshal(map[string]string{"path": "note.md", "content": "x"}) + args = string(body) + } + ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ + Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, + ToolCalls: []agentllm.ToolCall{{ID: "w", Name: name, Arguments: args}}, + }} + }() + return ch + } +} + +func containsEvent(events []core.StreamEventType, want core.StreamEventType) bool { + for _, ev := range events { + if ev == want { + return true + } + } + return false +} + +func containsStr(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 0976177..8c55fab 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -30,10 +30,11 @@ func SystemDefaults() map[string]any { "tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具) }, "runtime": map[string]any{ - "mock": false, // true = 全部会话走 mock runner(演示/测试) - "squad": true, // 小队编排(Agent-Squad 引擎)可用 - "rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限) - "redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内) + "mock": false, // true = 全部会话走 mock runner(演示/测试) + "squad": true, // 小队编排(Agent-Squad 引擎)可用 + "entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认) + "rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限) + "redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内) }, "storage": map[string]any{ "dataDir": "./data", // 启动注入实际值(--data) @@ -73,6 +74,7 @@ func Schema() []core.SettingDesc { {Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot}, {Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"}, {Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"}, + {Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"}, {Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"}, {Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"}, {Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun}, diff --git a/internal/squadadapter/entry.go b/internal/squadadapter/entry.go new file mode 100644 index 0000000..0f167cb --- /dev/null +++ b/internal/squadadapter/entry.go @@ -0,0 +1,230 @@ +package squadadapter + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "agent-native/exec" + agentllm "agent-native/runtime/llm" + "agent-native/runtime/tools" + "agent-squad/squad" + "agent-system-backend/internal/quality" +) + +// EntryOptions 是小队作为对话入口时的一次运行参数。 +type EntryOptions struct { + DataDir string + Goal string + HostTools []tools.Tool + OnEvent func(ev exec.ProcessEvent) + Stream agentllm.StreamFunc + Router squad.Router + Checker squad.Checker + Hitl squad.HitlPolicy + MaxTurns int + MaxToolCalls int + RepairBudget int + Model string +} + +// EntryResult 是入口跑完后的文本。工件登记由调用方在虚假交付检查之后做。 +type EntryResult struct { + Summary string + Body string + Name string +} + +// RunEntry 用小队引擎跑完一条用户目标。简单任务是单步 assistant,复杂任务进 leader。 +// 每一步都走 agent-native 内核。 +func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) { + if strings.TrimSpace(opt.Goal) == "" { + return EntryResult{}, fmt.Errorf("squad entry: goal text is empty") + } + cfg, enabled := squad.AgentNativeConfig{}, false + if AgentNativeOptions != nil { + cfg, enabled = AgentNativeOptions(rc) + } + if opt.Model != "" { + cfg.DefaultModel = opt.Model + } + if opt.Stream != nil { + cfg.Stream = opt.Stream + if cfg.DefaultModel == "" { + cfg.DefaultModel = "test" + } + enabled = true + } + if !enabled || cfg.DefaultModel == "" { + return EntryResult{}, fmt.Errorf("squad entry: 主模型未配置") + } + cfg.HostTools = opt.HostTools + cfg.OnEvent = opt.OnEvent + if opt.MaxTurns > 0 { + cfg.MaxTurns = opt.MaxTurns + } + if cfg.MaxTurns <= 0 { + cfg.MaxTurns = 12 + } + cfg.MaxToolCalls = opt.MaxToolCalls + + hostNames := make([]string, 0, len(opt.HostTools)) + for _, tool := range opt.HostTools { + hostNames = append(hostNames, tool.Name()) + } + registry := squad.NewRoleRegistry() + for _, role := range kernelRoles(roleCaps{ + host: hostNames, exec: cfg.Exec != nil, skills: len(cfg.SkillsRoots) > 0, + search: cfg.Search != nil, fetch: cfg.Fetch != nil, + }) { + if err := registry.Register(role); err != nil { + return EntryResult{}, fmt.Errorf("squad entry: register %s: %w", role.Role, err) + } + } + backends := buildBackends(rc) + backends.RegisterAgentNativeFull(cfg) + + checker := opt.Checker + if checker == nil { + checker = quality.Checker{} + } + repair := opt.RepairBudget + if repair <= 0 { + repair = 2 + } + runDir := filepath.Join(opt.DataDir, "squad-entry", rc.Run().ID) + engine, err := squad.NewEngine(squad.Options{ + DataDir: runDir, + Registry: registry, + Planner: squad.NewRulePlanner(), + Backends: backends, + DefaultBackend: "agent-native", + StepTimeout: 5 * time.Minute, + HitlPolicy: opt.Hitl, + Checker: checker, + RepairBudget: repair, + Router: opt.Router, + Sink: squad.ProgressFunc(func(ev squad.Event) { onSquadEvent(rc, ev) }), + }) + if err != nil { + return EntryResult{}, fmt.Errorf("squad entry: engine: %w", err) + } + + res, err := engine.Run(rc.Context(), squad.Goal{Text: opt.Goal}, squad.RunOptions{}) + for err == nil && res != nil && !squad.RunStatus(res.Status).Terminal() { + switch { + case isHoldStatus(string(res.Status)): + req := res.Pending + if req == nil { + if req, err = engine.Pending(res.RunID); err != nil || req == nil { + break + } + } + decision, aerr := askHuman(rc, req) + if aerr != nil { + return EntryResult{}, aerr + } + res, err = engine.Respond(rc.Context(), res.RunID, decision) + default: + res, err = engine.Resume(rc.Context(), res.RunID) + } + if err != nil { + break + } + if res.Plan != nil { + _ = rc.UpdatePlan(mapPlan(res.Plan)) + } + } + if err != nil { + return EntryResult{}, fmt.Errorf("squad entry: %w", err) + } + if res == nil { + return EntryResult{}, fmt.Errorf("squad entry: empty result") + } + if res.Plan != nil { + _ = rc.UpdatePlan(mapPlan(res.Plan)) + } + if res.Status != squad.RunCompleted && res.Status != squad.RunDegraded { + return EntryResult{Summary: res.Summary}, fmt.Errorf("squad entry: status %s: %s", res.Status, res.Error) + } + name := "squad-entry.md" + if res.Deliverable != "" && !strings.Contains(res.Deliverable, "..") { + name = filepath.Base(res.Deliverable) + } + return EntryResult{Summary: res.Summary, Body: readDeliverable(res), Name: name}, nil +} + +func readDeliverable(res *squad.RunResult) string { + if res.WorkDir != "" && res.Deliverable != "" && !strings.Contains(res.Deliverable, "..") { + path := filepath.Join(res.WorkDir, filepath.FromSlash(res.Deliverable)) + if b, err := os.ReadFile(path); err == nil { + return string(b) + } + } + return res.Deliverable +} + +type roleCaps struct { + host []string + exec bool + skills bool + search bool + fetch bool +} + +func kernelRoles(caps roleCaps) []squad.RoleSpec { + base := []string{"read_file", "write_file", "list_dir", "grep"} + writerTools := append([]string{}, base...) + writerTools = append(writerTools, "edit_file", "multi_edit", "glob") + if caps.exec { + writerTools = append(writerTools, "run_python", "run_command") + } + if caps.skills { + writerTools = append(writerTools, "load_skill") + } + writerTools = append(writerTools, caps.host...) + research := append([]string{}, base...) + if caps.search { + research = append(research, "web_search") + } + if caps.fetch { + research = append(research, "fetch_url") + } + instr := func(kind, fallback string) string { + if RoleInstructions != nil { + if body, ok := RoleInstructions(kind); ok && strings.TrimSpace(body) != "" { + return body + } + } + return fallback + } + writerBash := squad.BashPermissionDeny + if caps.exec { + writerBash = squad.BashPermissionAsk + } + writerSkills := []string(nil) + if caps.skills { + writerSkills = []string{"docx-author", "xlsx-author", "pptx-author"} + } + return []squad.RoleSpec{ + {Role: "leader", Name: "组长", Backend: "agent-native", BashPermission: squad.BashPermissionDeny, + Tools: []string{"read_file", "write_file"}, Instructions: instr("leader", "先判断任务该直通还是拆开。复杂任务每次只委派一个成员,并写明技能与最低分。")}, + {Role: "assistant", Name: "助手", Backend: "agent-native", BashPermission: writerBash, + Tools: writerTools, Skills: writerSkills, + Instructions: instr("assistant", "直接完成用户的这一件事。需要成文时按交付物工作流生成并登记,不要只在回复里声称文件已生成。")}, + {Role: "planner", Name: "规划员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny, + Tools: base, Instructions: instr("planner", "梳理目标与材料,产出结构化要点与执行建议。")}, + {Role: "researcher", Name: "调研员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny, + Tools: research, Instructions: instr("researcher", "围绕目标调研关键问题,输出简明结论与依据。")}, + {Role: "writer", Name: "撰稿员", Backend: "agent-native", BashPermission: writerBash, + Tools: writerTools, Skills: writerSkills, Instructions: instr("writer", "把前序要点与调研结论整理为清晰成文的交付文档。")}, + {Role: "analyst", Name: "分析员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny, + Tools: append(append([]string{}, base...), caps.host...), + Instructions: instr("analyst", "按现象、影响、隐性顾虑、建议四层写洞察。开头给一句话洞察,结尾给下一步行动。")}, + {Role: "reviewer", Name: "审校员", Backend: "agent-native", BashPermission: squad.BashPermissionDeny, + Tools: append(append([]string{}, base...), caps.host...), + Instructions: instr("reviewer", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")}, + } +}