diff --git a/AGENTS.md b/AGENTS.md index 9129859..4cb3644 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 不参与本仓编译。 diff --git a/cli/cli.go b/cli/cli.go index 25a9e06..2044598 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -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 diff --git a/cli/demo.go b/cli/demo.go index abf1bb2..bce410c 100644 --- a/cli/demo.go +++ b/cli/demo.go @@ -79,7 +79,7 @@ func cmdDemo(args []string) int { backends := squad.NewBackendResolver() demoScript := map[string]map[string]string{ "01_researcher": { - "notes.md": "# 调研笔记\n\n- 目标产品:便携式超声诊断仪\n- 竞品:A/B/C 三款\n- 法规路径:二类器械,注册检验 + 临床评价\n", + "notes.md": "# 调研笔记\n\n- 目标产品:便携式超声诊断仪\n- 竞品:A/B/C 三款\n- 法规路径:二类器械,注册检验 + 临床评价\n", "fields.json": `{"target_market":"中国","device_class":"二类","budget":"15万"}`, "gaps.json": `[{"item":"目标年产量未提供,影响成本测算","blocking":true}]`, }, @@ -87,7 +87,7 @@ func cmdDemo(args []string) int { "output.md": "# 便携式超声诊断仪调研报告\n\n## 概述与背景\n\n本报告基于输入材料与调研产出,梳理便携式超声诊断仪的市场与技术背景。目标市场为中国(二类器械),预算 15 万元。\n\n## 核心内容\n\n竞品 A/B/C 在成像质量与便携性上各有取舍;技术路线建议探头一体化设计与电池供电优化。注册路径按二类器械执行注册检验与临床评价。\n\n## 风险与注意事项\n\n- 供应链对探头晶片依赖较高\n- 注册周期受检验排队影响\n\n## 基准假设与待确认清单\n\n- 年产量按人工澄清结论执行\n", }, "03_reviewer": { - "output.md": "# 便携式超声诊断仪调研报告(终审版)\n\n## 概述与背景\n\n本报告由小队协作产出:调研专员梳理事实与缺口,撰写专员成文,审核专员终审。目标市场为中国(二类器械),预算 15 万元,年产量 10 万套。\n\n## 核心内容\n\n竞品 A/B/C 在成像质量与便携性上各有取舍;技术路线建议探头一体化设计与电池供电优化。注册路径按二类器械执行注册检验与临床评价。成本测算基于年产量 10 万套摊薄产线投入。\n\n## 风险与注意事项\n\n- 供应链对探头晶片依赖较高\n- 注册周期受检验排队影响\n- 年产量波动超过 ±30% 时需重估成本模型\n\n## 基准假设与待确认清单\n\n- 灭菌方式采用环氧乙烷(EO)灭菌(行业常规)\n", + "output.md": "# 便携式超声诊断仪调研报告(终审版)\n\n## 概述与背景\n\n本报告由小队协作产出:调研专员梳理事实与缺口,撰写专员成文,审核专员终审。目标市场为中国(二类器械),预算 15 万元,年产量 10 万套。\n\n## 核心内容\n\n竞品 A/B/C 在成像质量与便携性上各有取舍;技术路线建议探头一体化设计与电池供电优化。注册路径按二类器械执行注册检验与临床评价。成本测算基于年产量 10 万套摊薄产线投入。\n\n## 风险与注意事项\n\n- 供应链对探头晶片依赖较高\n- 注册周期受检验排队影响\n- 年产量波动超过 ±30% 时需重估成本模型\n\n## 基准假设与待确认清单\n\n- 灭菌方式采用环氧乙烷(EO)灭菌(行业常规)\n", "fields.json": `{"annual_volume":"100000","review":"passed"}`, }, } diff --git a/deps/agent-intent b/deps/agent-intent index 55780ae..3674dc4 160000 --- a/deps/agent-intent +++ b/deps/agent-intent @@ -1 +1 @@ -Subproject commit 55780ae2cce4ae98d8b0557b8234af8833a7c522 +Subproject commit 3674dc4bfd0b49191d21f0afab4002381e41a429 diff --git a/deps/agent-native b/deps/agent-native index c659dc1..292142a 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit c659dc17d5086a8128999011994fc2dbe73419d3 +Subproject commit 292142ac1a8b125488794e9374faee2cb1c8c579 diff --git a/docs/records/2026-09-29-catalog-driven-orchestration.md b/docs/records/2026-09-29-catalog-driven-orchestration.md new file mode 100644 index 0000000..8207c7a --- /dev/null +++ b/docs/records/2026-09-29-catalog-driven-orchestration.md @@ -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 步。 diff --git a/policy/guidelines-adoption.md b/policy/guidelines-adoption.md index 4502b42..599374a 100644 --- a/policy/guidelines-adoption.md +++ b/policy/guidelines-adoption.md @@ -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)。 diff --git a/squad/blueprint.go b/squad/blueprint.go index 04d7fda..7d12be8 100644 --- a/squad/blueprint.go +++ b/squad/blueprint.go @@ -10,8 +10,8 @@ import ( // BlueprintRegistry 任务模板注册中心:空表起步。 // 命中模板的 Goal 走基线工作流,未命中走规划器自主拆解。 type BlueprintRegistry struct { - mu sync.RWMutex - bps map[string]Blueprint + mu sync.RWMutex + bps map[string]Blueprint // OnChange 可选钩子:注册/删除后回调(store 层用于落盘)。 OnChange func(bp Blueprint) } diff --git a/squad/engine.go b/squad/engine.go index c47cd86..2269dbc 100644 --- a/squad/engine.go +++ b/squad/engine.go @@ -37,18 +37,25 @@ 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 派生)。 type engineDeps struct { - Registry *RoleRegistry - Backends *BackendResolver - Evaluator StepEvaluator - Checker Checker - Sink ProgressSink - Workspaces *WorkspaceEngine - StepTimeout time.Duration - HitlPolicy HitlPolicy + Registry *RoleRegistry + Backends *BackendResolver + Evaluator StepEvaluator + Checker Checker + Sink ProgressSink + 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 { diff --git a/squad/engine_test.go b/squad/engine_test.go index 23de5cb..0952f21 100644 --- a/squad/engine_test.go +++ b/squad/engine_test.go @@ -125,9 +125,9 @@ func TestEngineHappyPath(t *testing.T) { func TestEngineGapHitlSuspendRespondAndRestartRecovery(t *testing.T) { script := map[string]map[string]string{ "01_researcher": { - "notes.md": "# 笔记\n内容", + "notes.md": "# 笔记\n内容", "fields.json": `{"k":"v"}`, - "gaps.json": `[{"item":"缺少产量","blocking":true}]`, + "gaps.json": `[{"item":"缺少产量","blocking":true}]`, }, "02_writer": {"output.md": goodReport}, "03_reviewer": {"output.md": goodReport}, @@ -297,9 +297,9 @@ func TestEngineQualityReviewManualPass(t *testing.T) { func TestEngineAutoModeGapFallbackAssumption(t *testing.T) { script := map[string]map[string]string{ "01_researcher": { - "notes.md": "# 笔记\n内容", + "notes.md": "# 笔记\n内容", "fields.json": `{"k":"v"}`, - "gaps.json": `[{"item":"缺少产量","blocking":true}]`, + "gaps.json": `[{"item":"缺少产量","blocking":true}]`, }, "02_writer": {"output.md": goodReport}, "03_reviewer": {"output.md": goodReport}, diff --git a/squad/evaluator.go b/squad/evaluator.go index 1913fed..64037b5 100644 --- a/squad/evaluator.go +++ b/squad/evaluator.go @@ -12,22 +12,22 @@ type StepEvaluator interface { // EvaluateRequest 评估输入。 type EvaluateRequest struct { - RunID string - Step StepSpec - Workspace string // 运行工作区根目录 - Output string // 步骤主产出文本 + RunID string + Step StepSpec + Workspace string // 运行工作区根目录 + Output string // 步骤主产出文本 Blackboard *BlackboardState } // EvaluationResult 评估结论与动态调整建议(与产品 squad 语义对齐)。 type EvaluationResult struct { - StepID string `json:"step_id"` - Action StepAction `json:"action"` - Reason string `json:"reason"` - NewAssumptions []AssumptionItem `json:"new_assumptions,omitempty"` - ResolvedGaps []string `json:"resolved_gaps,omitempty"` - DynamicStepsToAdd []StepSpec `json:"dynamic_steps_to_add,omitempty"` - StepsToSkip []string `json:"steps_to_skip,omitempty"` + StepID string `json:"step_id"` + Action StepAction `json:"action"` + Reason string `json:"reason"` + NewAssumptions []AssumptionItem `json:"new_assumptions,omitempty"` + ResolvedGaps []string `json:"resolved_gaps,omitempty"` + DynamicStepsToAdd []StepSpec `json:"dynamic_steps_to_add,omitempty"` + StepsToSkip []string `json:"steps_to_skip,omitempty"` // NeedsApproval 为 true 时,本批调整先经 HITL 审批再应用。 NeedsApproval bool `json:"needs_approval,omitempty"` ApprovalNote string `json:"approval_note,omitempty"` diff --git a/squad/evaluator_test.go b/squad/evaluator_test.go index b19b9ce..9d43f17 100644 --- a/squad/evaluator_test.go +++ b/squad/evaluator_test.go @@ -68,7 +68,7 @@ func TestDynamicAdjustmentWithApproval(t *testing.T) { "01_researcher": {"notes.md": "# 笔记\n内容"}, "02_writer": {"output.md": goodReport}, // 动态加步发生在子工作区构建之后,runOneStep 按索引兜底目录名。 - "03_writer": {"price.md": priceNote}, + "03_writer": {"price.md": priceNote}, "03_reviewer": {"review.md": goodReport}, } engine := newDynamicEngine(t, script, &scriptedEvaluator{}) diff --git a/squad/hitl.go b/squad/hitl.go index 114ea89..ae3f336 100644 --- a/squad/hitl.go +++ b/squad/hitl.go @@ -40,11 +40,11 @@ func (p HitlPolicy) mode() HitlMode { type HumanRequestKind string const ( - HitlClarification HumanRequestKind = "clarification" // 规划歧义澄清 - HitlConfirmation HumanRequestKind = "confirmation" // 计划/高风险步骤确认 - HitlGap HumanRequestKind = "gap" // 阻塞缺口 - HitlAdjustment HumanRequestKind = "adjustment" // 动态调整审批 - HitlPermission HumanRequestKind = "permission" // 权限升级审批 + HitlClarification HumanRequestKind = "clarification" // 规划歧义澄清 + HitlConfirmation HumanRequestKind = "confirmation" // 计划/高风险步骤确认 + HitlGap HumanRequestKind = "gap" // 阻塞缺口 + HitlAdjustment HumanRequestKind = "adjustment" // 动态调整审批 + HitlPermission HumanRequestKind = "permission" // 权限升级审批 HitlQualityReview HumanRequestKind = "quality_review" // 质检超限人工裁决 HitlEscalation HumanRequestKind = "escalation" // leader/成员升级问人(委派不了/需要拍板) ) @@ -59,9 +59,9 @@ const ( // WaitCondition 挂起请求的自动唤醒条件(等事件而非只等人)。 // 条件满足时引擎合成一条自动决策续跑——唤醒报告的是事实,不是业务完成。 type WaitCondition struct { - Type string `json:"type"` // run_done | field_set - RunID string `json:"run_id,omitempty"` // run_done:等待该运行到达终态 - Field string `json:"field,omitempty"` // field_set:等待黑板出现该字段 + Type string `json:"type"` // run_done | field_set + RunID string `json:"run_id,omitempty"` // run_done:等待该运行到达终态 + Field string `json:"field,omitempty"` // field_set:等待黑板出现该字段 } // MaxWakeupFires 单个挂起请求自动唤醒次数上限(防失控护栏)。 @@ -69,14 +69,14 @@ const MaxWakeupFires = 5 // HumanRequest 一次结构化人工请求(可持久化、可恢复、按 ID 幂等回应)。 type HumanRequest struct { - ID string `json:"id"` - RunID string `json:"run_id"` - StepID string `json:"step_id,omitempty"` - Kind HumanRequestKind `json:"kind"` - Severity string `json:"severity,omitempty"` // action_required(默认)/ attention - Question string `json:"question"` - Options []HumanOption `json:"options,omitempty"` - Context string `json:"context,omitempty"` // 脱敏摘要(计划/缺口/质检报告) + ID string `json:"id"` + RunID string `json:"run_id"` + StepID string `json:"step_id,omitempty"` + Kind HumanRequestKind `json:"kind"` + Severity string `json:"severity,omitempty"` // action_required(默认)/ attention + Question string `json:"question"` + Options []HumanOption `json:"options,omitempty"` + Context string `json:"context,omitempty"` // 脱敏摘要(计划/缺口/质检报告) // WaitCondition 非空时,条件满足可自动唤醒(人工回应仍然优先)。 WaitCondition *WaitCondition `json:"wait_condition,omitempty"` // WakeupFires 已自动唤醒次数(护栏:达到 MaxWakeupFires 后过期)。 @@ -114,11 +114,11 @@ func newRequestID() string { // hitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。 type hitlGate struct { - Request HumanRequest `json:"request"` - Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划 - Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤 - SkipIDs []string `json:"skip_ids,omitempty"` // adjustment:建议跳过的步骤 - PrevStatus RunStatus `json:"prev_status"` // 回应后恢复到的状态 + Request HumanRequest `json:"request"` + Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划 + Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤 + SkipIDs []string `json:"skip_ids,omitempty"` // adjustment:建议跳过的步骤 + PrevStatus RunStatus `json:"prev_status"` // 回应后恢复到的状态 } // mergeHitlPolicy 按全局 → 角色 → Goal 顺序取窄者(后设置者覆盖)。 diff --git a/squad/intent.go b/squad/intent.go index a092706..8381c0a 100644 --- a/squad/intent.go +++ b/squad/intent.go @@ -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 +} diff --git a/squad/intent_catalog_test.go b/squad/intent_catalog_test.go new file mode 100644 index 0000000..d2e52e8 --- /dev/null +++ b/squad/intent_catalog_test.go @@ -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") + } +} diff --git a/squad/leader.go b/squad/leader.go index 0f91392..364326b 100644 --- a/squad/leader.go +++ b/squad/leader.go @@ -20,7 +20,7 @@ func execRequest(workDir, prompt, instructions string, targets []string, timeout Instructions: instructions, Targets: targets, Timeout: timeout, - OnEvent: func(ev exec.ProcessEvent) {}, + OnEvent: func(ev exec.ProcessEvent) {}, } } @@ -35,31 +35,31 @@ const DefaultMaxLeaderTurns = 8 // LeaderState leader 循环状态(workspace leader.json 持久化,重启可恢复)。 type LeaderState struct { - Turns int `json:"turns"` - MaxTurns int `json:"max_turns"` - LeaderRole string `json:"leader_role"` - Delegations []DelegationRecord `json:"delegations"` + Turns int `json:"turns"` + MaxTurns int `json:"max_turns"` + LeaderRole string `json:"leader_role"` + Delegations []DelegationRecord `json:"delegations"` } // DelegationRecord 一次已执行的委派(历史注入下一轮 leader 上下文)。 type DelegationRecord struct { - Turn int `json:"turn"` - Member string `json:"member"` - Title string `json:"title"` - Targets []string `json:"targets,omitempty"` - Status string `json:"status"` // completed / failed / degraded - Error string `json:"error,omitempty"` + Turn int `json:"turn"` + Member string `json:"member"` + Title string `json:"title"` + Targets []string `json:"targets,omitempty"` + Status string `json:"status"` // completed / failed / degraded + Error string `json:"error,omitempty"` } // delegation leader 单轮产出的结构化决策。 type delegation struct { - Action string `json:"action"` // delegate | escalate | close | failed - Member string `json:"member,omitempty"` - Title string `json:"title,omitempty"` - Targets []string `json:"targets,omitempty"` - Briefing string `json:"briefing,omitempty"` // 给成员的补充指令 - Question string `json:"question,omitempty"` // escalate 时的问题 - Reason string `json:"reason,omitempty"` + Action string `json:"action"` // delegate | escalate | close | failed + Member string `json:"member,omitempty"` + Title string `json:"title,omitempty"` + Targets []string `json:"targets,omitempty"` + Briefing string `json:"briefing,omitempty"` // 给成员的补充指令 + Question string `json:"question,omitempty"` // escalate 时的问题 + Reason string `json:"reason,omitempty"` } const leaderSubWorkspace = "tasks/00_leader" @@ -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() } @@ -189,7 +193,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu if leader.BashPermission == "ask" && !st.Approved["role:"+leader.Role] { if drv.policy(st).mode() != HitlInteractive { return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st), - Error: "leader role requires bash approval but hitl mode is " + string(drv.policy(st).mode()), + Error: "leader role requires bash approval but hitl mode is " + string(drv.policy(st).mode()), WorkDir: st.Meta.WorkDir}, nil, nil } req := &HumanRequest{ @@ -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" @@ -264,7 +268,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu } req := &HumanRequest{ ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlEscalation, Severity: SeverityActionRequired, - StepID: fmt.Sprintf("leader_turn_%d", st.Leader.Turns), + StepID: fmt.Sprintf("leader_turn_%d", st.Leader.Turns), Question: firstNonEmpty(dec.Question, "leader 需要人工拍板:") + "(原因:" + dec.Reason + ")", Options: []HumanOption{ {Value: "continue", Label: "已在回答中给出决定,leader 继续"}, @@ -285,11 +289,11 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu return e.leaderFail(st, fmt.Sprintf("leader 委派了编制外成员 %q", dec.Member)), nil, nil } step := StepSpec{ - ID: fmt.Sprintf("m_%02d_%s", st.Leader.Turns, member.Role), - Role: member.Role, - Title: firstNonEmpty(dec.Title, "子任务"), + ID: fmt.Sprintf("m_%02d_%s", st.Leader.Turns, member.Role), + Role: member.Role, + Title: firstNonEmpty(dec.Title, "子任务"), SubWorkspace: fmt.Sprintf("tasks/%02d_%s", st.Leader.Turns, member.Role), - Status: "pending", + Status: "pending", } if len(dec.Targets) > 0 { step.Targets = dec.Targets diff --git a/squad/models.go b/squad/models.go index 9e5e22d..3cad12c 100644 --- a/squad/models.go +++ b/squad/models.go @@ -16,22 +16,22 @@ const ( // RoleSpec 声明一个小队角色的完整设定。空表起步:库不预置任何角色。 type RoleSpec struct { - Role string `json:"role"` - Name string `json:"name"` - Source RoleSource `json:"source,omitempty"` - BaseRole string `json:"base_role,omitempty"` - Backend string `json:"backend,omitempty"` // 绑定的智能体名:stub/llm/agent-native/pi-agent/自定义注册名 - Model string `json:"model,omitempty"` // 角色级模型(backend 支持时生效) - ToolProfile string `json:"tool_profile,omitempty"` // agent-native ToolProfile:workspace/reasoning - Tools []string `json:"tools,omitempty"` - Skills []string `json:"skills,omitempty"` // 技能标识(backend 支持时注入) - BashPermission string `json:"bash_permission"` // allow / ask / deny - Responsibilities []string `json:"responsibilities,omitempty"` - ForbiddenActions []string `json:"forbidden_actions,omitempty"` - Keywords []string `json:"keywords,omitempty"` // 规则规划器推荐与匹配用 - Instructions string `json:"instructions,omitempty"` // 内联指令(优先) - InstructionsFile string `json:"instructions_file,omitempty"` // 指令文件(数据目录相对或绝对路径) - HitlPolicy *HitlPolicy `json:"hitl_policy,omitempty"` // 角色级 HITL 策略(覆盖全局) + Role string `json:"role"` + Name string `json:"name"` + Source RoleSource `json:"source,omitempty"` + BaseRole string `json:"base_role,omitempty"` + Backend string `json:"backend,omitempty"` // 绑定的智能体名:stub/llm/agent-native/pi-agent/自定义注册名 + Model string `json:"model,omitempty"` // 角色级模型(backend 支持时生效) + ToolProfile string `json:"tool_profile,omitempty"` // agent-native ToolProfile:workspace/reasoning + Tools []string `json:"tools,omitempty"` + Skills []string `json:"skills,omitempty"` // 技能标识(backend 支持时注入) + BashPermission string `json:"bash_permission"` // allow / ask / deny + Responsibilities []string `json:"responsibilities,omitempty"` + ForbiddenActions []string `json:"forbidden_actions,omitempty"` + Keywords []string `json:"keywords,omitempty"` // 规则规划器推荐与匹配用 + Instructions string `json:"instructions,omitempty"` // 内联指令(优先) + InstructionsFile string `json:"instructions_file,omitempty"` // 指令文件(数据目录相对或绝对路径) + HitlPolicy *HitlPolicy `json:"hitl_policy,omitempty"` // 角色级 HITL 策略(覆盖全局) // MaxConcurrentTasks 该角色同时执行步骤的上限(<=1 视为 1); // 引擎在解析后端时以并发限流包装实现(对象级串行语义)。 MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"` @@ -55,20 +55,20 @@ type MaterialFile struct { // Blueprint 已注册的任务模板:命中的 Goal 走基线工作流,未注册走自主规划。 type Blueprint struct { - Key string `json:"key"` // 模板键(可由 BlueprintMatcher 匹配 Goal.Text) - Title string `json:"title"` - Match []string `json:"match,omitempty"` // 关键词命中即视为命中模板 - Description string `json:"description,omitempty"` - Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色) - Steps []StepSpec `json:"steps"` - Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown) + Key string `json:"key"` // 模板键(可由 BlueprintMatcher 匹配 Goal.Text) + Title string `json:"title"` + Match []string `json:"match,omitempty"` // 关键词命中即视为命中模板 + Description string `json:"description,omitempty"` + Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色) + Steps []StepSpec `json:"steps"` + Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown) } // PlanningMode 规划模式。 type PlanningMode string const ( - PlanModeBlueprint PlanningMode = "blueprint" // 命中模板:基线步骤 + 动态微调 + PlanModeBlueprint PlanningMode = "blueprint" // 命中模板:基线步骤 + 动态微调 PlanModeAutonomous PlanningMode = "autonomous" // 未命中模板:规划器自主拆解 ) @@ -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 = 非关键,失败降级继续。 @@ -109,9 +114,9 @@ type WorkflowPlan struct { // SquadConfig 激活的小队编制(与产品 squad.json 字段兼容)。 type SquadConfig struct { - SquadName string `json:"squad_name"` - Version string `json:"version"` - Agents []RoleSpec `json:"agents"` + SquadName string `json:"squad_name"` + Version string `json:"version"` + Agents []RoleSpec `json:"agents"` } // ---- 黑板 ---- @@ -178,11 +183,11 @@ type ContextIndex struct { // QualityReport 交付检查报告。 type QualityReport struct { - Passed bool `json:"passed"` - Score float64 `json:"score"` - Violations []string `json:"violations,omitempty"` - MissingSections []string `json:"missing_sections,omitempty"` - Placeholders []string `json:"placeholders,omitempty"` + Passed bool `json:"passed"` + Score float64 `json:"score"` + Violations []string `json:"violations,omitempty"` + MissingSections []string `json:"missing_sections,omitempty"` + Placeholders []string `json:"placeholders,omitempty"` // ManualPass 为 true 时表示质检未过但经 HITL 人工放行。 ManualPass bool `json:"manual_pass,omitempty"` CheckedAt time.Time `json:"checked_at"` @@ -201,11 +206,11 @@ type Manifest struct { // RunMetadata 工作区元信息(workspace.json)。 type RunMetadata struct { - ID string `json:"id"` - Goal Goal `json:"goal"` - Status string `json:"status"` - Backend string `json:"backend,omitempty"` // 默认后端名(角色级 Backend 优先) - Planner string `json:"planner,omitempty"` + ID string `json:"id"` + Goal Goal `json:"goal"` + Status string `json:"status"` + Backend string `json:"backend,omitempty"` // 默认后端名(角色级 Backend 优先) + Planner string `json:"planner,omitempty"` WorkDir string `json:"work_dir"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -217,17 +222,17 @@ type RunMetadata struct { type RunStatus string const ( - RunCreated RunStatus = "created" - RunPlanning RunStatus = "planning" + RunCreated RunStatus = "created" + RunPlanning RunStatus = "planning" RunAwaitingClarification RunStatus = "awaiting_clarification" // HITL:规划歧义,等待澄清 - RunAwaitingConfirmation RunStatus = "awaiting_confirmation" // HITL:计划待确认 - RunExecuting RunStatus = "executing" - RunWaitingHuman RunStatus = "waiting_human" // HITL:执行中挂起(缺口/审批/权限/质检) - RunQualityReview RunStatus = "quality_review" // HITL:质检超限转人工 - RunCompleted RunStatus = "completed" - RunDegraded RunStatus = "degraded" - RunFailed RunStatus = "failed" - RunCancelled RunStatus = "cancelled" + RunAwaitingConfirmation RunStatus = "awaiting_confirmation" // HITL:计划待确认 + RunExecuting RunStatus = "executing" + RunWaitingHuman RunStatus = "waiting_human" // HITL:执行中挂起(缺口/审批/权限/质检) + RunQualityReview RunStatus = "quality_review" // HITL:质检超限转人工 + RunCompleted RunStatus = "completed" + RunDegraded RunStatus = "degraded" + RunFailed RunStatus = "failed" + RunCancelled RunStatus = "cancelled" ) // IsHeld 返回该状态是否为 HITL 挂起态(等待人工回应)。 diff --git a/squad/planner.go b/squad/planner.go index 7824073..eba0ffb 100644 --- a/squad/planner.go +++ b/squad/planner.go @@ -19,6 +19,9 @@ type PlanInput struct { Blueprint *Blueprint // 命中模板时非 nil(基线步骤,规划器可微调) Registry *RoleRegistry RunID string + // IntentCatalog 可选能力目录:启用意图编排时非 nil。规划者应把命中目录的 + // 子任务输出为 intent_id 步骤(必填槽位不足时改输出澄清,用目录的提问建议)。 + IntentCatalog *IntentCatalog } // PlanOutput 规划输出:计划或澄清请求二选一。 @@ -35,22 +38,22 @@ type PlanOutput struct { // SquadPlan 一次规划产物。 type SquadPlan struct { - Squad SquadConfig `json:"squad"` - Workflow WorkflowPlan `json:"workflow"` - Template string `json:"template,omitempty"` // 交付物骨架(蓝图携带或规划器生成) + Squad SquadConfig `json:"squad"` + Workflow WorkflowPlan `json:"workflow"` + Template string `json:"template,omitempty"` // 交付物骨架(蓝图携带或规划器生成) } // Clarification 澄清请求:问题 + 可选建议选项。 type Clarification struct { - Reason string `json:"reason"` - Questions []Question `json:"questions"` + Reason string `json:"reason"` + Questions []Question `json:"questions"` } // Question 单个澄清问题。 type Question struct { - ID string `json:"id"` - Text string `json:"text"` - Options []HumanOption `json:"options,omitempty"` + ID string `json:"id"` + Text string `json:"text"` + Options []HumanOption `json:"options,omitempty"` } // NewPlanID 生成工作流 ID。 diff --git a/squad/planner_llm.go b/squad/planner_llm.go index a1ebc82..677ee3d 100644 --- a/squad/planner_llm.go +++ b/squad/planner_llm.go @@ -13,11 +13,11 @@ import ( // 模型客户端与默认模型在构造时注入(角色级模型在派生角色时由模型自行指定)。 // 输出为严格 JSON;解析失败时重试一次,仍失败则 fail-closed 返回错误。 type LLMPlanner struct { - Client *llm.Client - Model string - APIBase string - APIKey string - MaxTokens int + Client *llm.Client + Model string + APIBase string + APIKey string + MaxTokens int } // NewLLMPlanner 创建 LLM 规划器。 @@ -29,38 +29,40 @@ func (p *LLMPlanner) Name() string { return "llm" } // llmPlanSchema 期望模型输出的 JSON 结构(宽松解析:仅取必要字段)。 type llmPlanSchema struct { - Clarify []llmQuestion `json:"clarify,omitempty"` - Confirm bool `json:"confirm,omitempty"` - Reason string `json:"confirm_reason,omitempty"` - Agents []llmAgent `json:"agents"` - Steps []llmStep `json:"steps"` - Template string `json:"template,omitempty"` + Clarify []llmQuestion `json:"clarify,omitempty"` + Confirm bool `json:"confirm,omitempty"` + Reason string `json:"confirm_reason,omitempty"` + Agents []llmAgent `json:"agents"` + Steps []llmStep `json:"steps"` + Template string `json:"template,omitempty"` } type llmQuestion struct { - ID string `json:"id,omitempty"` - Text string `json:"text"` - Options []HumanOption `json:"options,omitempty"` + ID string `json:"id,omitempty"` + Text string `json:"text"` + Options []HumanOption `json:"options,omitempty"` } type llmAgent struct { - Action string `json:"action"` // use | derive - Role string `json:"role"` - Name string `json:"name,omitempty"` - BaseRole string `json:"base_role,omitempty"` - Model string `json:"model,omitempty"` - Backend string `json:"backend,omitempty"` - DomainTopic string `json:"domain_topic,omitempty"` + Action string `json:"action"` // use | derive + Role string `json:"role"` + Name string `json:"name,omitempty"` + BaseRole string `json:"base_role,omitempty"` + Model string `json:"model,omitempty"` + Backend string `json:"backend,omitempty"` + DomainTopic string `json:"domain_topic,omitempty"` Responsibilities []string `json:"responsibilities,omitempty"` } type llmStep struct { - ID string `json:"id"` - Role string `json:"role"` - Title string `json:"title"` - Parallel bool `json:"parallel,omitempty"` - Targets []string `json:"targets,omitempty"` - RequiresApproval bool `json:"requires_approval,omitempty"` + ID string `json:"id"` + Role string `json:"role"` + Title string `json:"title"` + 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() } diff --git a/squad/planner_rule.go b/squad/planner_rule.go index 76ab711..f694d9d 100644 --- a/squad/planner_rule.go +++ b/squad/planner_rule.go @@ -97,10 +97,10 @@ func (p *RulePlanner) autonomous(input PlanInput) (*PlanOutput, error) { // 以推荐角色/首个角色为基线派生通用角色。 base := p.baseRole(input.Registry) derived, err := input.Registry.Derive(DeriveRequest{ - RoleKey: st.role, - Name: fmt.Sprintf("%s专员", st.role), - BaseRole: base.Role, - DomainTopic: goalText, + RoleKey: st.role, + Name: fmt.Sprintf("%s专员", st.role), + BaseRole: base.Role, + DomainTopic: goalText, Responsibilities: []string{st.title}, }) if err != nil { diff --git a/squad/runstate.go b/squad/runstate.go index fd0cff4..786cb95 100644 --- a/squad/runstate.go +++ b/squad/runstate.go @@ -13,10 +13,10 @@ import ( // runState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json / // squad.json / blackboard.json / pending.json 全部落盘,进程重启可恢复)。 type runState struct { - Meta RunMetadata `json:"meta"` - Plan SquadPlan `json:"plan"` - BB BlackboardState `json:"bb"` - Template string `json:"template,omitempty"` + Meta RunMetadata `json:"meta"` + Plan SquadPlan `json:"plan"` + BB BlackboardState `json:"bb"` + Template string `json:"template,omitempty"` // Approved 运行内一次性授权(permission/confirmation),键为 role:/step: 前缀。 Approved map[string]bool `json:"approved,omitempty"` // RepairBudgetRemaining 质检失败后的自动重跑剩余次数。 diff --git a/squad/steering_test.go b/squad/steering_test.go index 90a9661..a6d73e5 100644 --- a/squad/steering_test.go +++ b/squad/steering_test.go @@ -129,10 +129,10 @@ func TestUsageAggregatedPerRole(t *testing.T) { // slowCountBackend 统计并发峰值(验证角色级并发上限)。 type slowCountBackend struct { - mu sync.Mutex - cur int - peak int - delay time.Duration + mu sync.Mutex + cur int + peak int + delay time.Duration } func (b *slowCountBackend) Name() string { return "slow" } diff --git a/squad/workflow.go b/squad/workflow.go index 26542de..7f37a8e 100644 --- a/squad/workflow.go +++ b/squad/workflow.go @@ -187,11 +187,18 @@ 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() - st.setStep(idx, step) - return d.failStep(ctx, st, idx) + 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 @@ -199,6 +206,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru 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})}) }, } - result, err = backend.Execute(ctx, req) + 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 } diff --git a/squadintent/catalog.go b/squadintent/catalog.go new file mode 100644 index 0000000..7dabf9d --- /dev/null +++ b/squadintent/catalog.go @@ -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() +} diff --git a/squadintent/catalog_test.go b/squadintent/catalog_test.go new file mode 100644 index 0000000..65a214e --- /dev/null +++ b/squadintent/catalog_test.go @@ -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") + } +}