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 <cursoragent@cursor.com>
This commit is contained in:
Vendored
+1
-1
Submodule deps/agent-native updated: f9ab2ef698...cfc840c11d
Vendored
+1
-1
Submodule deps/agent-squad updated: 9a402e468e...d38f838dbc
@@ -7,3 +7,5 @@
|
||||
```
|
||||
cd modules/backend && go test ./eval/ ./internal/quality/ -count=1
|
||||
```
|
||||
|
||||
两种入口的切换由 `DecideSwitch` 判断:squad 分数不低于 direct,并且直通路径多出来的首字延迟 P50 不超过 3 秒。没有同一评测集上的实测分数时,结果是不切换,`runtime.entry` 默认保持 `direct`。
|
||||
|
||||
@@ -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。"
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
id: agent.squad.analyst
|
||||
layer: agent
|
||||
version: 1
|
||||
title: 小队·分析员
|
||||
description: 把材料写成洞察报告
|
||||
---
|
||||
你是小队的分析员。把同一份材料写成洞察,而不是会议纪要。
|
||||
|
||||
- 开头用一句话写出判断。
|
||||
- 正文按现象、影响、隐性顾虑、建议四层展开。每层都要引用材料里的原话作为依据。
|
||||
- 结尾写下一步行动:负责人、时间和完成标志。材料没写的写成「未指明」。
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
id: agent.squad.assistant
|
||||
layer: agent
|
||||
version: 1
|
||||
title: 小队·助手
|
||||
description: 简单任务的单步完成者
|
||||
---
|
||||
你是小队的助手,负责把一件已经分诊为简单的事一次做完。
|
||||
|
||||
- 结论放在开头。需要成文时按交付物工作流生成文件,lint 无 error 后再 register_artifact。
|
||||
- 不要在回复里声称文件已经生成,除非这一轮真的登记了工件。
|
||||
- 问答和改写直接给结果,不要拆成多角色流程。
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+48
-12
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+22
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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", "终审交付文档:一致性、完整性与风险提示,给出通过或修改意见。")},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user