LLM invocation surfaces — the router-style tool contract¶
Status: partially stale. This page originally compared two invocation kinds: the chat router (function-calling tools) and the phase executor inside a now-deleted workflow engine (a JSON
control/artifact/control_iroutput contract, deleted in a later engine-deletion arc. The phase-style surface and every section that compared against it (the capability matrix, the four divergence types, the doctrine options for closing router/phase gaps) described a comparison that no longer has two sides — confirmed via direct grep thatOSRuntimeand thecontrol/artifact/control_irenvelope do not exist in current source. Those sections have been removed. Section 4 (the unifiedToolRegistryimplementation log) remains accurate — it documents the still- current architecture — except that itsgates(phase=...)references describe a field that no longer exists: the #2696 drift-audit deletedToolGates.phase,ToolRegistry.for_phase()andToolDefinition.render_for_phase()outright, soToolGatesnow has a singlerouteraxis. Read §4'sphase=mentions as a historical record of what was registered at the time, not as current fields. §2.1's tool-inventory description has been corrected twice now: first from the dead "13 always-present + conditional" per-kind list to an (incorrect) claim that the universal-action- catalog wrapper mode is the chat layer's production default; that second version was itself wrong — verified viasrc/reyn/config/execution.py'sToolUseConfig(scheme: str = "enumerate-all",transport: str = "tool_calls"— FP-0066 P4b, #3247) andsrc/reyn/tools/scheme.py'sDEFAULT_SCHEME_NAME = "enumerate-all"that tool presentation is a pluggable scheme (tool_use.schemextool_use.transportinreyn.yaml), and the chat layer's actual default isenumerate-all(flat presentation, not the universal wrapper) — a deliberate owner-driven fix (flat listing preventsinvoke_actionname-hallucination, 30%→100% direct tool-use accuracy in evidence). The chat-layer scheme selection generalizes the olderuniversal_wrappers_enabledflag (moved fromaction_retrieval:totool_use:, #4552 PR-3+4), but does not retire it — the flag remains a live presentation sub-flag of theuniversal-categoryscheme (catalog-wrapper vs direct-tool). (#2768 removed the dead phase-graph-erastep/phasetool-use layers.)docs/feature-map.md's Tool-Use Schemes section (already correct throughout this arc) is the canonical source this page now matches.
1. Why this matters¶
Reyn invokes the LLM via native function-calling tools over RouterLoop (interactive chat sessions), assembled per-context by a RouterLoopHost facade. This document names that invocation surface and its tool inventory.
2. The router invocation surface¶
2.1 Router-style (chat)¶
Used by: RouterLoop (interactive chat sessions), with a RouterLoopHost facade that narrows the catalog per context.
Mechanism: native LLM function calling via call_llm_tools (backed by litellm). Tool definitions follow the OpenAI tools array shape; the model replies with tool_calls in the assistant message. The OS dispatches each call, appends the tool_result, and re-invokes the LLM until it produces a plain text reply.
Tool surface: build_tools() in src/reyn/runtime/router_tools.py assembles the tool list, returning the OpenAI tools array shape — but which shape that list takes is a pluggable scheme, not a single fixed format. tool_use.scheme (presentation) x tool_use.transport (how actions are expressed) in reyn.yaml resolves to a registered ToolUseScheme for the chat layer; every scheme routes every tool call through the same exclude → permission → dispatch gate regardless of which is active. See Tool-Use Schemes and Universal Action Catalog for the full model.
- The
chatlayer (this page'sRouterLoopsurface) defaults toenumerate-all— a flat-native-JSON baseline that presents every usable tool flatly intools=and dispatches by name, no universal-wrapper discovery indirection. This is a deliberate owner-driven default (an H1 fix): flat listing stopsinvoke_actionname-hallucination, with 30%→100% direct tool-use accuracy as the evidence. retrieval(RAG-over-tools) andCodeActare supported, opt-in chat-layer schemes, for very large tool sets or weak models respectively.- The chat layer's scheme is operator-configurable via
tool_use.schemeinreyn.yaml— an operator can, for example, opt the chat layer intouniversal-categoryinstead of theenumerate-alldefault.
Role: orchestration — pick the next sub-component (workflow, agent, plan, memory operation, direct text reply).
3. See also¶
- ../architecture/care-boundary.md — what Reyn does and does not own
- ../../reference/runtime/control-ir.md — the OS-dispatched op vocabulary
- ../../reference/cli/chat.md — slash commands available in chat (sometimes confused with router tools; they are distinct)
- ../../reference/cli/mcp.md — MCP server side (Reyn-as-MCP-server exposes a third surface that is NOT covered here because it is external clients calling INTO Reyn, not Reyn's internal LLM invocation surface)
4. Implementation: unified registry (ADR-0026 Accepted)¶
The dual-implementation architecture this ADR closed (two separate catalogs:
router_tools.py / OP_KIND_MODEL_MAP, back when a phase-side surface existed
too) is the historical baseline. ADR-0026 closes the structural drift by
introducing a single ToolDefinition per capability with two render methods
(one of which — the phase-side render — is now vestigial per the status note
above).
M1 (landed — commit edd4c1b): The infrastructure module src/reyn/tools/ is in place:
ToolDefinition,ToolGates,ToolContext,ToolHandler,ToolResult— insrc/reyn/tools/types.pyToolRegistry— insrc/reyn/tools/registry.pyinvoke_tool,ToolNotFound,ToolGateRefused— insrc/reyn/tools/dispatch.py
M2 POC (landed — commit 367b41c): web_search is the first capability
migrated to the unified registry. src/reyn/tools/web_search.py contains the
WEB_SEARCH ToolDefinition instance and a thin adapter wrapping the legacy
handle_web_search handler. build_tools() now derives web_search from the
registry via render_for_router(), producing byte-identical output to the prior
ToolSpec literal (LLMReplay fixtures unchanged). All M2 verification gates
passed: byte-identity GREEN, drift test GREEN, full suite 1500 passed / 2
xfailed, mkdocs strict empty.
M3 Wave 1 (landed — commit ba4c5fe): 7 capabilities migrated:
web_fetch, shell, lint, ask_user, delegate_to_agent, plan,
reyn_repo_list, reyn_repo_read. ToolDefinition gains a dispatch_kind
field. +99 Tier 2 invariants.
M3 Wave 2 (landed — commit 66435d1): 17 capabilities migrated —
file ops × 4 / MCP ops × 3 / memory ops × 5 / catalog ops × 4 /
invoke_skill. All 3 Type C convention-drift gaps identified in §4 are
declaratively closed via gates(router=allow, phase=allow): memory write
phase-side, catalog browse phase-side, MCP discover phase-side. +127 Tier 2
invariants. LLMReplay fixtures preserved across all migrations. Sanity check
via live reyn web A2A endpoint confirmed zero real-LLM regression.
All 13 capability clusters (= 26 ToolDefinitions) are registered in the unified
ToolRegistry. Type C convention-drift gaps identified in §4 are declaratively
closed via gates(router=allow, phase=allow). Phase-side Control IR dispatch
wiring to consume the registry is M4 cleanup work.
M4 Phase 2 (landed): ToolContext expansion — router_state and phase_state
are now typed sub-objects (RouterCallerState / PhaseCallerState) instead of
loose Any, resolving ADR-0026 Open Question #3. All fields default to None
for gradual migration. +7 Tier 2 invariants.
M4 Phase 3 step 1 (landed): handler activation + per-call schema enrichment
hook. The 6 design-revisit NotImplementedError stubs (4 catalog +
delegate_to_agent + plan) are activated to delegate via the typed
RouterCallerState callable fields. RouterCallerState gains 4 new callable
fields (list_skills_fn, describe_skill_fn, list_agents_fn,
describe_agent_fn). ToolDefinition gains an optional schema_enricher hook
invoked by render_for_router(state=...) to inject per-session dynamic data
(canonical use: invoke_skill.name / delegate_to_agent.to enums). The 2
remaining inline ToolSpec literals in router_tools.py (= invoke_skill +
delegate_to_agent) are migrated to registry consumption with the new hook,
preserving byte-identity. Mis-wiring contract: handlers raise RuntimeError
with a descriptive message when the dispatcher fails to populate the required
callable. +29 Tier 2 invariants. 1754 passed / 2 xfailed.
M4 Phase 3 step 2 (landed — commit 649a426):
RouterLoop._invoke_router_tool dispatches the 6 activated tools (catalog
×4 + delegate_to_agent + plan) through invoke_tool(get_default_registry(), ...)
instead of the legacy if/elif tree. RouterLoop._build_router_caller_state
populates a RouterCallerState with bound callbacks. Catalog list-handler
return shape relaxed to bare list (= LLMReplay byte-identity preserved).
Legacy A1–A4 / B2 / G branches in _invoke_router_tool removed.
M4 Phase 4 step 1 (landed): _DISPATCH_KIND sidecar dict /
_TOOL_SPECS_STATIC_ASYNC removed from router_tools.py;
get_dispatch_kind(name) consults ToolDefinition.dispatch_kind from the
registry directly. The registry is now the canonical source for both schema
rendering AND dispatch posture classification.
M4 Phase 3.5 (landed — 5 commits 0093667 / 2b1fe8d / 3378051 /
a58c685 / 7482b33): router-side cluster activations complete.
All 18 remaining tools (file ×4 / mcp ×3 / memory ×5 / web ×2 /
reyn_repo ×2 / invoke_skill) now dispatch through
invoke_tool(get_default_registry(), ...). Per-tool design issues
identified in the migration audit were addressed with three bridge
patterns on RouterCallerState:
op_context_factory: Callable | None— RouterLoop bindshost.make_router_op_contextso file / mcp / web handlers receive the operator-declared PermissionDecl + Workspace, matching the legacy router branch.host: Any— duck-typed RouterHostAdapter reference for MCP handlers that preserve the session-level MCPClient cache.- Per-tool callable bridges (
run_skill_fn,list_memory_fn) — bound to RouterLoop's private helpers so chain_id propagation (invoke_skill) and the agent-aware combined memory listing (list_memory) are preserved. memory_service(#3607) — the session'sMemoryService, passed whole.read_memory_body/remember_*/forget_memorydelegate to its methods. They used to be three more callable bridges onto RouterLoop privates that assembled the operations out of the host's file primitives; the operations, and the domain rules they carry (memory-write threat scan, frontmatter, index regeneration, knowledge ingest), belong to the memory layer.
RouterLoop._invoke_router_tool is now a thin top-branch (registry
dispatch) plus a comment placeholder for future clusters.
_normalise_router_tool_result adapts handler return shapes (= dict
envelopes from op_runtime synthesis) back to the bare-string /
bare-list shapes the legacy router branches emitted to the LLM,
preserving LLMReplay byte-identity end-to-end through all 5 cluster
migrations.
M4 Phase 4 (landed): phase-side migration completes the architectural goal.
- Phase 4 step 1 (commit
ebe5786) —_DISPATCH_KINDsidecar dict removed;get_dispatch_kind()readsToolDefinition.dispatch_kindfrom the registry. - Phase 4 step 2 — coarse-name
FILE_OP/MCP_OP/RUN_SKILL_OPToolDefinitions registered withgates(phase="allow")so phase Control IRkindvalues map 1:1 to registry entries.ControlIRExecutor.execute()dispatches viainvoke_tool(get_default_registry(), op.kind, ...). Catalog building (_build_phase_tool_catalog) reads schemas from the registry. - Phase 4 step 3 —
OP_KIND_MODEL_MAPretained as the op-kind reference (= linterALL_OP_KINDS,OP_PURITYcoverage); no longer consulted at dispatch time.op_runtime/<kind>.pyhandlers retained as the shared implementation that registry handlers delegate to. is_op_allowedhelper (removed, #2890 F9) — this prefix-wildcardallowed_opsmembership helper's only consumer,control_ir_executor, was removed in #2438 (#2434 stage3b kernel phase-engine bulk-delete); the helper itself (plusis_op_instance_allowedand theCOARSE_TO_FINEtable) was dead code with zero remaining call sites and was removed fromop_runtime/registry.py.
#1240 (the 2-axis tool-model pivot) superseded the coarse phase-side dispatch. The phase catalog and Control IR now use the fine-grained chat-tools subset directly:
- Catalog axis — the coarse
FILE_OP/MCP_OP/RUN_SKILL_OPphase ToolDefinitions were dropped; phases advertise the fine file kinds (read_file…grep_files) plusinvoke_skill/call_mcp_tool(the chat-tool names, aliased back to therun_skill/mcpkinds at the parse boundary).OP_KIND_MODEL_MAPnow holds the fine file kinds; the coarse"file"kind was removed (theFileIROpmodel survives only as the shared execution backend). - Phase
allowed_opsdefaults migrated to the fine kinds, so phase Control IR now emits fine kinds — the earlier "still emits coarse kinds today" caveat no longer holds.
Tool addition cost at the steady state: 1 file in
src/reyn/tools/<name>.py + 1 register call in __init__.py = 2 touch
points for a router-or-phase tool. New phase-side coarse op kinds
additionally need an OP_KIND_MODEL_MAP entry (linter / purity
coverage) and a Pydantic IROp model in schemas/models.py =
3-touch-point budget for a fully phase-eligible new kind. This is the
baseline future tool-scope expansion amortises against.
ADR-0026 is now Accepted.
Cross-reference: ../../deep-dives/decisions/0026-unified-tool-registry.md