Merge pull request 'Add the explicit spec.action declaration (I1 WP2)' (#7) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 16s
ci / docker-checkout-hostgateway (push) Successful in 1s

This commit was merged in pull request #7.
This commit is contained in:
2026-10-07 07:41:37 +00:00
5 changed files with 67 additions and 2 deletions
+5 -2
View File
@@ -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}
}
+41
View File
@@ -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")
}
}
+4
View File
@@ -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 {
+3
View File
@@ -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
}
+14
View File
@@ -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 {