Add catalog-driven orchestration: intent catalog injection
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Failing after 2s

Details (original message):
Add catalog-driven orchestration: intent catalog injection, deterministic plan validation, and system-function steps.
This commit is contained in:
weichen
2026-09-29 16:37:46 +00:00
parent f3d000f6a6
commit b6ff0ab55f
25 changed files with 830 additions and 181 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
- 本库只做编排与任务拆解,不实现 Agent。智能体执行只来自 Agent-Native 或调用方注册的后端。
- 未知 backend fail-closed,禁止静默回落 stub。
- 意图闸门为可选能力:唯 `squadintent` 与组合根(cli 接线)可 import agent-intent;`squad`/`server`/`store` 核心不感知意图。未配置意图(无闸门或零意图)时引擎行为与原实现一致;闸门错误与守卫拒绝 fail-closed(`squad.intent_refused`)。
- 意图闸门与能力目录为可选能力:唯 `squadintent` 与组合根(cli 接线)可 import agent-intent;`squad`/`server`/`store` 核心不感知意图(目录以 `squad.IntentCatalog` 数据视图注入)。未配置意图(无闸门或零意图)时引擎行为与原实现一致;闸门错误、守卫拒绝与系统功能步的目录/校验/handler 缺失均 fail-closed(`squad.intent_refused` / 明确步骤错误)。
- 磁盘上的运行状态是恢复权威;HTTP 错误使用稳定规范码和 `X-Trace-Id`,不以自由文本作为唯一错误合同。
- 依赖仓版本以本仓 gitlink 为准。构建时只认本仓 `go.mod` 的 `replace`;嵌套子模块里的 `replace` 不参与本仓编译。
+19
View File
@@ -129,6 +129,8 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
fanout := squad.NewFanoutSink(8192)
fileSink := store.NewFileEventSink(dir, fanout)
var gate squad.IntentGate
var catalog *squad.IntentCatalog
var runner squad.IntentRunner
if intentRoot != "" {
g, err := squadintent.New(context.Background(), squadintent.Options{
IntentOptions: intent.Options{
@@ -142,10 +144,27 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
return nil, fmt.Errorf("意图配置加载失败(fail-closed):%w", err)
}
gate = g // g 可为 nil(来源为空),等价未启用
if g != nil {
catalog = g.Catalog()
// 系统功能步执行通道:默认 fail-closed(无 handler 拒绝);
// AGENT_SQUAD_INTENT_ECHO=1 时用演示执行器物化调用(开发/演示)。
if os.Getenv("AGENT_SQUAD_INTENT_ECHO") == "1" {
runner = g.EchoRunner()
} else {
runner = g.Runner(nil)
}
// 目录驱动的编排需要系统功能步角色存在(编制覆盖用,无后端语义)。
if _, ok := reg.Get(squad.DefaultIntentRole); !ok {
if err := reg.Register(squad.DefaultIntentRoleSpec()); err != nil {
return nil, err
}
}
}
}
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
Backends: backends, Sink: fileSink, IntentGate: gate,
IntentCatalog: catalog, IntentRunner: runner,
})
if err != nil {
return nil, err
@@ -0,0 +1,36 @@
# Agent-Squad 目录驱动的编排(能力目录 + 系统功能步)
对应设计:Agent-Intent 仓 `docs/records/2026-09-29-orchestration-capability-catalog.md`(G3–G5);依赖 agent-intent `3674dc4`(E2 槽位约束 + E4 目录)。
上一阶段(入口闸门)见 [2026-09-29-intent-gate-adoption.md](2026-09-29-intent-gate-adoption.md)。
## 形态
- `squad.IntentCatalog`(数据视图,squadintent 从识别快照渲染):`Markdown` 注入 LLM 规划器与 leader 提示词;`Specs` 用于计划确定性校验。核心包仍零 import agent-intent(`go list -deps ./squad ./server ./store` 0 命中)。
- `StepSpec.IntentID / IntentSlots`:系统功能步声明。LLM 规划器 schema 扩展 `intent_id/intent_slots` 并新增规则 8(命中目录输出意图步、必填槽位不足改 clarify 且用目录提问建议)。
- 计划校验 `validateIntentSteps`(fail-closed,同未知 backend 先例):无目录、意图不在目录、缺必填槽位(错误带澄清建议)、枚举不合法 → 整计划拒绝。
- 执行路由:`runOneStep` 中 IntentID 步不经 BackendResolver/智能体,由 `IntentRunner` 执行;Runner 只产出内容(patches),引擎统一物化到子工作区;Runner 缺席或 handler 未注册 fail-closed。
- `squadintent.Runner`:按 `spec.Handler` 分发宿主处理函数;`EchoRunner` 为演示执行器(物化调用文本,明确声明未真实执行)。
- cli:`--intent-root` 启用时自动注册 `intent` 角色与目录/Runner;`AGENT_SQUAD_INTENT_ECHO=1` 切换演示执行器,默认 fail-closed。
## 验证证据(2026-09-29,go vet/test/-race 全绿;既有 conformance 未改动即过)
| 层 | 用例 | 期望与结果 |
|---|---|---|
| 校验 | TestValidateIntentSteps(ok/无目录/未知意图/缺必填槽位/枚举违规) | 全部按预期拒绝或通过 ✅ |
| 执行 | TestIntentStepExecutedByRunnerNotBackend | 混合蓝图完成;Runner 收到 IntentID+Slots;产物落盘 ✅ |
| 执行 | TestIntentStepWithoutRunnerFailsClosed | 无 Runner → 步骤失败 ✅ |
| 校验 | TestIntentStepPlanValidationFailsClosed | 无目录 → 计划即拒(不到执行层) ✅ |
| 提示词 | TestLLMPlannerPromptIncludesCatalog / TestLeaderPromptIncludesCatalog | 目录与提问建议进入提示词;未配置时不出现 ✅ |
| 适配器 | TestGateCatalogMapsSpecsAndSlots / Runner 分发与 fail-closed / EchoRunner 物化 / nil Gate | 全通过 ✅ |
## E2E(真实 CLI,外挂意图含 `demo.run.status_query`:必填槽 run_id + 提问建议;蓝图 mixed_demo = 系统功能步 sys1 + agent 步 write1)
| 用例 | 配置 | 输入 | 实际结果 | 期望 | 判定 |
|---|---|---|---|---|---|
| F 混合步 | `--intent-root` + `AGENT_SQUAD_INTENT_ECHO=1` | `做一次混合演示` | `completed`,2 步;sys1 产物为系统功能调用物化(intent_id/demo.run.status_query、handler、slot run_id=run-42),write1 为 agent 后端产物 | 系统功能步不经智能体、agent 步照常 ✅ |
| G 无执行器 | `--intent-root`,无 ECHO | 同上 | `failed`:`intent runner: handler "taskrun.query_status" 未注册(fail-closed,不静默降级)` | fail-closed ✅ |
| 目录输出 | intentctl catalog | 同一意图目录 | 目录 Markdown 含能力、必填槽位、提问建议(守卫 spec 不出现) | 供规划者/领导提示词注入 ✅ |
## 回退程序
在上一阶段清单之上追加:删 `squadintent/catalog.go`、`squad/intent.go` 的目录/Runner 段、models 的 IntentID/IntentSlots、planner_llm 的 schema 规则与提示词段、leader 的目录参数、workflow 的意图路由块、cli 的目录/Runner 接线。核心流程恢复为纯 agent 步。
+6
View File
@@ -46,3 +46,9 @@
- 依赖边界新增:唯 `squadintent` 与 cli 组合根可 import agent-intent;`squad`/`server`/`store` 不感知意图;未配置意图时引擎行为与原实现一致。
- 接入与验证记录见 [docs/records/2026-09-29-intent-gate-adoption.md](../docs/records/2026-09-29-intent-gate-adoption.md);跨仓测试报告见 Agent-Intent 仓 docs/records。
- 回退:删 `squadintent/` 与 cli 接线、go.mod 两行、submodule 与 `.gitmodules` 条目、一致性脚本对应段;可将 agent-native 回退到 `cc8a6a7`(其 intentgate 为可选包,不回退亦可编译)。
### 2026-09-29 追加(二):目录驱动的编排
- `deps/agent-intent` 前进到 `3674dc4bfd0b49191d21f0afab4002381e41a429`(槽位约束与能力目录,E2/E4);`deps/agent-native` 前进到 `292142ac1a8b125488794e9374faee2cb1c8c579`(pin 同步)。
- 新增能力目录(`squad.IntentCatalog` 数据视图)与系统功能步(`StepSpec.IntentID` + `IntentRunner`);核心包仍零 import agent-intent。
- 接入与验证记录见 [docs/records/2026-09-29-catalog-driven-orchestration.md](../docs/records/2026-09-29-catalog-driven-orchestration.md)。
+14
View File
@@ -37,6 +37,12 @@ type Options struct {
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
IntentGate IntentGate
// IntentCatalog 可选能力目录(squadintent 渲染):注入 PlanInput 与 leader 提示词,
// 并用于 intent 步骤的确定性校验。nil = 未启用目录驱动编排。
IntentCatalog *IntentCatalog
// IntentRunner 系统功能步执行器(squadintent 提供)。目录启用而 Runner 缺席时,
// intent 步骤 fail-closed。
IntentRunner IntentRunner
}
// engineDeps 驱动器依赖(由引擎从 Options 派生)。
@@ -49,6 +55,7 @@ type engineDeps struct {
Workspaces *WorkspaceEngine
StepTimeout time.Duration
HitlPolicy HitlPolicy
IntentRunner IntentRunner
}
// RunOptions 单次运行的可选项。
@@ -120,6 +127,7 @@ func NewEngine(opts Options) (*SquadEngine, error) {
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
IntentRunner: opts.IntentRunner,
}
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
}
@@ -212,10 +220,16 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
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 {
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: meta.ID, Text: err.Error(), At: time.Now()})
return e.failNew(meta, err)
}
// 规划歧义:挂起澄清(auto/disabled 模式直接失败,不猜计划)。
if planOut.Clarification != nil {
if drv.policy(st).mode() != HitlInteractive {
+121 -1
View File
@@ -1,6 +1,11 @@
package squad
import "context"
import (
"context"
"fmt"
"agent-native/exec"
)
// IntentRejection 守卫拦截:拒绝该次运行并给出引导("识别即拒绝"语义)。
type IntentRejection struct {
@@ -29,3 +34,118 @@ type IntentDecision struct {
type IntentGate interface {
Decide(ctx context.Context, goal Goal, opts RunOptions) (*IntentDecision, error)
}
// ---- 能力目录(目录驱动的编排)----
// IntentSlotView 目录中的槽位视图:规划者/领导据此知道缺什么信息、建议怎么问。
type IntentSlotView struct {
Key string `json:"key"`
Required bool `json:"required,omitempty"`
Label string `json:"label,omitempty"`
Question string `json:"question,omitempty"`
Enum []string `json:"enum,omitempty"`
Format string `json:"format,omitempty"`
}
// IntentSpecView 目录中的单条可调用能力。
type IntentSpecView struct {
ID string `json:"id"`
Description string `json:"description,omitempty"`
Risk string `json:"risk,omitempty"`
RequiresConfirm bool `json:"requires_confirm,omitempty"`
Handler string `json:"handler,omitempty"`
Slots []IntentSlotView `json:"slots,omitempty"`
RequiredSlots []string `json:"required_slots,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// IntentCatalog 意图能力目录:squadintent 从识别快照渲染的数据视图,
// squad 只消费不构造(不 import agent-intent)。Markdown 注入规划者/领导提示词;
// Specs 用于计划的确定性校验(意图存在、必填槽位齐、枚举合法)。
type IntentCatalog struct {
Revision int64
Markdown string
Specs map[string]IntentSpecView
}
// intentCatalogMd nil 安全的目录文本(未启用时为空串)。
func (c *IntentCatalog) intentCatalogMd() string {
if c == nil {
return ""
}
return c.Markdown
}
// Get 按 id 查目录能力。
func (c *IntentCatalog) Get(id string) (IntentSpecView, bool) {
if c == nil {
return IntentSpecView{}, false
}
v, ok := c.Specs[id]
return v, ok
}
// IntentStep 系统功能步:不经 BackendResolver/智能体,由宿主 IntentRunner 直接执行。
type IntentStep struct {
IntentID string
Slots map[string]string
Title string
WorkDir string
Targets []string
}
// IntentRunner 系统功能执行器(squadintent 提供实现:查 spec.Handler 并调宿主处理函数)。
type IntentRunner interface {
Run(ctx context.Context, step IntentStep) (*exec.Result, error)
}
// DefaultIntentRole 系统功能步的默认角色 key(无后端语义,仅满足编制覆盖)。
const DefaultIntentRole = "intent"
// DefaultIntentRoleSpec 返回系统功能步的默认角色(组合根在启用意图目录时注册)。
func DefaultIntentRoleSpec() RoleSpec {
return RoleSpec{Role: DefaultIntentRole, Name: "系统能力", Backend: "stub", Keywords: []string{"系统功能"}}
}
// validateIntentSteps 计划落盘前的确定性校验:意图步必须命中目录、必填槽位齐、枚举合法。
// fail-closed:任一不通过即整计划拒绝(与未知 backend 拒绝同构)。
func validateIntentSteps(plan *SquadPlan, catalog *IntentCatalog) error {
if plan == nil {
return nil
}
for _, step := range plan.Workflow.Steps {
if step.IntentID == "" {
continue
}
if catalog == nil {
return fmt.Errorf("intent 步骤 %s:引擎未装配意图目录(IntentCatalog),无法执行系统功能步", step.ID)
}
view, ok := catalog.Get(step.IntentID)
if !ok {
return fmt.Errorf("intent 步骤 %s:目录中不存在意图 %s", step.ID, step.IntentID)
}
for _, sl := range view.Slots {
v, given := step.IntentSlots[sl.Key]
if sl.Required && (!given || v == "") {
q := sl.Question
if q == "" {
q = "请提供 " + sl.Key
}
return fmt.Errorf("intent 步骤 %s:意图 %s 缺必填槽位 %s(澄清建议:%s)", step.ID, step.IntentID, sl.Key, q)
}
if given && len(sl.Enum) > 0 && !containsString(sl.Enum, v) {
return fmt.Errorf("intent 步骤 %s:槽位 %s 的值 %q 不在枚举 %v 内", step.ID, sl.Key, v, sl.Enum)
}
}
}
return nil
}
func containsString(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
+171
View File
@@ -0,0 +1,171 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
)
type stubRunner struct {
calls []IntentStep
text string
err error
}
func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, error) {
r.calls = append(r.calls, step)
if r.err != nil {
return nil, r.err
}
patches := map[string][]byte{}
for _, t := range step.Targets {
patches[t] = []byte(r.text)
}
return &exec.Result{Success: true, Patches: patches, Logs: "stub-intent"}, nil
}
func testCatalog() *IntentCatalog {
return &IntentCatalog{
Revision: 1,
Markdown: "## demo.run.status_query — 查询运行进度\n- 槽位 run_id(必填)\n - 提问建议:要查询哪个运行?\n",
Specs: map[string]IntentSpecView{
"demo.run.status_query": {
ID: "demo.run.status_query", Description: "查询运行进度", Handler: "taskrun.query_status",
Slots: []IntentSlotView{
{Key: "run_id", Required: true, Label: "运行编号", Question: "要查询哪个运行?"},
{Key: "env", Enum: []string{"prod", "staging"}},
},
RequiredSlots: []string{"run_id"},
},
},
}
}
func TestValidateIntentSteps(t *testing.T) {
step := func(id, intent string, slots map[string]string) SquadPlan {
return SquadPlan{Workflow: WorkflowPlan{Steps: []StepSpec{{ID: id, IntentID: intent, IntentSlots: slots}}}}
}
cases := []struct {
name string
plan SquadPlan
catalog *IntentCatalog
wantErr string
}{
{"ok", step("s1", "demo.run.status_query", map[string]string{"run_id": "run-1"}), testCatalog(), ""},
{"no_catalog", step("s1", "demo.run.status_query", nil), nil, "未装配意图目录"},
{"unknown_intent", step("s1", "demo.nope.x", nil), testCatalog(), "不存在意图"},
{"missing_required", step("s1", "demo.run.status_query", nil), testCatalog(), "缺必填槽位 run_id(澄清建议:要查询哪个运行?)"},
{"enum_violation", step("s1", "demo.run.status_query", map[string]string{"run_id": "run-1", "env": "dev"}), testCatalog(), "不在枚举"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateIntentSteps(&tc.plan, tc.catalog)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
})
}
}
func newCatalogTestEngine(t *testing.T, runner IntentRunner, catalog *IntentCatalog) *SquadEngine {
t.Helper()
engine, _ := newTestEngine(t, NewRulePlanner(),
map[string]map[string]string{"02_writer": {"output.md": goodReport}}, nil)
if err := engine.Blueprints().Register(Blueprint{
Key: "mixed_demo", Title: "混合演示", Match: []string{"混合演示"},
Steps: []StepSpec{
{ID: "sys1", Role: "researcher", Title: "查询运行进度",
IntentID: "demo.run.status_query", IntentSlots: map[string]string{"run_id": "run-42"}},
{ID: "write1", Role: "writer", Title: "汇总成文", Targets: []string{"output.md"}},
},
}); err != nil {
t.Fatal(err)
}
// 产品侧经 Options 在 NewEngine 传入;测试在构造后注入,需同时更新
// engineDeps(步骤执行走 deps 副本,构建期已定型)。
engine.opts.IntentCatalog = catalog
engine.opts.IntentRunner = runner
engine.deps.IntentRunner = runner
return engine
}
func TestIntentStepExecutedByRunnerNotBackend(t *testing.T) {
runner := &stubRunner{text: "# 查询结果\n\nrun-42 进度 100%,内容满足最小质检长度要求,作为系统功能步产物。\n"}
engine := newCatalogTestEngine(t, runner, testCatalog())
res, err := engine.Run(context.Background(), Goal{Text: "做一次混合演示", HitlMode: HitlAuto}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("expected completed, got %s (err=%s)", res.Status, res.Error)
}
if len(runner.calls) != 1 || runner.calls[0].IntentID != "demo.run.status_query" || runner.calls[0].Slots["run_id"] != "run-42" {
t.Fatalf("runner calls: %+v", runner.calls)
}
// 系统功能步产物落盘。
sysOut := filepath.Join(res.WorkDir, "tasks", "01_researcher", "output.md")
if data, err := os.ReadFile(sysOut); err != nil || !strings.Contains(string(data), "查询结果") {
t.Fatalf("intent step deliverable missing: %v", err)
}
}
func TestIntentStepWithoutRunnerFailsClosed(t *testing.T) {
engine := newCatalogTestEngine(t, nil, testCatalog())
res, err := engine.Run(context.Background(), Goal{Text: "做一次混合演示", HitlMode: HitlAuto}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "IntentRunner") {
t.Fatalf("expected fail-closed runner error, got %+v", res)
}
}
func TestIntentStepPlanValidationFailsClosed(t *testing.T) {
// 目录缺席时,含 intent 步的蓝图在计划校验即被拒(不到执行层)。
engine := newCatalogTestEngine(t, &stubRunner{text: "x"}, nil)
res, err := engine.Run(context.Background(), Goal{Text: "做一次混合演示", HitlMode: HitlAuto}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "未装配意图目录") {
t.Fatalf("expected validation failure, got %+v", res)
}
}
func TestLLMPlannerPromptIncludesCatalog(t *testing.T) {
p := NewLLMPlanner(nil, "m")
input := PlanInput{
Goal: Goal{Text: "目标"},
Registry: NewRoleRegistry(),
IntentCatalog: testCatalog(),
}
got := p.buildPrompt(input)
for _, want := range []string{"系统能力目录", "demo.run.status_query", "提问建议:要查询哪个运行"} {
if !strings.Contains(got, want) {
t.Fatalf("prompt missing %q", want)
}
}
if strings.Contains(NewLLMPlanner(nil, "m").buildPrompt(PlanInput{Goal: Goal{Text: "g"}, Registry: NewRoleRegistry()}), "系统能力目录") {
t.Fatal("catalog must be absent when not configured")
}
}
func TestLeaderPromptIncludesCatalog(t *testing.T) {
md := leaderPrompt(nil, testCatalog().Markdown)
if !strings.Contains(md, "系统能力目录") || !strings.Contains(md, "demo.run.status_query") {
t.Fatalf("leader prompt missing catalog:\n%s", md)
}
if strings.Contains(leaderPrompt(nil, ""), "系统能力目录") {
t.Fatal("empty catalog must not add section")
}
}
+7 -3
View File
@@ -83,7 +83,7 @@ func (st *runState) saveLeader() {
}
// leaderPrompt 组装 leader 的系统指令(协调契约 + 通用守则)。
func leaderPrompt(members []RoleSpec) string {
func leaderPrompt(members []RoleSpec, catalogMd string) string {
var b strings.Builder
b.WriteString(`你是本小队的 LEADER。你被激活的唯一职责是协调,不是自己实现。
@@ -108,6 +108,10 @@ func leaderPrompt(members []RoleSpec) string {
for _, m := range members {
fmt.Fprintf(&b, "- %s — %s — skills=%v — %v\n", m.Role, m.Name, m.Skills, m.Responsibilities)
}
if catalogMd != "" {
b.WriteString("\n系统能力目录(供你判断哪些子任务不必派成员、以及该向用户收集什么信息;缺失必填信息时 escalate 并使用提问建议):\n")
b.WriteString(catalogMd)
}
return b.String()
}
@@ -213,7 +217,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st)), 0o644); err != nil {
return nil, nil, err
}
if err := os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(leaderPrompt(members)), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd())), 0o644); err != nil {
return nil, nil, err
}
@@ -228,7 +232,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
}
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st))
prompt = st.injectSteering(prompt)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members))
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd()))
result, execErr := backend.Execute(ctx, execRequest(subDir, prompt, instructions, []string{"delegation.json"}, e.deps.StepTimeout))
if execErr != nil || result == nil || !result.Success {
reason := "leader execution failed"
+5
View File
@@ -87,6 +87,11 @@ type StepSpec struct {
Error string `json:"error,omitempty"`
// RequiresApproval 为 true 时,该步骤启动前进入 HITL 计划确认。
RequiresApproval bool `json:"requires_approval,omitempty"`
// IntentID 非空 = 系统功能步:不经 BackendResolver/智能体,由 IntentRunner 直接执行
//(计划确定性校验:意图须在目录内且必填槽位齐)。
IntentID string `json:"intent_id,omitempty"`
// IntentSlots 系统功能步的槽位取值(规划者按目录填写)。
IntentSlots map[string]string `json:"intent_slots,omitempty"`
// AllowDynamicAdjustment 允许评估器在步骤后做动态调整(加步/跳步/改步)。
AllowDynamicAdjustment bool `json:"allow_dynamic_adjustment,omitempty"`
// Critical: nil(默认)= 关键步骤失败即终止;false = 非关键,失败降级继续。
+3
View File
@@ -19,6 +19,9 @@ type PlanInput struct {
Blueprint *Blueprint // 命中模板时非 nil(基线步骤,规划器可微调)
Registry *RoleRegistry
RunID string
// IntentCatalog 可选能力目录:启用意图编排时非 nil。规划者应把命中目录的
// 子任务输出为 intent_id 步骤(必填槽位不足时改输出澄清,用目录的提问建议)。
IntentCatalog *IntentCatalog
}
// PlanOutput 规划输出:计划或澄清请求二选一。
+10 -1
View File
@@ -61,6 +61,8 @@ type llmStep struct {
Parallel bool `json:"parallel,omitempty"`
Targets []string `json:"targets,omitempty"`
RequiresApproval bool `json:"requires_approval,omitempty"`
IntentID string `json:"intent_id,omitempty"`
IntentSlots map[string]string `json:"intent_slots,omitempty"`
}
const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标、已注册角色清单和可选的任务模板,输出一个可执行的小队计划。
@@ -71,7 +73,8 @@ const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标
4. agents: 优先复用已注册角色({"action":"use","role":"已注册key"});确无合适角色才派生({"action":"derive","role":"新key","base_role":"基线key","name":"中文名","domain_topic":"领域","responsibilities":[...]})。派生数量尽量少。
5. steps: 每步 {"id":"step_01_x","role":"角色key","title":"步骤职责"};可并行的相邻步骤 parallel=true;高风险/破坏性/对外发布类步骤 requires_approval=true;步骤通常 2~6 个,含梳理、专业执行、汇总成文、终审把关。
6. 涉及写入、删除、对外提交或超出常规预算的计划 confirm=true。
7. 通用守则:缺失的信息若会实质改变结果、执行路径、权限或安全,必须 clarify 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。`
7. 通用守则:缺失的信息若会实质改变结果、执行路径、权限或安全,必须 clarify 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。
8. 目标下方若给出"系统能力目录":子任务命中目录能力时,该步输出 {"role":"intent","intent_id":"目录id","intent_slots":{...},"title":"..."},槽位必须按目录填齐;必填槽位信息不足时禁止编造,改为 clarify 并使用目录的提问建议。未命中目录的子任务照常派给角色。没有目录时不得输出 intent_id 步骤。`
func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
if p.Client == nil || p.Model == "" {
@@ -175,6 +178,7 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
steps = append(steps, StepSpec{
ID: id, Role: ls.Role, Title: ls.Title, Parallel: ls.Parallel,
Targets: targets, RequiresApproval: ls.RequiresApproval, Status: "pending",
IntentID: ls.IntentID, IntentSlots: ls.IntentSlots,
})
}
@@ -219,6 +223,11 @@ func (p *LLMPlanner) buildPrompt(input PlanInput) string {
b.Write(enc)
b.WriteString("\n")
}
if input.IntentCatalog != nil && input.IntentCatalog.Markdown != "" {
b.WriteString("\n## 系统能力目录(可直接调用,不必派智能体)\n")
b.WriteString(input.IntentCatalog.Markdown)
b.WriteString("\n")
}
return b.String()
}
+28 -3
View File
@@ -187,18 +187,26 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
prompt = st.injectSteering(prompt)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role))
backend, backendName, err := d.opts.Backends.Resolve(role, st.Meta.Backend)
if err != nil {
step.Status, step.Error = "failed", err.Error()
var backend exec.Backend
backendName := ""
if step.IntentID != "" {
backendName = "intent:" + step.IntentID
} else {
var rerr error
backend, backendName, rerr = d.opts.Backends.Resolve(role, st.Meta.Backend)
if rerr != nil {
step.Status, step.Error = "failed", rerr.Error()
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
}
targets := step.Targets
if len(targets) == 0 {
targets = []string{"output.md"}
}
var result *exec.Result
var err error
for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ {
reqPrompt := prompt
if attempt > 1 {
@@ -222,7 +230,24 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})})
},
}
if step.IntentID != "" {
// 系统功能步:不经智能体,由 IntentRunner 直接执行;缺席即 fail-closed。
// Runner 只产出内容(patches),由引擎统一物化到子工作区。
if d.opts.IntentRunner == nil {
step.Status, step.Error = "failed", fmt.Sprintf("intent step %s: no IntentRunner configured", step.ID)
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
result, err = d.opts.IntentRunner.Run(ctx, IntentStep{
IntentID: step.IntentID, Slots: step.IntentSlots,
Title: step.Title, WorkDir: subDir, Targets: targets,
})
if err == nil && result != nil && len(result.Patches) > 0 {
_, _ = exec.MaterializeTargets(subDir, targets, result.Patches)
}
} else {
result, err = backend.Execute(ctx, req)
}
if err == nil && result.Success {
break
}
+115
View File
@@ -0,0 +1,115 @@
package squadintent
import (
"context"
"fmt"
"sort"
"strings"
"agent-native/exec"
"agent-squad/squad"
)
// Catalog 把识别快照渲染为 squad 的能力目录数据视图(规划者提示词 + 计划校验用)。
// 闸门未启用(nil Gate)时返回 nil。
func (g *Gate) Catalog() *squad.IntentCatalog {
if g == nil || g.snap == nil {
return nil
}
catalog := &squad.IntentCatalog{
Revision: g.snap.Revision(),
Markdown: g.snap.CatalogMarkdown(),
Specs: make(map[string]squad.IntentSpecView, len(g.snap.SpecIDs())),
}
for _, cs := range g.snap.Catalog() {
view := squad.IntentSpecView{
ID: cs.ID,
Description: cs.Description,
Risk: cs.Risk,
RequiresConfirm: cs.RequiresConfirm,
Handler: cs.Handler,
Metadata: cs.Metadata,
}
for _, sl := range cs.Slots {
view.Slots = append(view.Slots, squad.IntentSlotView{
Key: sl.Key, Required: sl.Required, Label: sl.Label,
Question: sl.Question, Enum: sl.Enum, Format: sl.Format,
})
if sl.Required {
view.RequiredSlots = append(view.RequiredSlots, sl.Key)
}
}
catalog.Specs[cs.ID] = view
}
return catalog
}
// IntentHandler 宿主注册的系统功能处理函数:返回写入步骤 targets 的正文。
type IntentHandler func(ctx context.Context, step squad.IntentStep) (string, error)
// Runner 系统功能执行器:按 spec.Handler 查宿主处理函数并调用;
// echo=true 时未注册的 handler 以物化文本回退(开发/演示用,正文明确声明未真实执行)。
type Runner struct {
gate *Gate
handlers map[string]IntentHandler
echo bool
}
// Runner 基于闸门快照构建执行器。handlers 以 spec.Handler 为键。
func (g *Gate) Runner(handlers map[string]IntentHandler) *Runner {
return &Runner{gate: g, handlers: handlers}
}
// EchoRunner 演示执行器:所有调用物化为文本(AGENT_SQUAD_INTENT_ECHO 场景)。
func (g *Gate) EchoRunner() *Runner {
return &Runner{gate: g, echo: true}
}
// Run 实现 squad.IntentRunner。未知意图/未知 handler fail-closed(不静默降级)。
func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result, error) {
if r == nil || r.gate == nil || r.gate.snap == nil {
return nil, fmt.Errorf("intent runner: gate not configured")
}
spec, ok := r.gate.snap.SpecByID(step.IntentID)
if !ok {
return nil, fmt.Errorf("intent runner: snapshot 中不存在意图 %s", step.IntentID)
}
handlerKey := spec.Handler
h, registered := r.handlers[handlerKey]
if !registered {
if !r.echo {
return nil, fmt.Errorf("intent runner: handler %q 未注册(fail-closed,不静默降级)", handlerKey)
}
text := echoText(step, handlerKey, false)
return textResult(step, text), nil
}
text, err := h(ctx, step)
if err != nil {
return nil, err
}
return textResult(step, text), nil
}
func textResult(step squad.IntentStep, text string) *exec.Result {
patches := make(map[string][]byte, len(step.Targets))
for _, t := range step.Targets {
patches[t] = []byte(text)
}
return &exec.Result{Success: true, Patches: patches, Logs: "intent:" + step.IntentID}
}
func echoText(step squad.IntentStep, handlerKey string, _ bool) string {
var b strings.Builder
b.WriteString("# 系统功能调用(EchoRunner 物化,未真实执行)\n\n")
fmt.Fprintf(&b, "- intent_id: %s\n- handler: %s\n- title: %s\n", step.IntentID, handlerKey, step.Title)
keys := make([]string, 0, len(step.Slots))
for k := range step.Slots {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&b, "- slot %s = %s\n", k, step.Slots[k])
}
b.WriteString("\n宿主应用注册真实 handler 后,此文件将是系统功能的真实输出。\n")
return b.String()
}
+122
View File
@@ -0,0 +1,122 @@
package squadintent
import (
"context"
"strings"
"testing"
"testing/fstest"
"agent-intent/intent"
"agent-squad/squad"
)
// catalogFS:带槽位(label/question/enum)的可调用能力 + 守卫。
func catalogFS() fstest.MapFS {
return fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{
"intents": [
{"id": "guard.config_change", "domain": "guard", "version": 1,
"matcher": {"includes_any": ["系统配置"]}},
{"id": "demo.run.status_query", "domain": "demo", "version": 1,
"description": "查询运行进度",
"matcher": {"includes_any": ["进度"]},
"handler": "taskrun.query_status",
"slots": [
{"key": "run_id", "required": true, "pattern": "(run-\\d+)",
"label": "运行编号", "question": "要查询哪个运行?请提供 run 编号"}
]}
]
}`)}}
}
func newCatalogGate(t *testing.T) *Gate {
t.Helper()
g, err := New(context.Background(), Options{
IntentOptions: intent.Options{GuardDomains: []string{"guard"}, ProtectedDomains: []string{"guard"}},
FS: catalogFS(),
Roots: []string{"."},
})
if err != nil {
t.Fatal(err)
}
return g
}
func TestGateCatalogMapsSpecsAndSlots(t *testing.T) {
g := newCatalogGate(t)
c := g.Catalog()
if c == nil || len(c.Specs) != 1 {
t.Fatalf("catalog specs: %+v", c)
}
view, ok := c.Get("demo.run.status_query")
if !ok || view.Handler != "taskrun.query_status" || len(view.RequiredSlots) != 1 || view.RequiredSlots[0] != "run_id" {
t.Fatalf("view: %+v", view)
}
if len(view.Slots) != 1 || view.Slots[0].Question == "" || view.Slots[0].Label != "运行编号" {
t.Fatalf("slot view: %+v", view.Slots)
}
if _, ok := c.Get("guard.config_change"); ok {
t.Fatal("guard spec must not appear in catalog")
}
if !strings.Contains(c.Markdown, "提问建议:要查询哪个运行") {
t.Fatalf("markdown missing question: %s", c.Markdown)
}
}
func TestRunnerDispatchesToHandlerAndFailsClosed(t *testing.T) {
g := newCatalogGate(t)
var got squad.IntentStep
runner := g.Runner(map[string]IntentHandler{
"taskrun.query_status": func(ctx context.Context, step squad.IntentStep) (string, error) {
got = step
return "# 进度\n\nrun-42 已完成,正文满足最小长度要求用于质检演示。\n", nil
},
})
res, err := runner.Run(context.Background(), squad.IntentStep{
IntentID: "demo.run.status_query", Slots: map[string]string{"run_id": "run-42"},
Targets: []string{"output.md"},
})
if err != nil || res == nil || !res.Success {
t.Fatalf("run: %+v %v", res, err)
}
if got.Slots["run_id"] != "run-42" || string(res.Patches["output.md"]) == "" {
t.Fatalf("handler dispatch: %+v", got)
}
// 未知意图与未注册 handler:fail-closed(无 handler 的 runner 拒绝执行)。
if _, err := runner.Run(context.Background(), squad.IntentStep{IntentID: "demo.nope"}); err == nil {
t.Fatal("expected unknown intent error")
}
bare := g.Runner(nil)
if _, err := bare.Run(context.Background(), squad.IntentStep{IntentID: "demo.run.status_query", Slots: map[string]string{"run_id": "run-1"}}); err == nil {
t.Fatal("expected unregistered handler error")
} else if !strings.Contains(err.Error(), "fail-closed") {
t.Fatalf("error: %v", err)
}
}
func TestEchoRunnerMaterializesCall(t *testing.T) {
g := newCatalogGate(t)
res, err := g.EchoRunner().Run(context.Background(), squad.IntentStep{
IntentID: "demo.run.status_query", Slots: map[string]string{"run_id": "run-7"},
Targets: []string{"output.md"}, Title: "查询进度",
})
if err != nil || !res.Success {
t.Fatalf("echo run: %+v %v", res, err)
}
text := string(res.Patches["output.md"])
for _, want := range []string{"EchoRunner", "demo.run.status_query", "run-7", "未真实执行"} {
if !strings.Contains(text, want) {
t.Fatalf("echo text missing %q: %s", want, text)
}
}
}
func TestNilGateCatalogAndRunner(t *testing.T) {
var g *Gate
if g.Catalog() != nil {
t.Fatal("nil gate catalog must be nil")
}
if _, err := g.EchoRunner().Run(context.Background(), squad.IntentStep{IntentID: "x"}); err == nil {
t.Fatal("nil gate runner must fail-closed")
}
}