symfonic.core.contracts.resolvers¶
resolvers ¶
v7.23 — ModelResolver Protocol + DispatchContext MVP.
Adopter-supplied per-iteration model dispatch hook. Mirrors the v7.10
ForcedToolChoiceResolver precedent at
src/symfonic/core/force_resolver.py: the react node looks up the
resolver via :class:BaseAgentDeps and calls it ONCE per iteration
before provider.get_chat_model(...). Returning None means
"use the snapshot default" (AgentConfig.model).
T-7.23.1 + T-7.23.2 land together in this file because the Protocol return type is tightly coupled with the context shape -- splitting them would require a stub context for protocol satisfaction tests.
Closed decisions (round-2 architect verdict, 2026-06-08; see plan §11):
- LOCKED 7-field MVP. NO procedure_id, turn_index,
intent_verdict, or intent_tags. Adopter workarounds per
non-field in the v7.23 plan §2.b table.
- cache_state is in-memory per-engine-instance. Cross-worker
coordination is out-of-scope-for-framework (v8.0 backlog §7).
- Resolver consults a SINGLE role field today (always "action").
Extension to other sites (reflection / public get_chat_model)
is v8.0 backlog §6.
DispatchContext
dataclass
¶
DispatchContext(
role: str,
messages: Sequence[BaseMessage],
scope: TenantScope,
forced_tool_choice: str | None,
resolved_tool_names: tuple[str, ...] | None,
cache_state: Mapping[str, datetime],
run_id: str,
turn_hop_index: int = 0,
)
State snapshot passed to the role_model_resolver per iteration.
v7.23 shipped a LOCKED 7-field MVP (round-2 architect verdict,
2026-06-08). v8.3.0 added an 8th field, turn_hop_index, on the
formal request of a production adopter (hop-aware thinking-gating).
Further fields require explicit adopter justification + a
state-propagation slice (see .agent/team/v8.0-backlog.md §5).
Fields:
role-- always"action"in v7.23 (only the react brain LLM dispatch site consults the resolver). Reflection (engine.py:7866), force-introspection (engine.py:2649-- v7.23 SWAPS to the resolver), and publicget_chat_model("action")continue to read from staticrole_models.messages-- the conversation history the react node is about to send. TypedSequence[BaseMessage]so adopters can iterate without coupling tolist.scope-- the :class:TenantScoperebuilt from state via the v7.21from_state_dict.forced_tool_choice-- bare tool name when force is on,Noneotherwise. Mirrorsstate["forced_tool_choice"].resolved_tool_names-- intent-narrowed tool list when intent_filter is on,Noneotherwise.cache_state-- per-engine-instance map ofmodel_name -> last cache_creation timestamp. In-memory; does NOT cross worker boundaries. TypedMappingso adopters can't mutate.run_id-- per-run UUID for correlation in adopter logging.turn_hop_index(v8.3.0) -- the number of assistant (AI) message turns the model has already produced since the start of the CURRENT user turn.0on the first model call of a turn, incrementing by one per react hop WITHIN the turn, reset to0when a new user turn begins. Enables hop-aware routing: e.g. think on the first hop (ctx.turn_hop_index == 0) and skip thinking on later mechanical hops. Distinct from the v7.15.0iteration_index(which counts GLOBAL AIMessages across the whole conversation and so accumulates across turns). Turn boundary = the LASTHumanMessageinmessages(the genuine current user turn); tool results (ToolMessage) and ask_user / interrupt resume (alsoToolMessage) do NOT reset it. Additive (v8.3.0); defaults to0so existing constructors keep working.
ModelResolver ¶
Bases: Protocol
Adopter-supplied hook called per-iteration to pick the model for THIS turn.
Mirrors the v7.10 ForcedToolChoiceResolver precedent: registered
into :class:BaseAgentDeps, called by the react node BEFORE
provider.get_chat_model(...). Returning None means "use the
snapshot default" (AgentConfig.model from agent build).
Adopters typically construct this as a factory closure at agent build time::
def make_resolver(
agent: SymfonicAgent,
classifier: Classifier,
) -> ModelResolver:
def resolve(ctx: DispatchContext) -> Optional[ModelConfig]:
if classifier.is_simple(ctx.messages[-1].content):
return CHEAP_MODEL_CONFIG
return None
return resolve
framework_config = FrameworkConfig(
role_model_resolver=make_resolver(agent, clf),
)
The Protocol body is intentionally MINIMAL -- only __call__ --
so runtime_checkable instance checks stay reliable. This is
the v7.19.3 lesson applied: complex Protocol bodies broke
isinstance because runtime_checkable only inspects method
names, not signatures.