Skip to content

symfonic.core.tool_palette_resolver

tool_palette_resolver

Tool-palette resolution capability — v7.11.0 role-aware tool gating.

Background

v7.9.0 introduced FrameworkConfig.role_models so adopters can route the action role through a different model than the default (e.g. gpt-4.1-nano for action turns while the default stays on claude-opus for reasoning). Jarvio surfaced Finding 5: when the action role is on a smaller non-Anthropic model, exposing the FULL tool palette induces the model to recursively call action tools (e.g. apply_pricing_change invokes itself), wasting tokens and stalling the conversation.

The fix is to narrow the tool palette for the active role. v7.11.0 adds FrameworkConfig.role_tools: dict[str, frozenset[str]] -- an allowlist of bare-identifier tool names per role -- consumed via the :class:ToolPaletteResolver Protocol registered in :class:BaseAgentDeps.

Architectural contract

  • Returns None to mean "no policy; bind the input tool list unchanged" -- the safe default. Returning [] would brick the agent; the resolver MUST distinguish "abstain" (None) from "deliberately empty" (which is a misconfiguration callers should surface).
  • The framework constrains the choice set; it never authors the tools themselves. Tool instances live on the registry; the Protocol takes all_tools and returns a filtered subset of THE SAME list -- it never constructs new tools.
  • Per-iteration safe (mirror v7.10 force-resolver lifecycle). The implementation may consult state freely; errors degrade silently to None rather than stall the loop.

Interaction with other levers

  • state["resolved_tools"] (v7.0.1 tool routing): the palette filter runs AFTER intent narrowing, so palette ∩ resolved_tools is the final set. Palette can only further narrow, never expand.
  • forced_tool_choice (v7.8.3 / v7.10): the force lever wins in v7.11.0 -- if the forced tool is excluded from the palette, the react node adds it back and emits a one-time WARN. Silently dropping a forced tool would resurrect the zero-tool-call probe failures that v7.8.3 was designed to close.
  • always_include_tools (FrameworkConfig.always_include_tools): applied AFTER the palette filter, so adopter-mandated always-on tools survive role narrowing.

ToolPaletteResolver

Bases: Protocol

Live, per-iteration resolver for the role-aware tool palette.

Registered in :class:symfonic.core.deps.BaseAgentDeps by the engine. The react node calls resolve(state, role, all_tools) on every iteration BEFORE binding tools to the LLM; the return value (a subset of all_tools or None) is forwarded to bind_tools.

Adopters who need a non-static palette policy (e.g. tenant-aware routing, per-conversation gating) can register their own implementation via deps.register(ToolPaletteResolver, impl) -- the engine- registered default that reads :attr:FrameworkConfig.role_tools will be replaced.

resolve async

resolve(
    state: dict[str, Any], role: str, all_tools: list[Any]
) -> list[Any] | None

Return the role-appropriate subset of all_tools, or None to mean "no policy; pass through unchanged".

Parameters

state: The current LangGraph node state. Adopters can read tenant / scope keys for dynamic policies. role: The canonical role name from :mod:symfonic.core.roles (e.g. ACTION). v7.11.0 only the react node calls with ACTION; future call sites will pass their own roles. all_tools: The full tool list the node would have bound absent any palette policy. Already narrowed by v7.0.1 intent routing (state["resolved_tools"]) when applicable.

Returns

list[Any] | None A subset of all_tools (NOT new tool instances), or None for "no policy". Returning an empty list signals a deliberate (and unsafe) "expose nothing" policy; the react node will detect this and abstain to all_tools with a WARN.

Source code in src/symfonic/core/tool_palette_resolver.py
async def resolve(
    self,
    state: dict[str, Any],
    role: str,
    all_tools: list[Any],
) -> list[Any] | None:
    """Return the role-appropriate subset of ``all_tools``, or
    ``None`` to mean "no policy; pass through unchanged".

    Parameters
    ----------
    state:
        The current LangGraph node state.  Adopters can read
        tenant / scope keys for dynamic policies.
    role:
        The canonical role name from :mod:`symfonic.core.roles`
        (e.g. ``ACTION``).  v7.11.0 only the react node calls
        with ``ACTION``; future call sites will pass their own
        roles.
    all_tools:
        The full tool list the node would have bound absent any
        palette policy.  Already narrowed by v7.0.1 intent
        routing (``state["resolved_tools"]``) when applicable.

    Returns
    -------
    list[Any] | None
        A subset of ``all_tools`` (NOT new tool instances), or
        ``None`` for "no policy".  Returning an empty list
        signals a deliberate (and unsafe) "expose nothing"
        policy; the react node will detect this and abstain to
        ``all_tools`` with a WARN.
    """
    ...