Add a router fast path and pass host tools through
Why: Simple tasks should finish as one assistant step, while complex tasks stay in leader mode, and every kernel step needs the host's tools and events. Evidence: go test ./squad/ -count=1 passed, including direct route, leader entry, skill override, and delegation score fields. Impact: Hosts can inject HostTools and OnEvent; a Router can choose direct or plan without changing product roles. Refs: deliverable-quality-system B2 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Vendored
+1
-1
Submodule deps/agent-native updated: f9ab2ef698...951c141f95
+32
-1
@@ -1,6 +1,7 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -71,6 +72,10 @@ type AgentNativeConfig struct {
|
||||
SkillsRoots []string
|
||||
Subagent subagent.Factory
|
||||
Compact *native.CompactConfig
|
||||
// HostTools 注册进内核工作区。与内置工具同名时该步骤失败,不覆盖。
|
||||
HostTools []tools.Tool
|
||||
// OnEvent 收到内核的逐步过程事件(文本、工具、用量)。与 Request.OnEvent 同时生效。
|
||||
OnEvent func(ev exec.ProcessEvent)
|
||||
}
|
||||
|
||||
// buildAgentNativeOptions 按角色把配置换算为 native.Options。
|
||||
@@ -94,6 +99,7 @@ func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Optio
|
||||
SkillsRoots: cfg.SkillsRoots,
|
||||
Subagent: cfg.Subagent,
|
||||
Compact: cfg.Compact,
|
||||
HostTools: cfg.HostTools,
|
||||
}
|
||||
if cfg.Client != nil {
|
||||
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
|
||||
@@ -106,6 +112,27 @@ func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Optio
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// processEventBackend 把配置级过程回调接到每次 Execute,同时保留请求自带的 OnEvent。
|
||||
type processEventBackend struct {
|
||||
next exec.Backend
|
||||
on func(ev exec.ProcessEvent)
|
||||
}
|
||||
|
||||
func (b *processEventBackend) Name() string { return b.next.Name() }
|
||||
|
||||
func (b *processEventBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
if b.on != nil {
|
||||
prev := req.OnEvent
|
||||
req.OnEvent = func(ev exec.ProcessEvent) {
|
||||
b.on(ev)
|
||||
if prev != nil {
|
||||
prev(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.next.Execute(ctx, req)
|
||||
}
|
||||
|
||||
// RegisterAgentNative 注册 "agent-native" 后端工厂(基础签名,向后兼容):
|
||||
// 角色级 Model 覆盖 defaultModel,无附加能力装配。
|
||||
func (r *BackendResolver) RegisterAgentNative(client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
|
||||
@@ -123,7 +150,11 @@ func (r *BackendResolver) RegisterAgentNativeFull(cfg AgentNativeConfig) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return native.New(opts), nil
|
||||
next := native.New(opts)
|
||||
if cfg.OnEvent == nil {
|
||||
return next, nil
|
||||
}
|
||||
return &processEventBackend{next: next, on: cfg.OnEvent}, nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+28
-7
@@ -43,6 +43,9 @@ type Options struct {
|
||||
// IntentRunner 系统功能步执行器(squadintent 提供)。目录启用而 Runner 缺席时,
|
||||
// intent 步骤 fail-closed。
|
||||
IntentRunner IntentRunner
|
||||
// Router 可选分诊。direct = 单步 assistant,不进多角色;plan = leader 委派。
|
||||
// nil = 保持调用方传入的 RunOptions.Mode。
|
||||
Router Router
|
||||
}
|
||||
|
||||
// engineDeps 驱动器依赖(由引擎从 Options 派生)。
|
||||
@@ -217,13 +220,31 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
meta := &st.Meta
|
||||
|
||||
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
|
||||
planOut, err := e.opts.Planner.Plan(ctx, PlanInput{
|
||||
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
|
||||
IntentCatalog: e.opts.IntentCatalog,
|
||||
})
|
||||
if err != nil {
|
||||
return e.failNew(meta, err)
|
||||
if e.opts.Router != nil && runOpts.Mode == "" && meta.Metadata["route"] == "" {
|
||||
dec, rerr := e.opts.Router.Route(ctx, meta.Goal)
|
||||
if rerr != nil {
|
||||
return e.failNew(meta, rerr)
|
||||
}
|
||||
applyRoute(meta, dec, runOpts.Mode)
|
||||
}
|
||||
|
||||
var planOut *PlanOutput
|
||||
var err error
|
||||
if meta.Metadata["route_direct"] == "1" {
|
||||
plan, perr := directAssistantPlan(e.opts.Registry, meta.Goal)
|
||||
if perr != nil {
|
||||
return e.failNew(meta, perr)
|
||||
}
|
||||
planOut = &PlanOutput{Plan: plan}
|
||||
} else {
|
||||
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
|
||||
planOut, err = e.opts.Planner.Plan(ctx, PlanInput{
|
||||
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
|
||||
IntentCatalog: e.opts.IntentCatalog,
|
||||
})
|
||||
if err != nil {
|
||||
return e.failNew(meta, err)
|
||||
}
|
||||
}
|
||||
// 目录驱动编排的确定性校验:intent 步骤命中目录、必填槽位齐、枚举合法(fail-closed)。
|
||||
if err := validateIntentSteps(planOut.Plan, e.opts.IntentCatalog); err != nil {
|
||||
|
||||
+9
-1
@@ -73,6 +73,8 @@ type delegation struct {
|
||||
Member string `json:"member,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Targets []string `json:"targets,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
MinScore int `json:"min_score,omitempty"`
|
||||
Briefing string `json:"briefing,omitempty"` // 给成员的补充指令
|
||||
Question string `json:"question,omitempty"` // escalate 时的问题
|
||||
Reason string `json:"reason,omitempty"`
|
||||
@@ -104,7 +106,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string {
|
||||
b.WriteString(`你是本小队的 LEADER。你被激活的唯一职责是协调,不是自己实现。
|
||||
|
||||
每轮你必须做且只做一件事:评估当前进展,然后输出一份委派决策文件 delegation.json 到本子目录(用工具写入,不要输出到对话里):
|
||||
{"action":"delegate","member":"成员role","title":"给成员的子任务标题","targets":["output.md"],"briefing":"给成员的补充指令/验收标准","reason":"为什么派给这个人"}
|
||||
{"action":"delegate","member":"成员role","title":"给成员的子任务标题","targets":["output.md"],"skills":["可选,覆盖该成员本步技能"],"min_score":0,"briefing":"给成员的补充指令/验收标准","reason":"为什么派给这个人"}
|
||||
{"action":"escalate","question":"要向人提出的问题","reason":"为什么需要人拍板"}
|
||||
{"action":"close","reason":"目标已达成,收口"}
|
||||
{"action":"failed","reason":"无法达成的原因"}
|
||||
@@ -316,6 +318,12 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
} else {
|
||||
step.Targets = []string{"output.md"}
|
||||
}
|
||||
if len(dec.Skills) > 0 {
|
||||
step.Skills = dec.Skills
|
||||
}
|
||||
if dec.MinScore > 0 {
|
||||
step.MinScore = dec.MinScore
|
||||
}
|
||||
st.Plan.Workflow.Steps = append(st.Plan.Workflow.Steps, step)
|
||||
_ = st.save()
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ type StepSpec struct {
|
||||
AllowDynamicAdjustment bool `json:"allow_dynamic_adjustment,omitempty"`
|
||||
// Critical: nil(默认)= 关键步骤失败即终止;false = 非关键,失败降级继续。
|
||||
Critical *bool `json:"critical,omitempty"`
|
||||
// Skills 覆盖角色默认技能。空 = 用角色自己的技能列表。
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
// MinScore 质检总分下限。0 = 不额外设门槛,只看 Checker.Passed。
|
||||
MinScore int `json:"min_score,omitempty"`
|
||||
}
|
||||
|
||||
// IsCritical 报告步骤是否关键(nil 视为关键)。
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// RouteMode 是分诊结果。direct 不进入多角色流程;plan 进入 leader 委派。
|
||||
type RouteMode string
|
||||
|
||||
const (
|
||||
RouteDirect RouteMode = "direct"
|
||||
RoutePlan RouteMode = "plan"
|
||||
)
|
||||
|
||||
// RouteDecision 描述这一次目标怎么跑,以及它属于哪一族交付物。
|
||||
type RouteDecision struct {
|
||||
Mode RouteMode
|
||||
Family string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Router 在规划之前做一次分诊。nil 决策表示不干预。
|
||||
type Router interface {
|
||||
Route(ctx context.Context, goal Goal) (*RouteDecision, error)
|
||||
}
|
||||
|
||||
// applyRoute 把分诊写进运行元数据。显式 RunOptions.Mode 优先,分诊不覆盖。
|
||||
func applyRoute(meta *RunMetadata, dec *RouteDecision, explicitMode string) {
|
||||
if dec == nil || explicitMode != "" {
|
||||
return
|
||||
}
|
||||
if meta.Metadata == nil {
|
||||
meta.Metadata = map[string]string{}
|
||||
}
|
||||
meta.Metadata["route"] = string(dec.Mode)
|
||||
if dec.Family != "" {
|
||||
meta.Metadata["deliverable_family"] = dec.Family
|
||||
}
|
||||
if dec.Reason != "" {
|
||||
meta.Metadata["route_reason"] = dec.Reason
|
||||
}
|
||||
switch dec.Mode {
|
||||
case RouteDirect:
|
||||
meta.Metadata["route_direct"] = "1"
|
||||
case RoutePlan:
|
||||
meta.Metadata["mode"] = "leader"
|
||||
if meta.Metadata["leader_role"] == "" {
|
||||
meta.Metadata["leader_role"] = "leader"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// directAssistantPlan 生成只有 assistant 一步的计划。
|
||||
func directAssistantPlan(reg *RoleRegistry, goal Goal) (*SquadPlan, error) {
|
||||
spec, ok := reg.Get("assistant")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("direct route requires a registered assistant role")
|
||||
}
|
||||
return &SquadPlan{
|
||||
Squad: SquadConfig{Agents: []RoleSpec{spec}},
|
||||
Workflow: WorkflowPlan{
|
||||
WorkflowID: NewPlanID(),
|
||||
Title: goal.Text,
|
||||
Status: "pending",
|
||||
Steps: []StepSpec{{
|
||||
ID: "assistant",
|
||||
Role: "assistant",
|
||||
Title: goal.Text,
|
||||
Targets: []string{"output.md"},
|
||||
SubWorkspace: "tasks/01_assistant",
|
||||
Status: "pending",
|
||||
}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
type fixedRouter struct{ dec *RouteDecision }
|
||||
|
||||
func (r fixedRouter) Route(ctx context.Context, goal Goal) (*RouteDecision, error) {
|
||||
return r.dec, nil
|
||||
}
|
||||
|
||||
func TestRouterDirectRunsAssistantOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
if err := reg.Register(RoleSpec{Role: "assistant", Name: "助手", Backend: "script"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{
|
||||
"01_assistant": {"output.md": goodReport},
|
||||
}})
|
||||
engine, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Planner: NewMockPlanner(), Backends: backends,
|
||||
HitlPolicy: HitlPolicy{Mode: HitlDisabled},
|
||||
Router: fixedRouter{dec: &RouteDecision{Mode: RouteDirect, Family: "minutes", Reason: "短任务"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写一句纪要"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
if res.Plan == nil || len(res.Plan.Steps) != 1 || res.Plan.Steps[0].Role != "assistant" {
|
||||
t.Fatalf("plan = %+v", res.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterPlanEntersLeaderMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
_ = reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "script"})
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{}})
|
||||
engine, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg,
|
||||
Planner: mockScriptPlanner(StepSpec{ID: "w", Role: "writer", Title: "写", Targets: []string{"output.md"}}),
|
||||
Backends: backends,
|
||||
HitlPolicy: HitlPolicy{Mode: HitlDisabled},
|
||||
Router: fixedRouter{dec: &RouteDecision{Mode: RoutePlan, Family: "report"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写完整报告"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunFailed || res.Error == "" {
|
||||
t.Fatalf("missing leader should fail closed, got %s %s", res.Status, res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationCarriesSkillsAndScore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := []byte(`{"action":"delegate","member":"writer","title":"成文","skills":["docx-author"],"min_score":70}`)
|
||||
if err := os.WriteFile(filepath.Join(dir, "delegation.json"), body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec, err := parseDelegation(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dec.Skills) != 1 || dec.Skills[0] != "docx-author" || dec.MinScore != 70 {
|
||||
t.Fatalf("delegation = %+v", dec)
|
||||
}
|
||||
}
|
||||
|
||||
type captureSkillsBackend struct{ got []string }
|
||||
|
||||
func (b *captureSkillsBackend) Name() string { return "capture-skills" }
|
||||
|
||||
func (b *captureSkillsBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
b.got = append([]string{}, req.Skills...)
|
||||
for _, target := range req.Targets {
|
||||
path := filepath.Join(req.WorkDir, target)
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
_ = os.WriteFile(path, []byte(goodReport), 0o644)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: map[string][]byte{"output.md": []byte(goodReport)}}, nil
|
||||
}
|
||||
|
||||
func TestStepSkillsOverrideRoleSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
_ = reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "capture-skills", Skills: []string{"role-skill"}})
|
||||
got := &captureSkillsBackend{}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("capture-skills", got)
|
||||
engine, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Backends: backends,
|
||||
HitlPolicy: HitlPolicy{Mode: HitlDisabled},
|
||||
Planner: &MockPlanner{Script: []StepSpec{{
|
||||
ID: "w", Role: "writer", Title: "写", Targets: []string{"output.md"},
|
||||
SubWorkspace: "tasks/01_writer", Skills: []string{"docx-author"}, MinScore: 10,
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
if len(got.got) != 1 || got.got[0] != "docx-author" {
|
||||
t.Fatalf("request skills = %v", got.got)
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -1,6 +1,7 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -237,7 +238,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
},
|
||||
// RoleSpec v2 贯通:工具白名单 / 技能 / 标识(原为死字段或缺失)。
|
||||
ToolAllowlist: role.Tools,
|
||||
Skills: role.Skills,
|
||||
Skills: firstNonEmptySlice(step.Skills, role.Skills),
|
||||
SessionScope: st.Meta.ID + "/" + step.ID,
|
||||
ExecutionID: st.Meta.ID + "-" + step.ID,
|
||||
Budget: stepBudget,
|
||||
@@ -532,6 +533,10 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h
|
||||
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: deliverable,
|
||||
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
|
||||
})
|
||||
if srcStep.MinScore > 0 && report.Score < float64(srcStep.MinScore) {
|
||||
report.Passed = false
|
||||
report.Violations = append(report.Violations, fmt.Sprintf("总分 %.0f 低于步骤门槛 %d", report.Score, srcStep.MinScore))
|
||||
}
|
||||
if !report.Passed && st.RepairBudgetRemaining > 0 {
|
||||
st.RepairBudgetRemaining--
|
||||
_ = st.save()
|
||||
@@ -607,6 +612,12 @@ func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID st
|
||||
if st.Plan.Workflow.Steps[i].ID == stepID {
|
||||
st.Plan.Workflow.Steps[i].Status = "pending"
|
||||
st.Plan.Workflow.Steps[i].Error = ""
|
||||
note, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "quality-feedback.md"))
|
||||
if err == nil && len(bytes.TrimSpace(note)) > 0 {
|
||||
taskPath := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(st.Plan.Workflow.Steps[i].SubWorkspace), "TASK.md")
|
||||
existing, _ := os.ReadFile(taskPath)
|
||||
_ = os.WriteFile(taskPath, append(existing, append([]byte("\n\n"), note...)...), 0o644)
|
||||
}
|
||||
}
|
||||
}
|
||||
d.emit(st, Event{Type: EvPhase, Text: "质检未通过,自动修复:重跑交付步骤 " + stepID})
|
||||
|
||||
Reference in New Issue
Block a user