From 916ee0733f410314324b37706fcaff652a5bde46 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 07:31:05 +0000 Subject: [PATCH] Add the explicit spec.action declaration for action-key unification Why: I1 WP2 - one explicit action field outranks the four legacy action keys (Handler, metadata.blueprint, command-id, workflow IntentID) so hosts migrate to a single derivation path. Evidence: go vet clean; go test ./... all ok, including declared action winning the Start command and kind/id validation rejections. Impact: IntentSpec gains Action (command/workflow/blueprint/handler); Candidate carries it through; actionOf prefers it, then blueprint, then Handler, then auto. Legacy keys keep working. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP2 --- intent/command.go | 7 +++++-- intent/command_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ intent/registry.go | 4 ++++ intent/result.go | 3 +++ intent/spec.go | 14 ++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/intent/command.go b/intent/command.go index 7189344..92d5f9b 100644 --- a/intent/command.go +++ b/intent/command.go @@ -57,9 +57,12 @@ type Command struct { Target string `json:"target,omitempty"` } -// actionOf 从候选推导动作引用:显式 blueprint 键优先于 Handler, -// 都没有时回落 auto(宿主裁决)。 +// actionOf 从候选推导动作引用:spec.action 显式声明优先, +// 其后是 blueprint 键、Handler,都没有时回落 auto(宿主裁决)。 func actionOf(c Candidate) ActionRef { + if c.Action != nil { + return *c.Action + } if bp := strings.TrimSpace(c.Metadata["blueprint"]); bp != "" { return ActionRef{Kind: ActionBlueprint, ID: bp} } diff --git a/intent/command_test.go b/intent/command_test.go index d0d4d1c..759ea80 100644 --- a/intent/command_test.go +++ b/intent/command_test.go @@ -162,3 +162,44 @@ type listExpectation struct{ ids []string } func (s listExpectation) Pending(ctx context.Context) *PendingExpectation { return &PendingExpectation{IntentIDs: s.ids} } + +// TestSpecActionDeclaredWins:spec.action 显式声明在 ActionRef 推导中胜出(WP2)。 +func TestSpecActionDeclaredWins(t *testing.T) { + b := NewRegistryBuilder(Options{}) + if err := b.Register(IntentSpec{ + ID: "report.explicit", Domain: "report", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"显式动作"}}, + Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-report"}, + }); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + res := recognize(t, reg.Snapshot(), "显式动作") + if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart { + t.Fatalf("commands = %+v", res.Commands) + } + if got := res.Commands[0].Action; got.Kind != ActionWorkflow || got.ID != "wf-report" { + t.Fatalf("action = %+v", got) + } +} + +// TestSpecActionValidation:action.kind 非法与 id 为空都被拒绝。 +func TestSpecActionValidation(t *testing.T) { + bad := IntentSpec{ + ID: "report.bad", Domain: "report", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"x"}}, + Action: &ActionRef{Kind: "bogus", ID: "y"}, + } + b := NewRegistryBuilder(Options{}) + if err := b.Register(bad); err == nil { + t.Fatal("bogus action kind accepted") + } + bad.Action = &ActionRef{Kind: ActionCommand, ID: ""} + b2 := NewRegistryBuilder(Options{}) + if err := b2.Register(bad); err == nil { + t.Fatal("empty action id accepted") + } +} diff --git a/intent/registry.go b/intent/registry.go index 6fc058b..0ce5785 100644 --- a/intent/registry.go +++ b/intent/registry.go @@ -420,6 +420,10 @@ func (h specHit) candidate(input string, withSlots bool) Candidate { if c.Risk == "" { c.Risk = RiskLow } + if cs.spec.Action != nil { + ref := *cs.spec.Action + c.Action = &ref + } if len(cs.spec.Metadata) > 0 { c.Metadata = make(map[string]string, len(cs.spec.Metadata)) for k, v := range cs.spec.Metadata { diff --git a/intent/result.go b/intent/result.go index 2e8b21b..8f7f47b 100644 --- a/intent/result.go +++ b/intent/result.go @@ -35,6 +35,9 @@ type Candidate struct { // InvalidSlots 捕获到但未通过确定性校验的槽位(key→原因,如枚举不合法)。 // 必填且非法只进此处(不重复进 Missing),宿主可区分"没给"与"给错了"。 InvalidSlots map[string]string + // Action 是 spec 的显式动作声明(I1 WP2),随候选透传;nil 表示未声明, + // ActionRef 推导回落 blueprint/Handler/auto 顺序。 + Action *ActionRef // Reason 命中解释:命中词与模式,供宿主审计与 intentctl explain 复用。 Reason string } diff --git a/intent/spec.go b/intent/spec.go index 2ab0369..cbb8fbd 100644 --- a/intent/spec.go +++ b/intent/spec.go @@ -83,6 +83,10 @@ type IntentSpec struct { RequiresConfirm bool `json:"requires_confirm,omitempty"` Handler string `json:"handler,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` + // Action 为显式动作声明(I1 WP2 四键统一的首选来源): + // 声明后优先于 Handler 与 Metadata["blueprint"] 被 ActionRef 采用。 + // kind 必须是 command/workflow/blueprint/handler 之一。 + Action *ActionRef `json:"action,omitempty"` } var ( @@ -131,6 +135,16 @@ func validateSpec(spec IntentSpec, lim limits, scoped map[string]SlotValidator) default: return newError(InvalidSpec, "risk 只能是 low/medium/high 或留空", "field", "risk", "risk", spec.Risk) } + if spec.Action != nil { + switch spec.Action.Kind { + case ActionCommand, ActionWorkflow, ActionBlueprint, ActionHandler: + default: + return newError(InvalidSpec, "action.kind 必须是 command/workflow/blueprint/handler", "field", "action.kind", "action.kind", spec.Action.Kind) + } + if strings.TrimSpace(spec.Action.ID) == "" { + return newError(InvalidSpec, "action.id 为空", "field", "action.id", "id", spec.ID) + } + } m := spec.Matcher for _, terms := range []struct {