Compare commits

..
Author SHA1 Message Date
weichenandCursor d38f838dbc Pass a scripted stream and tool-call cap into the kernel
ci / vet-and-test (pull_request) Failing after 1s
ci / docker-checkout-hostgateway (pull_request) Failing after 3s
Why: Tests and product budgets need to drive each kernel step without a live model, and stop it when the tool-call cap is hit.
Evidence: go test ./squad/ -count=1 passed after fetching agent-native cfc840c.
Impact: AgentNativeConfig.Stream and MaxToolCalls reach native.Options. The nested kernel pin moves to cfc840c.
Refs: deliverable-quality-system B2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 09:06:28 +00:00
weichenandCursor 75d8c08103 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>
2026-10-01 09:00:13 +00:00
weichen 9a402e468e Merge pull request 'Plumb RoleSpec execution config into agent-native requests and fix leader parity' (#3) from feature/full-agent-r1 into main
ci / vet-and-test (push) Failing after 30s
ci / docker-checkout-hostgateway (push) Failing after 31s
2026-10-01 06:02:37 +00:00
8 changed files with 307 additions and 20 deletions
+47 -10
View File
@@ -1,6 +1,7 @@
package squad
import (
"context"
"fmt"
"sync"
"time"
@@ -71,6 +72,14 @@ type AgentNativeConfig struct {
SkillsRoots []string
Subagent subagent.Factory
Compact *native.CompactConfig
// HostTools 注册进内核工作区。与内置工具同名时该步骤失败,不覆盖。
HostTools []tools.Tool
// OnEvent 收到内核的逐步过程事件(文本、工具、用量)。与 Request.OnEvent 同时生效。
OnEvent func(ev exec.ProcessEvent)
// Stream 测试注入。为空时按 Client 访问模型。
Stream llm.StreamFunc
// MaxToolCalls 单步工具调用上限。0 表示不限制。
MaxToolCalls int
}
// buildAgentNativeOptions 按角色把配置换算为 native.Options。
@@ -85,15 +94,18 @@ func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Optio
return native.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role)
}
opts := native.Options{
Model: model,
MaxTurns: cfg.MaxTurns,
Timeout: cfg.Timeout,
Search: cfg.Search,
Fetch: cfg.Fetch,
MCP: cfg.MCP,
SkillsRoots: cfg.SkillsRoots,
Subagent: cfg.Subagent,
Compact: cfg.Compact,
Model: model,
MaxTurns: cfg.MaxTurns,
Timeout: cfg.Timeout,
Search: cfg.Search,
Fetch: cfg.Fetch,
MCP: cfg.MCP,
SkillsRoots: cfg.SkillsRoots,
Subagent: cfg.Subagent,
Compact: cfg.Compact,
HostTools: cfg.HostTools,
Stream: cfg.Stream,
MaxToolCalls: cfg.MaxToolCalls,
}
if cfg.Client != nil {
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
@@ -106,6 +118,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 +156,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
View File
@@ -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
View File
@@ -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()
+4
View File
@@ -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 视为关键)。
+76
View File
@@ -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
}
+130
View File
@@ -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
View File
@@ -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})