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
+2 -2
View File
@@ -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"}`,
},
}
@@ -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)。
+2 -2
View File
@@ -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)
}
+22 -8
View File
@@ -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 {
+4 -4
View File
@@ -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},
+11 -11
View File
@@ -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"`
+1 -1
View File
@@ -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{})
+21 -21
View File
@@ -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 顺序取窄者(后设置者覆盖)。
+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")
}
}
+31 -27
View File
@@ -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
+52 -47
View File
@@ -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 挂起态(等待人工回应)。
+11 -8
View File
@@ -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。
+37 -28
View File
@@ -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()
}
+4 -4
View File
@@ -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 {
+4 -4
View File
@@ -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 质检失败后的自动重跑剩余次数。
+4 -4
View File
@@ -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" }
+31 -6
View File
@@ -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
}
+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")
}
}