Files
Maven/internal/router/actioncandidate.go
T

172 lines
6.1 KiB
Go

package router
import "strings"
// 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 != "" }
// --- structural validation ---
// ActionField identifies a structural field of ActionCandidate for validation
// reporting. Kept as a string enum so callers can switch on known values
// without importing a large set.
type ActionField string
const (
FieldFn ActionField = "fn"
FieldArgs ActionField = "args"
)
// ActionValidationIssue records one structural problem found by
// ValidateActionCandidate. Field names which field; Reason is a short
// machine-readable tag, not a human sentence.
type ActionValidationIssue struct {
Field ActionField
Reason string
}
// ActionValidationResult is the typed output of ValidateActionCandidate.
// Three disjoint outcomes:
//
// - Unresolved: Fn is empty — the matcher did not match. The caller should
// route to proposeGap / clarification, not treat this as a structural error.
// - Valid: Fn is non-empty and the candidate is structurally admissible for
// the current execution path.
// - Invalid: Fn is non-empty but the candidate is structurally malformed.
// The caller should refuse execution.
type ActionValidationResult struct {
Unresolved bool
Valid bool
Issues []ActionValidationIssue
}
// 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,
}
}
// ValidateActionCandidate checks whether a resolved ActionCandidate is
// structurally admissible for the current execution path. It answers only
// shape/completeness — not trust, confidence, risk, or semantic correctness.
//
// Three outcomes:
// - Unresolved (Fn empty): the matcher missed. Caller routes to
// proposeGap / clarification. This is NOT a validation error.
// - Valid (Fn non-empty, structure sound): candidate may proceed to
// risk policy and execution.
// - Invalid (Fn non-empty but malformed): candidate must not execute.
//
// Validation does not re-run routing, intent classification, matcher
// resolution, language parsing, or entity inference.
func ValidateActionCandidate(c ActionCandidate) ActionValidationResult {
// Unresolved: Fn is empty. The matcher did not match, or the decision
// was not an act. This flows into the existing propose-gap / proposal
// path and must NOT be treated as a structural error.
if c.Fn == "" {
return ActionValidationResult{Unresolved: true}
}
var issues []ActionValidationIssue
// Fn is present but must be a non-blank identifier. The route and
// matcher both produce bare function names; whitespace-only or
// control-character Fn would be a structural defect in the producer.
if strings.TrimSpace(c.Fn) == "" {
issues = append(issues, ActionValidationIssue{
Field: FieldFn,
Reason: "blank_function_name",
})
}
if len(issues) > 0 {
return ActionValidationResult{Issues: issues}
}
return ActionValidationResult{Valid: true}
}