Documentation
¶
Overview ¶
Package researchroute implements the route_search component from ADR-045 Phase 1 (PR 3 of six per docs/operations/22-adr045-phase1-plan.md).
route_search is the first LLM-judgment stage of the graph-search rule chain. It receives a publish trigger on component.route_search.<loop_id>, reads the upstream research.Intent + research.ClassifierOutput payloads from AGENT_LOOPS, builds a structured-emit prompt, calls the configured research_routing LLM endpoint, parses the model's JSON output into a research.RouteDecision, validates per-action arg shape, and writes the RouteDecision envelope plus a route.complete.<loop_id> trigger key that R2 (PR 6) watches to dispatch the next stage.
Architectural notes:
LLM-wrapping component, not agent-wrapping. Direct LLM call via the configured CapabilityResearchRouting endpoint; the chain does not delegate decision-making to a free-form ReAct loop.
Prompt is structured per discipline memory feedback_persona_prose_needs_decision_criteria: per-action purpose framing + decision-axis framing (no magic-number thresholds) + concrete examples for non-obvious choice points (decompose vs walk_seeds vs retighten) + negative-shape definition for synthesize_directly.
Args emitted by the model follow intent-not-structure (ADR-045 Amendment 2026-05-23): decompose carries axes/focus/ scope; walk_seeds carries seed references (name/partial_id/ candidate_index), not full 6-part entity IDs. Backend (execute_subqueries, PR 4) resolves to typed sub-queries and full IDs.
All public methods are safe for concurrent use across loops; the component holds no per-call mutable state. Same pattern as research-graph-classify.
Index ¶
- Constants
- func NewProcessor(rawConfig json.RawMessage, deps component.Dependencies) (component.Discoverable, error)
- func Register(registry *component.Registry) error
- type Component
- func (c *Component) ConfigSchema() component.ConfigSchema
- func (c *Component) DataFlow() component.FlowMetrics
- func (c *Component) Health() component.HealthStatus
- func (c *Component) Initialize() error
- func (c *Component) InputPorts() []component.Port
- func (c *Component) Meta() component.Metadata
- func (c *Component) OutputPorts() []component.Port
- func (c *Component) Start(ctx context.Context) error
- func (c *Component) Stop(timeout time.Duration) error
- type Config
- type LoopStore
- type Router
Constants ¶
const ( // DefaultRouteTimeout caps the structured-emit LLM call. Generous // enough for slower self-hosted endpoints; operators can tighten // for faster hosted models. DefaultRouteTimeout = 30 * time.Second // DefaultMaxResponseTokens caps the LLM's response budget. The // JSON-shaped output is small (action + args + rationale); 512 is // well above the worst case for the four action shapes. DefaultMaxResponseTokens = 512 // DefaultMaxCandidatesInPrompt caps how many ClassifierOutput // candidates the prompt embeds. The router doesn't need the full // candidate list to decide — the top-N by relevance is enough — // and a tight cap keeps the prompt fitting frontier-tier and // small-model context windows alike. DefaultMaxCandidatesInPrompt = 10 )
Default knobs surfaced as exported constants so the prompt-builder tests and operator docs can reference them by name rather than duplicating literals.
const ComponentName = "research-graph-route"
ComponentName is the canonical registry name + log subsystem.
Variables ¶
This section is empty.
Functions ¶
func NewProcessor ¶
func NewProcessor(rawConfig json.RawMessage, deps component.Dependencies) (component.Discoverable, error)
NewProcessor is the component-factory shape registered with the component registry. Parses + validates config, applies defaults, and constructs the Component with the injected production adapters. The LLM router is wired in Start() because the model registry isn't available at construction time.
Types ¶
type Component ¶
type Component struct {
// contains filtered or unexported fields
}
Component implements the route_search processor. Struct field set is intentionally small; lifecycle methods own the NATS / model- registry plumbing and the per-message handler hands off to the pure routeDecision function in handler.go.
func (*Component) ConfigSchema ¶
func (c *Component) ConfigSchema() component.ConfigSchema
ConfigSchema implements Discoverable.
func (*Component) DataFlow ¶
func (c *Component) DataFlow() component.FlowMetrics
DataFlow implements Discoverable.
func (*Component) Health ¶
func (c *Component) Health() component.HealthStatus
Health implements Discoverable.
func (*Component) Initialize ¶
Initialize is part of the LifecycleComponent contract. route_search has nothing to initialise pre-Start — bucket open + LLM client wire happen in Start so they can fail loudly and propagate.
func (*Component) InputPorts ¶
InputPorts implements Discoverable.
func (*Component) OutputPorts ¶
OutputPorts implements Discoverable. route_search has no NATS- publishing output port: emits are KV writes to AGENT_LOOPS.
type Config ¶
type Config struct {
Ports *component.PortConfig `` /* 167-byte string literal not displayed */
LoopsBucket string `` /* 175-byte string literal not displayed */
RouteTimeout time.Duration `` /* 212-byte string literal not displayed */
MaxResponseTokens int `` /* 222-byte string literal not displayed */
MaxCandidatesInPrompt int `` /* 286-byte string literal not displayed */
}
Config holds operator-tunable knobs for the route_search component.
LLM wiring follows the same model-registry capability seam as processor/research-graph-classify: operator declares CapabilityResearchRouting in the model registry; the component resolves it at Start() time. Absence is a startup error — unlike the optional LLM tier on nl_classify, route_search has no keyword-only fallback (its entire job is the LLM judgment).
func DefaultConfig ¶
func DefaultConfig() Config
DefaultConfig returns a default Config skeleton with the standard route_search input port.
func (*Config) ApplyDefaults ¶
func (c *Config) ApplyDefaults()
ApplyDefaults fills in defaults for unset fields.
type LoopStore ¶
type LoopStore interface {
// GetIntent loads the research_intent payload from the
// research.requested.<loopID> key. Returned for the prompt's
// topic + hints context.
GetIntent(ctx context.Context, loopID string) (*research.Intent, error)
// GetClassifierOutput loads the upstream ClassifierOutput from
// the classify.complete.<loopID> trigger key. The router needs
// the candidate set, hints, and confidence to make its decision.
GetClassifierOutput(ctx context.Context, loopID string) (*research.ClassifierOutput, error)
// PutRouteDecision writes the RouteDecision envelope at R2's
// trigger key route.complete.<loopID>.
PutRouteDecision(ctx context.Context, loopID string, envelope []byte) error
// PutSnapshot writes the envelope at a stable non-trigger key
// route.snapshot.<loopID> so operators / downstream queryability
// can read the full decision without racing R2's wildcard
// watcher. Same pattern as research-graph-classify.
PutSnapshot(ctx context.Context, loopID string, envelope []byte) error
}
LoopStore is the AGENT_LOOPS read/write surface this component consumes. Production wraps natsclient.KVStore; tests substitute an in-memory map.
type Router ¶
type Router interface {
Route(ctx context.Context, systemPrompt, userPrompt string, maxResponseTokens int) (content string, reason string, err error)
}
Router is the narrow LLM surface this component consumes. Production satisfies it via llmRouterAdapter wrapping a real graph/llm.Client; tests substitute a fake that returns a deterministic raw JSON response per scenario without standing up any LLM infrastructure.
Route returns the raw response Content + a short reason string the adapter can use for error diagnostics (model identifier, finish reason, etc.). The handler parses the Content as JSON into a RouteDecision and validates separately so prompt iteration and response-shape drift surface as separate test failures.