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
Noneto 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_toolsand 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
statefreely; errors degrade silently toNonerather 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
¶
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.