From 064747f1924f4dd28488d6f706e2c89f27a74eba Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 5 Sep 2026 21:49:25 +0400 Subject: [PATCH] router: add ActionCandidate type and ResolveActionCandidate Introduce the typed boundary between routing and action resolution: - ActionCandidate: Fn, Args, Source (route|matcher), Producer, Confidence - ResolveActionCandidate(dec, m): standalone function usable by both the daemon and the eval harness - Update eval harness Reach() to use ResolveActionCandidate instead of duplicating the matcher fallback logic This is the routing-side half of the action-resolution boundary. The daemon integration follows in the next commit. --- internal/router/actioncandidate.go | 95 +++++++++++++++ internal/router/actioncandidate_test.go | 151 ++++++++++++++++++++++++ internal/router/eval/reach.go | 13 +- 3 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 internal/router/actioncandidate.go create mode 100644 internal/router/actioncandidate_test.go diff --git a/internal/router/actioncandidate.go b/internal/router/actioncandidate.go new file mode 100644 index 0000000..1f7dba4 --- /dev/null +++ b/internal/router/actioncandidate.go @@ -0,0 +1,95 @@ +package router + +// ActionCandidate — the result of action resolution, produced before execution. +// It replaces the implicit ownership split where the router filled Slots.Fn/Args +// and actionAct re-matched when they were absent. One candidate is produced per +// IntentAct decision, carrying the resolved function, its arguments, and where +// the resolution came from. +type ActionCandidate struct { + // Fn — the resolved function/tool identity. Empty when no match was found. + Fn string + + // Args — positional arguments passed to the tool. May be nil when Fn is + // empty or when the match produced no arguments. + Args []string + + // Source — where the resolution came from. Typed enum, not free-form. + Source ActionSource + + // Producer — which cascade stage produced the routing decision that led + // here. Carried for observability; not used for dispatch. + Producer RouteProducer + + // Confidence — the routing confidence from the decision. Carried for + // observability; not used for dispatch. + Confidence float64 +} + +// ActionSource — where action resolution came from. Two values: the router +// resolved the function upstream (stage-0 grammar or stage-2 extraction), or +// the fallback matcher ran because the router did not fill Fn. +type ActionSource string + +const ( + // ActionSourceRoute — Fn/Args were already resolved in the routing + // cascade (stage-0 grammar match, stage-2 extractor, or LLM slot + // backfill). The matcher was not invoked. + ActionSourceRoute ActionSource = "route" + + // ActionSourceMatcher — the router left Fn empty, so the fallback + // matcher ran against the text slot and produced the match. + ActionSourceMatcher ActionSource = "matcher" +) + +// ActionResolved reports whether the candidate resolved to a function. +func (c ActionCandidate) ActionResolved() bool { return c.Fn != "" } + +// ResolveActionCandidate produces an ActionCandidate from a routing decision. +// It is the single boundary between routing and action resolution: everything +// downstream consumes the candidate rather than re-resolving the function. +// +// Resolution rules: +// - Non-act intents: candidate is not applicable (Fn empty, source empty). +// - Act with Slots.HasFn: the router already resolved the function upstream +// (stage-0 grammar, stage-2 extractor, or LLM slot backfill). Candidate +// source is ActionSourceRoute. +// - Act without Fn: the fallback matcher runs against the text slot. +// Candidate source is ActionSourceMatcher on match, or Fn stays empty. +// +// The matcher algorithm, enabled-tool set, alias behavior, fuzzy-prefix +// behavior, and ordering are unchanged — this is a mechanical extraction of +// the same matching call that actionAct previously owned. +func ResolveActionCandidate(dec Decision, m ActMatcher) ActionCandidate { + if dec.Intent != IntentAct { + return ActionCandidate{} + } + + // Router resolved the function upstream. + if dec.Slots.HasFn { + return ActionCandidate{ + Fn: dec.Slots.Fn, + Args: dec.Slots.Args, + Source: ActionSourceRoute, + Producer: dec.Producer, + Confidence: dec.Confidence, + } + } + + // Fallback: invoke the matcher against the text slot. + if dec.Slots.Text != "" && m != nil { + if fn, args, ok := m.Match(dec.Slots.Text); ok { + return ActionCandidate{ + Fn: fn, + Args: args, + Source: ActionSourceMatcher, + Producer: dec.Producer, + Confidence: dec.Confidence, + } + } + } + + return ActionCandidate{ + Producer: dec.Producer, + Confidence: dec.Confidence, + } +} diff --git a/internal/router/actioncandidate_test.go b/internal/router/actioncandidate_test.go new file mode 100644 index 0000000..b79537a --- /dev/null +++ b/internal/router/actioncandidate_test.go @@ -0,0 +1,151 @@ +package router + +import ( + "testing" +) + +// TestResolveActionCandidate_RouteSource pins that an act with HasFn=true +// produces a candidate from the route, not the matcher. +func TestResolveActionCandidate_RouteSource(t *testing.T) { + dec := Decision{ + Intent: IntentAct, + Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, + } + c := ResolveActionCandidate(dec, nil) + if !c.ActionResolved() { + t.Fatal("expected resolved candidate") + } + if c.Fn != "restart" { + t.Errorf("Fn = %q, want restart", c.Fn) + } + if len(c.Args) != 1 || c.Args[0] != "nginx" { + t.Errorf("Args = %v, want [nginx]", c.Args) + } + if c.Source != ActionSourceRoute { + t.Errorf("Source = %q, want route", c.Source) + } +} + +// TestResolveActionCandidate_MatcherSource pins that an act without Fn +// invokes the matcher and produces a candidate from it. +func TestResolveActionCandidate_MatcherSource(t *testing.T) { + m := DefaultActMatcher{Fns: []string{"restart", "stop"}} + dec := Decision{ + Intent: IntentAct, + Slots: Slots{Text: "restart nginx"}, + } + c := ResolveActionCandidate(dec, m) + if !c.ActionResolved() { + t.Fatal("expected resolved candidate") + } + if c.Fn != "restart" { + t.Errorf("Fn = %q, want restart", c.Fn) + } + if c.Source != ActionSourceMatcher { + t.Errorf("Source = %q, want matcher", c.Source) + } +} + +// TestResolveActionCandidate_MatcherMiss pins that a matcher miss produces +// an unresolved candidate. +func TestResolveActionCandidate_MatcherMiss(t *testing.T) { + m := DefaultActMatcher{Fns: []string{"restart", "stop"}} + dec := Decision{ + Intent: IntentAct, + Slots: Slots{Text: "deploy the thing"}, + } + c := ResolveActionCandidate(dec, m) + if c.ActionResolved() { + t.Fatal("expected unresolved candidate") + } + if c.Fn != "" { + t.Errorf("Fn = %q, want empty", c.Fn) + } + if c.Source != "" { + t.Errorf("Source = %q, want empty", c.Source) + } +} + +// TestResolveActionCandidate_NonAct pins that a non-act decision produces +// an empty candidate. +func TestResolveActionCandidate_NonAct(t *testing.T) { + dec := Decision{ + Intent: IntentFact, + Slots: Slots{Key: "water", Value: "drank", HasKey: true}, + } + c := ResolveActionCandidate(dec, nil) + if c.ActionResolved() { + t.Fatal("expected unresolved candidate for non-act") + } +} + +// TestResolveActionCandidate_Stage0Match pins that a stage-0 act (which +// sets HasFn=true) produces a route-sourced candidate. +func TestResolveActionCandidate_Stage0Match(t *testing.T) { + dec := Decision{ + Intent: IntentAct, + Stage: 0, + Confidence: 1.0, + Slots: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, + Producer: RouteProducerGrammar, + } + c := ResolveActionCandidate(dec, nil) + if !c.ActionResolved() { + t.Fatal("expected resolved candidate") + } + if c.Source != ActionSourceRoute { + t.Errorf("Source = %q, want route", c.Source) + } + if c.Producer != RouteProducerGrammar { + t.Errorf("Producer = %q, want grammar", c.Producer) + } + if c.Confidence != 1.0 { + t.Errorf("Confidence = %f, want 1.0", c.Confidence) + } +} + +// TestResolveActionCandidate_LearnedRouterNoFn pins that a learned-router +// act without Fn falls through to the matcher. +func TestResolveActionCandidate_LearnedRouterNoFn(t *testing.T) { + m := DefaultActMatcher{Fns: []string{"restart", "stop"}} + dec := Decision{ + Intent: IntentAct, + Stage: 1, + Confidence: 0.85, + Slots: Slots{Text: "restart the server"}, + Producer: RouteProducerLLM, + } + c := ResolveActionCandidate(dec, m) + if !c.ActionResolved() { + t.Fatal("expected resolved candidate from matcher fallback") + } + if c.Fn != "restart" { + t.Errorf("Fn = %q, want restart", c.Fn) + } + if c.Source != ActionSourceMatcher { + t.Errorf("Source = %q, want matcher", c.Source) + } +} + +// TestResolveActionCandidate_AliasMatch pins that aliases resolve through +// the matcher path. +func TestResolveActionCandidate_AliasMatch(t *testing.T) { + m := DefaultActMatcher{ + Fns: []string{"restart"}, + Aliases: map[string][]string{"restart": {"перезагрузи"}}, + } + dec := Decision{ + Intent: IntentAct, + Slots: Slots{Text: "перезагрузи роутер"}, + } + c := ResolveActionCandidate(dec, m) + if !c.ActionResolved() { + t.Fatal("expected resolved candidate from alias match") + } + if c.Fn != "restart" { + t.Errorf("Fn = %q, want restart", c.Fn) + } + if len(c.Args) != 1 || c.Args[0] != "роутер" { + t.Errorf("Args = %v, want [роутер]", c.Args) + } +} diff --git a/internal/router/eval/reach.go b/internal/router/eval/reach.go index efbc9e7..8ad60a0 100644 --- a/internal/router/eval/reach.go +++ b/internal/router/eval/reach.go @@ -121,7 +121,7 @@ var PraxisAliases = map[string]string{ // 1. A clarified act with a named entity target and no fn reaches Hexis before the clarify // question is ever asked. That path runs on the raw slots, so the matcher // does not get to fill fn first. -// 2. Otherwise the act matcher may earn a fn from the text slot. +// 2. Otherwise the act resolver produces an ActionCandidate from the route or matcher. // 3. A fn that is a Praxis capability alias dispatches to Praxis. // 4. An act with a named entity target reaches Hexis. // 5. Anything else stays inside Maven. @@ -137,14 +137,9 @@ func Reach(d router.Decision, m router.ActMatcher) (Service, string) { } return ServiceNone, "" } - fn, hasFn := d.Slots.Fn, d.Slots.HasFn - if !hasFn && d.Slots.Text != "" && m != nil { - if matched, _, ok := m.Match(d.Slots.Text); ok { - fn, hasFn = matched, true - } - } - if hasFn { - if capability, ok := PraxisAliases[strings.ToLower(strings.TrimSpace(fn))]; ok { + candidate := router.ResolveActionCandidate(d, m) + if candidate.ActionResolved() { + if capability, ok := PraxisAliases[strings.ToLower(strings.TrimSpace(candidate.Fn))]; ok { return ServicePraxis, capability } }