Skip to content

symfonic.core.nodes.tool_selection

tool_selection

Legacy-side wiring for the tool-selection capability (T3.1.2).

The react node used to compose the selection levers inline: ninety lines that resolved a forced tool_choice, released it against history, called the role palette resolver, and re-added the forced tool the palette had just excluded. The levers are now stages in :mod:symfonic.capabilities.tools; this module is the adapter that reads them out of :class:BaseAgentDeps and states the order.

The order is the contract, so it lives here in one readable list:

  1. forced_choice โ€” decide what is forced, from the state stamp or the per-iteration resolver, releasing once history answers it.
  2. role_palette โ€” narrow to the active role's palette. It runs after the force is known, which is what lets the pipeline's protection rule put a forced tool back rather than each consumer special-casing it.

Intent routing has already run upstream (the engine writes state["resolved_tools"]), which is why it is not a stage here: the react node receives its output, not its inputs. The stage exists and narrow_tools_for_intent delegates to it.

select_tools_for_role async

select_tools_for_role(*, tools: Sequence[Any], state: Any, deps: Any, role: str) -> SelectionResult

Run the react node's selection pipeline for one iteration.

Returns the tools to bind and the tool_choice to bind them with. Never raises: every stage failure degrades to "no policy" inside the pipeline, because a selection preference must not be able to stall the loop.

Source code in src/symfonic/core/nodes/tool_selection.py
async def select_tools_for_role(
    *,
    tools: Sequence[Any],
    state: Any,
    deps: Any,
    role: str,
) -> SelectionResult:
    """Run the react node's selection pipeline for one iteration.

    Returns the tools to bind and the ``tool_choice`` to bind them with.
    Never raises: every stage failure degrades to "no policy" inside the
    pipeline, because a selection preference must not be able to stall
    the loop.
    """
    from symfonic.core.force_resolver import ForcedToolChoiceResolver
    from symfonic.core.tool_palette_resolver import ToolPaletteResolver

    palette_resolver = deps.get(ToolPaletteResolver) if deps is not None else None
    force_resolver = deps.get(ForcedToolChoiceResolver) if deps is not None else None

    pipeline = ToolSelectionPipeline(
        [
            ForcedChoiceStage(
                resolver=force_resolver, is_tool_message=_is_tool_message,
            ),
            RolePaletteStage(resolver=palette_resolver, role=role),
        ]
    )
    context = SelectionContext(
        state=state,
        messages=state.get("messages") or (),
        role=role,
    )
    return await pipeline.select(tuple(tools), context)