Add catalog-driven orchestration: intent catalog injection
Details (original message): Add catalog-driven orchestration: intent catalog injection, deterministic plan validation, and system-function steps.
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 55780ae2cc...3674dc4bfd
Vendored
+1
-1
Submodule deps/agent-native updated: c659dc17d5...292142ac1a
@@ -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 步。
|
||||
@@ -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)。
|
||||
|
||||
@@ -37,6 +37,12 @@ type Options struct {
|
||||
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
|
||||
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
|
||||
IntentGate IntentGate
|
||||
// IntentCatalog 可选能力目录(squadintent 渲染):注入 PlanInput 与 leader 提示词,
|
||||
// 并用于 intent 步骤的确定性校验。nil = 未启用目录驱动编排。
|
||||
IntentCatalog *IntentCatalog
|
||||
// IntentRunner 系统功能步执行器(squadintent 提供)。目录启用而 Runner 缺席时,
|
||||
// intent 步骤 fail-closed。
|
||||
IntentRunner IntentRunner
|
||||
}
|
||||
|
||||
// engineDeps 驱动器依赖(由引擎从 Options 派生)。
|
||||
@@ -49,6 +55,7 @@ type engineDeps struct {
|
||||
Workspaces *WorkspaceEngine
|
||||
StepTimeout time.Duration
|
||||
HitlPolicy HitlPolicy
|
||||
IntentRunner IntentRunner
|
||||
}
|
||||
|
||||
// RunOptions 单次运行的可选项。
|
||||
@@ -120,6 +127,7 @@ func NewEngine(opts Options) (*SquadEngine, error) {
|
||||
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
|
||||
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
|
||||
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
|
||||
IntentRunner: opts.IntentRunner,
|
||||
}
|
||||
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
|
||||
}
|
||||
@@ -212,10 +220,16 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
|
||||
planOut, err := e.opts.Planner.Plan(ctx, PlanInput{
|
||||
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
|
||||
IntentCatalog: e.opts.IntentCatalog,
|
||||
})
|
||||
if err != nil {
|
||||
return e.failNew(meta, err)
|
||||
}
|
||||
// 目录驱动编排的确定性校验:intent 步骤命中目录、必填槽位齐、枚举合法(fail-closed)。
|
||||
if err := validateIntentSteps(planOut.Plan, e.opts.IntentCatalog); err != nil {
|
||||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: meta.ID, Text: err.Error(), At: time.Now()})
|
||||
return e.failNew(meta, err)
|
||||
}
|
||||
// 规划歧义:挂起澄清(auto/disabled 模式直接失败,不猜计划)。
|
||||
if planOut.Clarification != nil {
|
||||
if drv.policy(st).mode() != HitlInteractive {
|
||||
|
||||
+121
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
type stubRunner struct {
|
||||
calls []IntentStep
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, error) {
|
||||
r.calls = append(r.calls, step)
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
patches := map[string][]byte{}
|
||||
for _, t := range step.Targets {
|
||||
patches[t] = []byte(r.text)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "stub-intent"}, nil
|
||||
}
|
||||
|
||||
func testCatalog() *IntentCatalog {
|
||||
return &IntentCatalog{
|
||||
Revision: 1,
|
||||
Markdown: "## demo.run.status_query — 查询运行进度\n- 槽位 run_id(必填)\n - 提问建议:要查询哪个运行?\n",
|
||||
Specs: map[string]IntentSpecView{
|
||||
"demo.run.status_query": {
|
||||
ID: "demo.run.status_query", Description: "查询运行进度", Handler: "taskrun.query_status",
|
||||
Slots: []IntentSlotView{
|
||||
{Key: "run_id", Required: true, Label: "运行编号", Question: "要查询哪个运行?"},
|
||||
{Key: "env", Enum: []string{"prod", "staging"}},
|
||||
},
|
||||
RequiredSlots: []string{"run_id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIntentSteps(t *testing.T) {
|
||||
step := func(id, intent string, slots map[string]string) SquadPlan {
|
||||
return SquadPlan{Workflow: WorkflowPlan{Steps: []StepSpec{{ID: id, IntentID: intent, IntentSlots: slots}}}}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
plan SquadPlan
|
||||
catalog *IntentCatalog
|
||||
wantErr string
|
||||
}{
|
||||
{"ok", step("s1", "demo.run.status_query", map[string]string{"run_id": "run-1"}), testCatalog(), ""},
|
||||
{"no_catalog", step("s1", "demo.run.status_query", nil), nil, "未装配意图目录"},
|
||||
{"unknown_intent", step("s1", "demo.nope.x", nil), testCatalog(), "不存在意图"},
|
||||
{"missing_required", step("s1", "demo.run.status_query", nil), testCatalog(), "缺必填槽位 run_id(澄清建议:要查询哪个运行?)"},
|
||||
{"enum_violation", step("s1", "demo.run.status_query", map[string]string{"run_id": "run-1", "env": "dev"}), testCatalog(), "不在枚举"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateIntentSteps(&tc.plan, tc.catalog)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newCatalogTestEngine(t *testing.T, runner IntentRunner, catalog *IntentCatalog) *SquadEngine {
|
||||
t.Helper()
|
||||
engine, _ := newTestEngine(t, NewRulePlanner(),
|
||||
map[string]map[string]string{"02_writer": {"output.md": goodReport}}, nil)
|
||||
if err := engine.Blueprints().Register(Blueprint{
|
||||
Key: "mixed_demo", Title: "混合演示", Match: []string{"混合演示"},
|
||||
Steps: []StepSpec{
|
||||
{ID: "sys1", Role: "researcher", Title: "查询运行进度",
|
||||
IntentID: "demo.run.status_query", IntentSlots: map[string]string{"run_id": "run-42"}},
|
||||
{ID: "write1", Role: "writer", Title: "汇总成文", Targets: []string{"output.md"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 产品侧经 Options 在 NewEngine 传入;测试在构造后注入,需同时更新
|
||||
// engineDeps(步骤执行走 deps 副本,构建期已定型)。
|
||||
engine.opts.IntentCatalog = catalog
|
||||
engine.opts.IntentRunner = runner
|
||||
engine.deps.IntentRunner = runner
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestIntentStepExecutedByRunnerNotBackend(t *testing.T) {
|
||||
runner := &stubRunner{text: "# 查询结果\n\nrun-42 进度 100%,内容满足最小质检长度要求,作为系统功能步产物。\n"}
|
||||
engine := newCatalogTestEngine(t, runner, testCatalog())
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "做一次混合演示", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("expected completed, got %s (err=%s)", res.Status, res.Error)
|
||||
}
|
||||
if len(runner.calls) != 1 || runner.calls[0].IntentID != "demo.run.status_query" || runner.calls[0].Slots["run_id"] != "run-42" {
|
||||
t.Fatalf("runner calls: %+v", runner.calls)
|
||||
}
|
||||
// 系统功能步产物落盘。
|
||||
sysOut := filepath.Join(res.WorkDir, "tasks", "01_researcher", "output.md")
|
||||
if data, err := os.ReadFile(sysOut); err != nil || !strings.Contains(string(data), "查询结果") {
|
||||
t.Fatalf("intent step deliverable missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentStepWithoutRunnerFailsClosed(t *testing.T) {
|
||||
engine := newCatalogTestEngine(t, nil, testCatalog())
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "做一次混合演示", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunFailed || !strings.Contains(res.Error, "IntentRunner") {
|
||||
t.Fatalf("expected fail-closed runner error, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentStepPlanValidationFailsClosed(t *testing.T) {
|
||||
// 目录缺席时,含 intent 步的蓝图在计划校验即被拒(不到执行层)。
|
||||
engine := newCatalogTestEngine(t, &stubRunner{text: "x"}, nil)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "做一次混合演示", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunFailed || !strings.Contains(res.Error, "未装配意图目录") {
|
||||
t.Fatalf("expected validation failure, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMPlannerPromptIncludesCatalog(t *testing.T) {
|
||||
p := NewLLMPlanner(nil, "m")
|
||||
input := PlanInput{
|
||||
Goal: Goal{Text: "目标"},
|
||||
Registry: NewRoleRegistry(),
|
||||
IntentCatalog: testCatalog(),
|
||||
}
|
||||
got := p.buildPrompt(input)
|
||||
for _, want := range []string{"系统能力目录", "demo.run.status_query", "提问建议:要查询哪个运行"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(NewLLMPlanner(nil, "m").buildPrompt(PlanInput{Goal: Goal{Text: "g"}, Registry: NewRoleRegistry()}), "系统能力目录") {
|
||||
t.Fatal("catalog must be absent when not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderPromptIncludesCatalog(t *testing.T) {
|
||||
md := leaderPrompt(nil, testCatalog().Markdown)
|
||||
if !strings.Contains(md, "系统能力目录") || !strings.Contains(md, "demo.run.status_query") {
|
||||
t.Fatalf("leader prompt missing catalog:\n%s", md)
|
||||
}
|
||||
if strings.Contains(leaderPrompt(nil, ""), "系统能力目录") {
|
||||
t.Fatal("empty catalog must not add section")
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -83,7 +83,7 @@ func (st *runState) saveLeader() {
|
||||
}
|
||||
|
||||
// leaderPrompt 组装 leader 的系统指令(协调契约 + 通用守则)。
|
||||
func leaderPrompt(members []RoleSpec) string {
|
||||
func leaderPrompt(members []RoleSpec, catalogMd string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`你是本小队的 LEADER。你被激活的唯一职责是协调,不是自己实现。
|
||||
|
||||
@@ -108,6 +108,10 @@ func leaderPrompt(members []RoleSpec) string {
|
||||
for _, m := range members {
|
||||
fmt.Fprintf(&b, "- %s — %s — skills=%v — %v\n", m.Role, m.Name, m.Skills, m.Responsibilities)
|
||||
}
|
||||
if catalogMd != "" {
|
||||
b.WriteString("\n系统能力目录(供你判断哪些子任务不必派成员、以及该向用户收集什么信息;缺失必填信息时 escalate 并使用提问建议):\n")
|
||||
b.WriteString(catalogMd)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -213,7 +217,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st)), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(leaderPrompt(members)), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd())), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -228,7 +232,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
}
|
||||
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st))
|
||||
prompt = st.injectSteering(prompt)
|
||||
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members))
|
||||
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd()))
|
||||
result, execErr := backend.Execute(ctx, execRequest(subDir, prompt, instructions, []string{"delegation.json"}, e.deps.StepTimeout))
|
||||
if execErr != nil || result == nil || !result.Success {
|
||||
reason := "leader execution failed"
|
||||
|
||||
@@ -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 = 非关键,失败降级继续。
|
||||
|
||||
@@ -19,6 +19,9 @@ type PlanInput struct {
|
||||
Blueprint *Blueprint // 命中模板时非 nil(基线步骤,规划器可微调)
|
||||
Registry *RoleRegistry
|
||||
RunID string
|
||||
// IntentCatalog 可选能力目录:启用意图编排时非 nil。规划者应把命中目录的
|
||||
// 子任务输出为 intent_id 步骤(必填槽位不足时改输出澄清,用目录的提问建议)。
|
||||
IntentCatalog *IntentCatalog
|
||||
}
|
||||
|
||||
// PlanOutput 规划输出:计划或澄清请求二选一。
|
||||
|
||||
+10
-1
@@ -61,6 +61,8 @@ type llmStep struct {
|
||||
Parallel bool `json:"parallel,omitempty"`
|
||||
Targets []string `json:"targets,omitempty"`
|
||||
RequiresApproval bool `json:"requires_approval,omitempty"`
|
||||
IntentID string `json:"intent_id,omitempty"`
|
||||
IntentSlots map[string]string `json:"intent_slots,omitempty"`
|
||||
}
|
||||
|
||||
const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标、已注册角色清单和可选的任务模板,输出一个可执行的小队计划。
|
||||
@@ -71,7 +73,8 @@ const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标
|
||||
4. agents: 优先复用已注册角色({"action":"use","role":"已注册key"});确无合适角色才派生({"action":"derive","role":"新key","base_role":"基线key","name":"中文名","domain_topic":"领域","responsibilities":[...]})。派生数量尽量少。
|
||||
5. steps: 每步 {"id":"step_01_x","role":"角色key","title":"步骤职责"};可并行的相邻步骤 parallel=true;高风险/破坏性/对外发布类步骤 requires_approval=true;步骤通常 2~6 个,含梳理、专业执行、汇总成文、终审把关。
|
||||
6. 涉及写入、删除、对外提交或超出常规预算的计划 confirm=true。
|
||||
7. 通用守则:缺失的信息若会实质改变结果、执行路径、权限或安全,必须 clarify 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。`
|
||||
7. 通用守则:缺失的信息若会实质改变结果、执行路径、权限或安全,必须 clarify 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。
|
||||
8. 目标下方若给出"系统能力目录":子任务命中目录能力时,该步输出 {"role":"intent","intent_id":"目录id","intent_slots":{...},"title":"..."},槽位必须按目录填齐;必填槽位信息不足时禁止编造,改为 clarify 并使用目录的提问建议。未命中目录的子任务照常派给角色。没有目录时不得输出 intent_id 步骤。`
|
||||
|
||||
func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
|
||||
if p.Client == nil || p.Model == "" {
|
||||
@@ -175,6 +178,7 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
steps = append(steps, StepSpec{
|
||||
ID: id, Role: ls.Role, Title: ls.Title, Parallel: ls.Parallel,
|
||||
Targets: targets, RequiresApproval: ls.RequiresApproval, Status: "pending",
|
||||
IntentID: ls.IntentID, IntentSlots: ls.IntentSlots,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -219,6 +223,11 @@ func (p *LLMPlanner) buildPrompt(input PlanInput) string {
|
||||
b.Write(enc)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if input.IntentCatalog != nil && input.IntentCatalog.Markdown != "" {
|
||||
b.WriteString("\n## 系统能力目录(可直接调用,不必派智能体)\n")
|
||||
b.WriteString(input.IntentCatalog.Markdown)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
+28
-3
@@ -187,18 +187,26 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
prompt = st.injectSteering(prompt)
|
||||
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role))
|
||||
|
||||
backend, backendName, err := d.opts.Backends.Resolve(role, st.Meta.Backend)
|
||||
if err != nil {
|
||||
step.Status, step.Error = "failed", err.Error()
|
||||
var backend exec.Backend
|
||||
backendName := ""
|
||||
if step.IntentID != "" {
|
||||
backendName = "intent:" + step.IntentID
|
||||
} else {
|
||||
var rerr error
|
||||
backend, backendName, rerr = d.opts.Backends.Resolve(role, st.Meta.Backend)
|
||||
if rerr != nil {
|
||||
step.Status, step.Error = "failed", rerr.Error()
|
||||
st.setStep(idx, step)
|
||||
return d.failStep(ctx, st, idx)
|
||||
}
|
||||
}
|
||||
|
||||
targets := step.Targets
|
||||
if len(targets) == 0 {
|
||||
targets = []string{"output.md"}
|
||||
}
|
||||
var result *exec.Result
|
||||
var err error
|
||||
for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ {
|
||||
reqPrompt := prompt
|
||||
if attempt > 1 {
|
||||
@@ -222,7 +230,24 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})})
|
||||
},
|
||||
}
|
||||
if step.IntentID != "" {
|
||||
// 系统功能步:不经智能体,由 IntentRunner 直接执行;缺席即 fail-closed。
|
||||
// Runner 只产出内容(patches),由引擎统一物化到子工作区。
|
||||
if d.opts.IntentRunner == nil {
|
||||
step.Status, step.Error = "failed", fmt.Sprintf("intent step %s: no IntentRunner configured", step.ID)
|
||||
st.setStep(idx, step)
|
||||
return d.failStep(ctx, st, idx)
|
||||
}
|
||||
result, err = d.opts.IntentRunner.Run(ctx, IntentStep{
|
||||
IntentID: step.IntentID, Slots: step.IntentSlots,
|
||||
Title: step.Title, WorkDir: subDir, Targets: targets,
|
||||
})
|
||||
if err == nil && result != nil && len(result.Patches) > 0 {
|
||||
_, _ = exec.MaterializeTargets(subDir, targets, result.Patches)
|
||||
}
|
||||
} else {
|
||||
result, err = backend.Execute(ctx, req)
|
||||
}
|
||||
if err == nil && result.Success {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user