Skip to content

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 public get_chat_model("action") continue to read from static role_models.
  • messages -- the conversation history the react node is about to send. Typed Sequence[BaseMessage] so adopters can iterate without coupling to list.
  • scope -- the :class:TenantScope rebuilt from state via the v7.21 from_state_dict.
  • forced_tool_choice -- bare tool name when force is on, None otherwise. Mirrors state["forced_tool_choice"].
  • resolved_tool_names -- intent-narrowed tool list when intent_filter is on, None otherwise.
  • cache_state -- per-engine-instance map of model_name -> last cache_creation timestamp. In-memory; does NOT cross worker boundaries. Typed Mapping so 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. 0 on the first model call of a turn, incrementing by one per react hop WITHIN the turn, reset to 0 when 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.0 iteration_index (which counts GLOBAL AIMessages across the whole conversation and so accumulates across turns). Turn boundary = the LAST HumanMessage in messages (the genuine current user turn); tool results (ToolMessage) and ask_user / interrupt resume (also ToolMessage) do NOT reset it. Additive (v8.3.0); defaults to 0 so 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.