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.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user