mirror of
https://github.com/pydantic/pydantic-ai.git
synced 2026-10-12 02:21:31 +00:00
Re-resolve tools when capability availability changes mid-step so prepare_tools still governs them (#8071)
This commit is contained in:
+2
-2
@@ -125,9 +125,9 @@ When `load_capability` succeeds:
|
||||
|
||||
Use `ctx.is_tool_available(tool_def)` when a wrapping toolset needs to decide whether a definition it holds is currently visible. The definition form remains reliable inside `get_tools`; the name form looks in the current resolved `ctx.tools` snapshot and is intended for model-request hooks and tool execution.
|
||||
|
||||
Message history matters. Loaded capability state is reconstructed from matching `LoadCapabilityCallPart` and `LoadCapabilityReturnPart` pairs, while revealed function-tool state is reconstructed from `ToolAvailabilityDeltaPart` entries. A history processor must preserve the deltas or the complete capability-load pairs from which Pydantic AI can reconstruct them. If it removes both representations, those tools become hidden again.
|
||||
Message history matters. Loaded capability state is reconstructed from matching `LoadCapabilityCallPart` and `LoadCapabilityReturnPart` pairs, while revealed function-tool state is reconstructed from `ToolAvailabilityDeltaPart` entries. A history processor must preserve the deltas or the complete capability-load pairs from which Pydantic AI can reconstruct them. If it removes both representations, those tools become hidden again. A processor that *adds* a complete load pair activates that capability for the request it is processing, not the next one: its `prepare_tools` runs before any of its tools are dispatched, so a capability using `prepare_tools` as a permission filter still governs them.
|
||||
|
||||
A `CompactionPart` resets both forms of prospective derived state at its exact position, so future requests load and reveal capability tools again. For a call in the response currently being dispatched, pre-boundary evidence still counts when the serving provider did not honor that boundary on the request wire; otherwise a call without visible load evidence is refused with a "not available yet" retry naming the capability to load.
|
||||
A `CompactionPart` resets both forms of prospective derived state at its exact position, so future requests load and reveal capability tools again. For a call in the response currently being dispatched, pre-boundary evidence still counts when the serving provider did not honor that boundary on the request wire; otherwise a call without visible load evidence is refused with a "not available yet" retry naming the capability to load. When that pre-boundary evidence does admit the call, the owning capability counts as active for that dispatch, so its `prepare_tools` and tool hooks run over the call — a capability filtering its own tools governs them on whatever evidence authorized them. `ctx.loaded_capability_ids` still reads the conservative window, so it can be empty there.
|
||||
|
||||
## Dynamic Descriptions and Instructions
|
||||
|
||||
|
||||
@@ -433,8 +433,10 @@ class GraphAgentDeps(Generic[DepsT, OutputDataT]):
|
||||
# identity survives `replace(ctx, ...)`, which shallow-copies) and are only ever mutated in
|
||||
# place — never reassigned. The per-step refresh relies on that shared identity for both, and
|
||||
# `discovered_tool_names` additionally on the in-step reveals written by tool execution.
|
||||
# `loaded_capability_ids` is refreshed from history only: a capability loaded during a step
|
||||
# lands from the next one, so nothing writes it mid-step. Reassigning either (here, or by
|
||||
# `loaded_capability_ids` is refreshed from history only: a capability the *model* loads during
|
||||
# a step lands from the next one, since its load return only reaches history at the step's end.
|
||||
# It is still refreshed a second time within the step, after history processing has rewritten
|
||||
# that history — the only way its contents move mid-step. Reassigning either (here, or by
|
||||
# passing it to a `replace(ctx, ...=...)`) would silently break in-step tool reveals.
|
||||
loaded_capability_ids: set[str]
|
||||
discovered_tool_names: set[str]
|
||||
@@ -1647,6 +1649,14 @@ class ModelRequestNode(AgentNode[DepsT, NodeRunEndT]):
|
||||
ctx.deps.resumed_request_index = shifted if shifted >= 0 else None
|
||||
# `ctx.state.message_history` is the same list used by `capture_run_messages`, so we should replace its contents, not the reference
|
||||
ctx.state.message_history[:] = messages
|
||||
|
||||
# Processing may have added or removed `load_capability` exchanges, and the durable history it
|
||||
# just rewrote is what the rest of the step reads availability from. Refresh so the execution
|
||||
# gate agrees with the reveal state `_with_outgoing_reveal_state` derives below from the same
|
||||
# messages; `ToolManager.for_run_step` re-resolves off this set at dispatch, so a capability
|
||||
# that became active here still governs its own tools through `prepare_tools`.
|
||||
_refresh_loaded_capability_ids(ctx)
|
||||
|
||||
# Update the new message index to ensure `result.new_messages()` returns the correct messages
|
||||
ctx.deps.new_message_index = _first_new_message_index(
|
||||
messages,
|
||||
@@ -1855,6 +1865,12 @@ class ModelRequestNode(AgentNode[DepsT, NodeRunEndT]):
|
||||
# replace its contents (dropping the suspended response) rather than the reference;
|
||||
# `_finish_handling` then appends the final merged response after the base history.
|
||||
ctx.state.message_history[:] = base_messages
|
||||
|
||||
# Same reason as in `_prepare_request`: processing may have changed which capabilities the
|
||||
# durable history shows as loaded, and the tool calls this continuation comes back with are
|
||||
# dispatched against that history.
|
||||
_refresh_loaded_capability_ids(ctx)
|
||||
|
||||
ctx.deps.new_message_index = _first_new_message_index(
|
||||
base_messages,
|
||||
ctx.state.run_id,
|
||||
@@ -2256,8 +2272,11 @@ class CallToolsNode(AgentNode[DepsT, NodeRunEndT]):
|
||||
# This will raise errors for any tool name conflicts
|
||||
ctx.deps.tool_manager = await ctx.deps.tool_manager.for_run_step(run_context)
|
||||
# The manager was already prepared for this same run step before the model request, so
|
||||
# `for_run_step` deliberately returns it unchanged, keeping the retries it accumulated —
|
||||
# which is why the evidence lands field by field rather than by swapping in `run_context`.
|
||||
# `for_run_step` normally returns it unchanged, keeping the retries it accumulated — which is
|
||||
# why the evidence lands field by field rather than by swapping in `run_context`. (It does
|
||||
# re-resolve when capability availability moved since preparation, e.g. a history processor
|
||||
# injected a `load_capability` exchange; that path carries the same retries through and ends
|
||||
# up holding `run_context`, whose evidence this assignment then re-applies harmlessly.)
|
||||
# Only the retrospective evidence is carried: replacing the prospective shared sets would
|
||||
# affect the next request's reveal pruning and search ranking.
|
||||
assert ctx.deps.tool_manager.ctx is not None
|
||||
|
||||
@@ -360,7 +360,8 @@ class RunContext(Generic[RunContextAgentDepsT]):
|
||||
_anchored_evidence: AnchoredEvidence = field(default_factory=lambda: AnchoredEvidence(), repr=False)
|
||||
"""Evidence the serving provider could still see that the conservative window dropped.
|
||||
|
||||
Set at tool-call dispatch and read only by `is_tool_available`. Private because the sets above
|
||||
Set at tool-call dispatch and read only through `_dispatch_active_capability_ids` (plus the
|
||||
reveal half, read by `is_tool_available` directly). Private because the sets above
|
||||
stay the answer for everything that feeds a *future* request, whose provider isn't knowable yet;
|
||||
this one is the answer for a call the model has already made, where it is. See `AnchoredEvidence`.
|
||||
"""
|
||||
@@ -496,6 +497,32 @@ class RunContext(Generic[RunContextAgentDepsT]):
|
||||
id for id, cap in self.capabilities.items() if cap.defer_loading is not True
|
||||
} | self.loaded_capability_ids
|
||||
|
||||
@property
|
||||
def _dispatch_active_capability_ids(self) -> set[str]:
|
||||
"""`active_capability_ids`, widened by the anchored evidence for the response being dispatched.
|
||||
|
||||
The single answer to "may this capability act on the call being dispatched right now?", and
|
||||
it has to be single: `is_tool_available` authorizes the call from it, and the `prepare_tools`
|
||||
dispatch gate decides from it whether the owning capability's filter runs over that tool. If
|
||||
only the first consulted the evidence, a tool authorized through a load the conservative
|
||||
window dropped would execute with its owner's `prepare_tools` never having run — the
|
||||
capability is not active, so nothing dispatched to it.
|
||||
|
||||
The evidence is narrowed to the run's configured deferred ids — the shape every load record
|
||||
has, since only a deferred capability can be loaded. Inert for both predicates above, which
|
||||
look up an id that came from a registered capability either way; it is there so that a
|
||||
history naming a capability this run no longer configures doesn't leave a permanent
|
||||
difference in `ToolManager.resolved_capability_ids` and rebuild the tools every dispatch.
|
||||
`_deferred_capability_ids` rather than `capabilities` deliberately: it crosses the Temporal
|
||||
activity boundary, where reading the live registry raises.
|
||||
|
||||
Outside tool-call dispatch `_anchored_evidence` is empty, so this is exactly
|
||||
`active_capability_ids`.
|
||||
"""
|
||||
return self.active_capability_ids | (
|
||||
self._anchored_evidence.loaded_capability_ids & self._deferred_capability_ids
|
||||
)
|
||||
|
||||
@property
|
||||
@deprecated(
|
||||
'`available_capability_ids` is deprecated, use `active_capability_ids` instead: for a '
|
||||
@@ -513,8 +540,8 @@ class RunContext(Generic[RunContextAgentDepsT]):
|
||||
def _deferred_capability_ids(self) -> set[str]:
|
||||
"""IDs of the capabilities configured to load on demand.
|
||||
|
||||
Private, and read only by `is_tool_available`, which needs the *configured* shape rather
|
||||
than the runtime one: `loaded_capability_ids` records what history says was loaded, which
|
||||
Private, and read only by `is_tool_available` and `_dispatch_active_capability_ids`, which
|
||||
need the *configured* shape rather than the runtime one: `loaded_capability_ids` records what history says was loaded, which
|
||||
can name a capability that has since been reconfigured as always-on. Overridden in
|
||||
`TemporalRunContext` with the snapshot serialized at activity dispatch, since the
|
||||
`capabilities` registry this reads does not cross that boundary.
|
||||
@@ -591,7 +618,7 @@ class RunContext(Generic[RunContextAgentDepsT]):
|
||||
and capability_id in self._deferred_capability_ids
|
||||
and capability_id in self.loaded_capability_ids | evidence.loaded_capability_ids
|
||||
):
|
||||
return capability_id in self.active_capability_ids | evidence.loaded_capability_ids
|
||||
return capability_id in self._dispatch_active_capability_ids
|
||||
if tool_def.name not in self.discovered_tool_names | evidence.discovered_tool_names:
|
||||
return False
|
||||
# A run holds to load, then reveal, then call. `discovered_tool_names` is raw history
|
||||
@@ -599,7 +626,7 @@ class RunContext(Generic[RunContextAgentDepsT]):
|
||||
# never loaded — a history no real run produces, and one that would skip the instructions
|
||||
# written to be read first. Checking the owner here keeps this predicate in step with what
|
||||
# `ToolManager` will run, so "available" means one thing everywhere it is asked.
|
||||
return capability_id is None or capability_id in (self.active_capability_ids | evidence.loaded_capability_ids)
|
||||
return capability_id is None or capability_id in self._dispatch_active_capability_ids
|
||||
|
||||
@property
|
||||
def tools(self) -> dict[str, ToolDefinition]:
|
||||
|
||||
@@ -1109,6 +1109,11 @@ def _capability_active(capability: AbstractCapability[AgentDepsT], ctx: RunConte
|
||||
|
||||
Activity, not loading: an always-on capability is active for the whole run without ever being
|
||||
loaded, which is why the deferred branch below is the only one that consults history.
|
||||
|
||||
During tool-call dispatch that history question is answered against the provider that served the
|
||||
response, via `_dispatch_active_capability_ids` — the same set `RunContext.is_tool_available`
|
||||
authorizes the call from. Reading the narrower `active_capability_ids` here would let a tool the
|
||||
gate admits on anchored evidence execute without its owner's `prepare_tools` ever running.
|
||||
"""
|
||||
if capability.defer_loading is not True:
|
||||
return True
|
||||
@@ -1116,4 +1121,4 @@ def _capability_active(capability: AbstractCapability[AgentDepsT], ctx: RunConte
|
||||
# Deferred capabilities are required to have an explicit `id` (enforced in
|
||||
# `_build_run_capabilities`), which is also the key they're registered under, so we read
|
||||
# it directly rather than resolving the instance back to its run-local registry id.
|
||||
return capability.id is not None and capability.id in ctx.active_capability_ids
|
||||
return capability.id is not None and capability.id in ctx._dispatch_active_capability_ids # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@@ -167,6 +167,25 @@ class ToolManager(Generic[AgentDepsT]):
|
||||
"""
|
||||
default_max_retries: int = 1
|
||||
"""Default number of times to retry a tool"""
|
||||
resolved_capability_ids: frozenset[str] | None = None
|
||||
"""Snapshot of the capability-activity set the cached `tools` were resolved against.
|
||||
|
||||
Availability is an input to tool *resolution*, not just to the execution gate: a deferred
|
||||
capability only has its `prepare_tools` dispatched once it's active, so a tool set resolved
|
||||
while its owner was inactive is ungoverned by that capability's filter. Caching on `run_step`
|
||||
alone would hand that ungoverned set to a dispatch that has since become available — a
|
||||
permission filter that silently doesn't run. Comparing this against the incoming context makes
|
||||
`for_run_step` re-resolve when availability moved mid-step instead.
|
||||
|
||||
Availability moves two ways within a step, and both have to be in the key: history processing
|
||||
rewrites the loaded set feeding
|
||||
[`active_capability_ids`][pydantic_ai.tools.RunContext.active_capability_ids], and dispatch
|
||||
anchors its evidence to the provider that served the response, which can see a load the
|
||||
conservative window dropped. So this snapshots the union the execution gate itself authorizes
|
||||
from, not `active_capability_ids` alone.
|
||||
|
||||
`None` before the manager has been prepared for a run step.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
@@ -186,25 +205,43 @@ class ToolManager(Generic[AgentDepsT]):
|
||||
_parallel_execution_mode_ctx_var.reset(token)
|
||||
|
||||
async def for_run_step(self, ctx: RunContext[AgentDepsT]) -> ToolManager[AgentDepsT]:
|
||||
"""Build a new tool manager for the next run step, carrying over the retries from the current run step."""
|
||||
"""Build a new tool manager for the next run step, carrying over the retries from the current run step.
|
||||
|
||||
Re-resolves within a step as well, when capability availability moved since the cached tools
|
||||
were resolved (see `resolved_capability_ids`), so a capability that became active mid-step
|
||||
still gets its `prepare_tools` say over its own tools.
|
||||
"""
|
||||
resolved_capability_ids = frozenset(ctx._dispatch_active_capability_ids) # pyright: ignore[reportPrivateUsage]
|
||||
same_step = False
|
||||
if self.ctx is not None:
|
||||
if ctx.run_step == self.ctx.run_step:
|
||||
return self
|
||||
if resolved_capability_ids == self.resolved_capability_ids:
|
||||
return self
|
||||
|
||||
retries = {
|
||||
tool_name: count
|
||||
for tool_name, count in self.ctx.retries.items()
|
||||
if tool_name not in self.succeeded_tools
|
||||
}
|
||||
retries.update(
|
||||
{
|
||||
failed_tool_name: self.ctx.retries.get(failed_tool_name, 0) + 1
|
||||
for failed_tool_name in self.failed_tools
|
||||
# Same step, so the retry carry-over below must not run again: it increments every
|
||||
# failed tool's count, and doing that twice within one step would burn two units of
|
||||
# a tool's budget for a single failure. The step's counts stand as they are.
|
||||
same_step = True
|
||||
ctx = replace(ctx, retries=self.ctx.retries)
|
||||
else:
|
||||
retries = {
|
||||
tool_name: count
|
||||
for tool_name, count in self.ctx.retries.items()
|
||||
if tool_name not in self.succeeded_tools
|
||||
}
|
||||
)
|
||||
ctx = replace(ctx, retries=retries)
|
||||
retries.update(
|
||||
{
|
||||
failed_tool_name: self.ctx.retries.get(failed_tool_name, 0) + 1
|
||||
for failed_tool_name in self.failed_tools
|
||||
}
|
||||
)
|
||||
ctx = replace(ctx, retries=retries)
|
||||
|
||||
toolset = await self.toolset.for_run_step(ctx)
|
||||
# `AbstractToolset.for_run_step` marks a step boundary, not a re-resolution: `DynamicToolset`
|
||||
# exits and re-enters its inner toolset there, re-running the factory and discarding whatever
|
||||
# state the entered toolset held. Within a step we reuse the already-transitioned instance and
|
||||
# only re-run `get_tools`, which is what re-runs `prepare_tools`.
|
||||
toolset = self.toolset if same_step else await self.toolset.for_run_step(ctx)
|
||||
|
||||
new_tm = self.__class__(
|
||||
toolset=toolset,
|
||||
@@ -213,6 +250,11 @@ class ToolManager(Generic[AgentDepsT]):
|
||||
tools=await toolset.get_tools(ctx),
|
||||
default_max_retries=self.default_max_retries,
|
||||
availability_refused=self.availability_refused,
|
||||
resolved_capability_ids=resolved_capability_ids,
|
||||
# Per-step accumulators: they belong to the step being rebuilt, and dropping them would
|
||||
# under-count the retries the next step's carry-over derives from them.
|
||||
failed_tools=self.failed_tools if same_step else set[str](),
|
||||
succeeded_tools=self.succeeded_tools if same_step else set[str](),
|
||||
)
|
||||
# Make the prepared ToolManager accessible from RunContext so that
|
||||
# wrapper toolsets (e.g. CodeModeToolset) can dispatch tool calls
|
||||
|
||||
@@ -1249,6 +1249,241 @@ async def test_processed_history_determines_request_reveal_state() -> None:
|
||||
assert seen == [set()]
|
||||
|
||||
|
||||
def _inject_load(capability_id: str) -> Callable[[list[ModelMessage]], list[ModelMessage]]:
|
||||
"""Build a processor that injects a complete `load_capability` exchange for `capability_id`."""
|
||||
|
||||
def processor(messages: list[ModelMessage]) -> list[ModelMessage]:
|
||||
if any(True for _ in iter_message_parts(messages, ModelResponse, LoadCapabilityCallPart)):
|
||||
return messages
|
||||
return [
|
||||
messages[0],
|
||||
ModelResponse(parts=[LoadCapabilityCallPart(args={'id': capability_id}, tool_call_id='injected')]),
|
||||
ModelRequest(parts=[LoadCapabilityReturnPart(content={}, tool_call_id='injected')]),
|
||||
*messages[1:],
|
||||
]
|
||||
|
||||
return processor
|
||||
|
||||
|
||||
def _secret_op_returns(messages: list[ModelMessage]) -> list[object]:
|
||||
return [
|
||||
part.content
|
||||
for part in iter_message_parts(messages, ModelRequest, ToolReturnPart)
|
||||
if part.tool_name == 'secret_op'
|
||||
]
|
||||
|
||||
|
||||
def _call_secret_op_once(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
|
||||
"""Call `secret_op` on the first request, then finish however that call was answered."""
|
||||
answered = any(
|
||||
part.tool_name == 'secret_op' for part in iter_message_parts(messages, ModelRequest, ToolReturnPart)
|
||||
) or any(True for _ in iter_message_parts(messages, ModelRequest, RetryPromptPart))
|
||||
if answered:
|
||||
return ModelResponse(parts=[TextPart('done')])
|
||||
return ModelResponse(parts=[ToolCallPart('secret_op', {}, tool_call_id='call-1')])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('inject_load', [False, True])
|
||||
async def test_processor_injected_load_lets_capability_prepare_tools_govern_its_tools(inject_load: bool) -> None:
|
||||
"""A capability made active by history processing still has `prepare_tools` over its own tools.
|
||||
|
||||
Availability is an input to tool *resolution*, not just to the execution gate: the tool set
|
||||
resolved at the start of the step was built while the capability was inactive, so its
|
||||
`prepare_tools` never ran and its tools sat in that set ungoverned. Caching the resolved set on
|
||||
`run_step` alone would then hand that set to a dispatch that reads the (now changed)
|
||||
availability live, and a `prepare_tools` used as a permission filter would be silently bypassed.
|
||||
|
||||
Both directions are pinned: with no injection the tool is refused as not-yet-available (the
|
||||
filter is never reached, and must not need to be), and with the injection it is refused because
|
||||
the filter removed it — never executed either way.
|
||||
"""
|
||||
prepare_tools_calls: list[list[str]] = []
|
||||
loaded_ids_seen: list[list[str]] = []
|
||||
|
||||
class FilteringSecrets(Capability[object]):
|
||||
"""Uses `prepare_tools` as a permission filter over its own tool."""
|
||||
|
||||
async def prepare_tools(self, ctx: RunContext[object], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
|
||||
prepare_tools_calls.append(sorted(tool_def.name for tool_def in tool_defs))
|
||||
loaded_ids_seen.append(sorted(ctx.loaded_capability_ids))
|
||||
return [tool_def for tool_def in tool_defs if tool_def.name != 'secret_op']
|
||||
|
||||
secrets_toolset = FunctionToolset[object]()
|
||||
|
||||
@secrets_toolset.tool_plain
|
||||
def secret_op() -> str: # pragma: no cover
|
||||
return 'EXECUTED'
|
||||
|
||||
capabilities: list[AbstractCapability[object]] = [
|
||||
FilteringSecrets(id='secrets', description='Secret tools.', toolsets=[secrets_toolset], defer_loading=True)
|
||||
]
|
||||
if inject_load:
|
||||
capabilities.append(ProcessHistory[object](processor=_inject_load('secrets')))
|
||||
|
||||
agent = Agent(FunctionModel(_call_secret_op_once), capabilities=capabilities)
|
||||
result = await agent.run('call secret_op')
|
||||
|
||||
assert _secret_op_returns(result.all_messages()) == []
|
||||
refusals = [str(part.content) for part in iter_message_parts(result.all_messages(), ModelRequest, RetryPromptPart)]
|
||||
if inject_load:
|
||||
# The capability is active, so its own filter decides — and it removed the tool.
|
||||
assert prepare_tools_calls[0] == snapshot(['secret_op'])
|
||||
# The run's own prospective state agrees with the history it is sending, so the documented
|
||||
# "has the runbook been loaded?" check (`id in ctx.loaded_capability_ids`) answers yes on
|
||||
# this step, rather than the capability being active only through dispatch-time evidence.
|
||||
assert loaded_ids_seen[0] == snapshot(['secrets'])
|
||||
assert refusals == snapshot(["Unknown tool name: 'secret_op'. Available tools: 'load_capability'"])
|
||||
else:
|
||||
# The capability never loaded, so the availability gate refuses before any filter is reached.
|
||||
assert prepare_tools_calls == snapshot([])
|
||||
assert refusals == snapshot(
|
||||
[
|
||||
"Tool 'secret_op' is not available yet: it belongs to capability 'secrets'. Call `load_capability` for it first, then call the tool again once you've read the capability's instructions."
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def test_processor_injected_load_makes_capability_tool_callable() -> None:
|
||||
"""Without a filter, a processor-injected load makes the capability's tool callable that same step.
|
||||
|
||||
The mirror of the test above, and the reason availability is refreshed after processing at all:
|
||||
the reveal state the request ships is derived from the processed history, so the execution gate
|
||||
has to read the same history or the model is offered a tool that comes back refused.
|
||||
"""
|
||||
secrets_toolset = FunctionToolset[object]()
|
||||
|
||||
@secrets_toolset.tool_plain
|
||||
def secret_op() -> str:
|
||||
return 'EXECUTED'
|
||||
|
||||
agent = Agent(
|
||||
FunctionModel(_call_secret_op_once),
|
||||
capabilities=[
|
||||
Capability[object](
|
||||
id='secrets', description='Secret tools.', toolsets=[secrets_toolset], defer_loading=True
|
||||
),
|
||||
ProcessHistory[object](processor=_inject_load('secrets')),
|
||||
],
|
||||
)
|
||||
result = await agent.run('call secret_op')
|
||||
|
||||
assert _secret_op_returns(result.all_messages()) == snapshot(['EXECUTED'])
|
||||
assert [
|
||||
str(part.content) for part in iter_message_parts(result.all_messages(), ModelRequest, RetryPromptPart)
|
||||
] == snapshot([])
|
||||
|
||||
|
||||
async def test_processor_removed_load_leaves_advertisement_and_gate_in_agreement() -> None:
|
||||
"""Refreshing availability from processed history must not advertise a tool the gate will refuse.
|
||||
|
||||
The mirror of the injection tests, and the direction where making the request and the gate agree
|
||||
could have gone wrong: the refresh takes the capability *out* of the loaded set for a step whose
|
||||
tool set was resolved while it was in. `_with_outgoing_reveal_state` derives the request's reveal
|
||||
state from the same processed messages, so the tool is withheld from the wire rather than shipped
|
||||
and then refused — the model is never offered something `ToolManager` would reject.
|
||||
"""
|
||||
secrets_toolset = FunctionToolset[object]()
|
||||
|
||||
@secrets_toolset.tool_plain
|
||||
def secret_op() -> str: # pragma: no cover
|
||||
return 'EXECUTED'
|
||||
|
||||
advertised: list[list[str]] = []
|
||||
|
||||
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
||||
advertised.append(sorted(tool.name for tool in info.function_tools))
|
||||
return _call_secret_op_once(messages, info)
|
||||
|
||||
def drop_load_exchange(messages: list[ModelMessage]) -> list[ModelMessage]:
|
||||
kept: list[ModelMessage] = []
|
||||
for message in messages:
|
||||
parts = [
|
||||
part
|
||||
for part in message.parts
|
||||
if not isinstance(part, (LoadCapabilityCallPart, LoadCapabilityReturnPart))
|
||||
]
|
||||
if parts:
|
||||
kept.append(replace(message, parts=parts))
|
||||
return kept
|
||||
|
||||
agent = Agent(
|
||||
FunctionModel(model_fn),
|
||||
capabilities=[
|
||||
Capability[object](
|
||||
id='secrets', description='Secret tools.', toolsets=[secrets_toolset], defer_loading=True
|
||||
),
|
||||
ProcessHistory[object](processor=drop_load_exchange),
|
||||
],
|
||||
)
|
||||
result = await agent.run(
|
||||
'call secret_op',
|
||||
message_history=[
|
||||
ModelRequest(parts=[UserPromptPart(content='load it')]),
|
||||
ModelResponse(parts=[LoadCapabilityCallPart(args={'id': 'secrets'}, tool_call_id='l1')]),
|
||||
ModelRequest(parts=[LoadCapabilityReturnPart(content={}, tool_call_id='l1')]),
|
||||
],
|
||||
)
|
||||
|
||||
# Withheld from the wire, so the model was never offered it...
|
||||
assert advertised[0] == snapshot(['load_capability'])
|
||||
# ...and the call the stub makes anyway is refused, rather than running ungoverned.
|
||||
assert _secret_op_returns(result.all_messages()) == []
|
||||
assert [
|
||||
str(part.content) for part in iter_message_parts(result.all_messages(), ModelRequest, RetryPromptPart)
|
||||
] == snapshot(
|
||||
[
|
||||
"Tool 'secret_op' is not available yet: it belongs to capability 'secrets'. Call `load_capability` for it first, then call the tool again once you've read the capability's instructions."
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def test_processor_injected_load_is_governed_when_resuming_a_suspended_response() -> None:
|
||||
"""The same governance holds on the request that resumes a provider-suspended turn.
|
||||
|
||||
That path prepares its request separately from the normal one — the history already ends in the
|
||||
suspended response, so there is no new `ModelRequest` to append — but it runs the same history
|
||||
processing and dispatches the continuation's tool calls against the result, so it needs the same
|
||||
post-processing availability refresh.
|
||||
"""
|
||||
prepare_tools_calls: list[list[str]] = []
|
||||
loaded_ids_seen: list[list[str]] = []
|
||||
|
||||
class FilteringSecrets(Capability[object]):
|
||||
"""Uses `prepare_tools` as a permission filter over its own tool."""
|
||||
|
||||
async def prepare_tools(self, ctx: RunContext[object], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
|
||||
prepare_tools_calls.append(sorted(tool_def.name for tool_def in tool_defs))
|
||||
loaded_ids_seen.append(sorted(ctx.loaded_capability_ids))
|
||||
return [tool_def for tool_def in tool_defs if tool_def.name != 'secret_op']
|
||||
|
||||
secrets_toolset = FunctionToolset[object]()
|
||||
|
||||
@secrets_toolset.tool_plain
|
||||
def secret_op() -> str: # pragma: no cover
|
||||
return 'EXECUTED'
|
||||
|
||||
agent = Agent(
|
||||
FunctionModel(_call_secret_op_once),
|
||||
capabilities=[
|
||||
FilteringSecrets(id='secrets', description='Secret tools.', toolsets=[secrets_toolset], defer_loading=True),
|
||||
ProcessHistory[object](processor=_inject_load('secrets')),
|
||||
],
|
||||
)
|
||||
result = await agent.run(
|
||||
message_history=[
|
||||
ModelRequest(parts=[UserPromptPart(content='call secret_op')]),
|
||||
ModelResponse(parts=[TextPart('paused')], state='suspended'),
|
||||
]
|
||||
)
|
||||
|
||||
assert _secret_op_returns(result.all_messages()) == []
|
||||
assert prepare_tools_calls[0] == snapshot(['secret_op'])
|
||||
assert loaded_ids_seen[0] == snapshot(['secrets'])
|
||||
assert [
|
||||
str(part.content) for part in iter_message_parts(result.all_messages(), ModelRequest, RetryPromptPart)
|
||||
] == snapshot(["Unknown tool name: 'secret_op'. Available tools: 'load_capability'"])
|
||||
|
||||
|
||||
async def test_orphaned_reveal_evidence_stripped_by_cleanup_does_not_count_as_revealed() -> None:
|
||||
"""Evidence orphaned by a history processor is stripped before reveal derivation.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ from pydantic_ai.messages import (
|
||||
from pydantic_ai.models import ModelRequestContext
|
||||
from pydantic_ai.models.function import AgentInfo, FunctionModel
|
||||
from pydantic_ai.models.test import TestModel
|
||||
from pydantic_ai.tools import RunContext
|
||||
from pydantic_ai.tools import RunContext, ToolDefinition
|
||||
from pydantic_ai.toolsets import FunctionToolset
|
||||
from pydantic_ai.toolsets._deferred_capability_loader import (
|
||||
LOAD_CAPABILITY_TOOL_NAME,
|
||||
@@ -154,6 +154,78 @@ async def test_capability_tool_call_uses_serving_providers_wire_window(boundary:
|
||||
assert result.output == 'done'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(('boundary', 'provider_name'), _INVALID_WIRE_BOUNDARIES)
|
||||
async def test_capability_prepare_tools_governs_a_tool_the_wire_window_admits(
|
||||
boundary: CompactionPart, provider_name: str
|
||||
):
|
||||
"""A tool admitted on anchored evidence still answers to its owner's `prepare_tools`.
|
||||
|
||||
The mirror of `test_capability_tool_call_uses_serving_providers_wire_window`, which pins that
|
||||
such a call is *not* refused. Admitting it is only half an answer: authorization and filtering
|
||||
have to read the same set. The conservative window drops the load, so `active_capability_ids`
|
||||
alone reports the capability inactive and nothing dispatches to it — while the gate, reading the
|
||||
provider-exact window, admits its tool. A capability using `prepare_tools` as a permission
|
||||
filter would be bypassed, the same fail-open as a processor-injected load reached from the other
|
||||
direction.
|
||||
|
||||
The test explicitly stamps `ModelResponse.provider_name`; a bare `FunctionModel` would exercise
|
||||
the missing-provenance fallback instead, where the two windows agree and nothing is admitted.
|
||||
"""
|
||||
prepare_tools_calls: list[list[str]] = []
|
||||
loaded_ids_seen: list[list[str]] = []
|
||||
|
||||
class FilteringGuarded(Capability[Any]):
|
||||
"""Uses `prepare_tools` as a permission filter over its own tool."""
|
||||
|
||||
async def prepare_tools(self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
|
||||
prepare_tools_calls.append(sorted(tool_def.name for tool_def in tool_defs))
|
||||
loaded_ids_seen.append(sorted(ctx.loaded_capability_ids))
|
||||
return [tool_def for tool_def in tool_defs if tool_def.name != 'guarded_tool']
|
||||
|
||||
def guarded_tool() -> str: # pragma: no cover
|
||||
return 'ran'
|
||||
|
||||
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
||||
if any(True for _ in iter_message_parts(messages, ModelRequest, RetryPromptPart)):
|
||||
return _provider_response([make_text_response('done').parts[0]], provider_name)
|
||||
return _provider_response([ToolCallPart(tool_name='guarded_tool', args={}, tool_call_id='g1')], provider_name)
|
||||
|
||||
capability = FilteringGuarded(
|
||||
id='guarded', description='Guarded.', toolsets=[FunctionToolset([guarded_tool])], defer_loading=True
|
||||
)
|
||||
agent = Agent(FunctionModel(model_fn), capabilities=[capability])
|
||||
history = [
|
||||
ModelResponse(
|
||||
parts=[LoadCapabilityCallPart(args={'id': 'guarded'}, tool_call_id='load')], provider_name=provider_name
|
||||
),
|
||||
ModelRequest(
|
||||
parts=[
|
||||
LoadCapabilityReturnPart(content={}, tool_call_id='load'),
|
||||
ToolAvailabilityDeltaPart(tools_added=['guarded_tool']),
|
||||
]
|
||||
),
|
||||
ModelResponse(parts=[boundary]),
|
||||
]
|
||||
|
||||
result = await agent.run('go', message_history=history)
|
||||
|
||||
executed = [
|
||||
part
|
||||
for part in iter_message_parts(result.all_messages(), ModelRequest, ToolReturnPart)
|
||||
if part.tool_name == 'guarded_tool'
|
||||
]
|
||||
assert executed == []
|
||||
# The filter ran with the capability treated as active, and it removed the tool.
|
||||
assert prepare_tools_calls[0] == snapshot(['guarded_tool'])
|
||||
# And it ran off the retrospective supplement alone: the conservative window legitimately
|
||||
# dropped the load, so the prospective set does not name the capability. That gap is the whole
|
||||
# point — the filter has to follow whatever the gate authorizes from, not the narrower set.
|
||||
assert loaded_ids_seen[0] == snapshot([])
|
||||
assert [
|
||||
str(part.content) for part in iter_message_parts(result.all_messages(), ModelRequest, RetryPromptPart)
|
||||
] == snapshot(["Unknown tool name: 'guarded_tool'. Available tools: 'load_capability'"])
|
||||
|
||||
|
||||
async def test_compaction_inside_serving_response_does_not_reset_tool_evidence():
|
||||
"""Anthropic may compact mid-response; explicit provenance exercises the strict-before rule."""
|
||||
toolset = FunctionToolset[Any]()
|
||||
|
||||
@@ -749,6 +749,134 @@ async def test_tool_manager_reuse_self():
|
||||
assert tool_manager != updated_tool_manager
|
||||
|
||||
|
||||
async def test_tool_manager_rebuilds_within_step_when_capability_availability_changes():
|
||||
"""Tools are re-resolved mid-step when capability availability moved, without re-running the step transition.
|
||||
|
||||
Invariants an agent run can't reach today, so they're pinned directly rather than through
|
||||
`Agent.run`. The retry carry-over sits behind the `run_step` short-circuit: a same-step rebuild
|
||||
that dropped the accumulated counts would hand every tool a fresh budget mid-run, and one that
|
||||
re-ran the carry-over would charge two units of that budget for a single failure. The per-step
|
||||
accumulators have to survive it too, in both directions — `failed_tools` so the next step's
|
||||
carry-over still charges the failure, `succeeded_tools` so it still clears the count of a tool
|
||||
that recovered. And `AbstractToolset.for_run_step` is a step boundary with real lifecycle
|
||||
effects (`DynamicToolset` exits and re-enters its inner toolset there, re-running its factory),
|
||||
so a same-step rebuild has to reuse the already-transitioned toolset and only re-run
|
||||
`get_tools`.
|
||||
"""
|
||||
factory_calls = 0
|
||||
flaky_calls = 0
|
||||
|
||||
def toolset_func(ctx: RunContext[None]) -> AbstractToolset[None]:
|
||||
nonlocal factory_calls
|
||||
factory_calls += 1
|
||||
toolset = FunctionToolset[None](max_retries=3)
|
||||
|
||||
@toolset.tool_plain
|
||||
def failing_tool() -> int:
|
||||
raise ModelRetry('This tool always fails')
|
||||
|
||||
@toolset.tool_plain
|
||||
def flaky_tool() -> int:
|
||||
nonlocal flaky_calls
|
||||
flaky_calls += 1
|
||||
if flaky_calls == 1:
|
||||
raise ModelRetry('Not yet')
|
||||
return flaky_calls
|
||||
|
||||
return toolset
|
||||
|
||||
secrets = Capability[None](id='secrets', description='Secret tools.', defer_loading=True)
|
||||
step_1 = replace(build_run_context(None, run_step=1), capabilities={'secrets': secrets})
|
||||
|
||||
async with DynamicToolset(toolset_func) as dynamic_toolset:
|
||||
tool_manager = await ToolManager[None](dynamic_toolset).for_run_step(step_1)
|
||||
assert factory_calls == 1
|
||||
|
||||
for tool_name in ('failing_tool', 'flaky_tool'):
|
||||
with pytest.raises(ToolRetryError):
|
||||
await tool_manager.handle_call(ToolCallPart(tool_name=tool_name, args={}))
|
||||
|
||||
# A step advance charges both failures, so the same-step rebuild below has counts to carry.
|
||||
tool_manager = await tool_manager.for_run_step(replace(step_1, run_step=2))
|
||||
assert factory_calls == 2
|
||||
assert tool_manager.ctx is not None
|
||||
assert tool_manager.ctx.retries == snapshot({'failing_tool': 1, 'flaky_tool': 1})
|
||||
|
||||
with pytest.raises(ToolRetryError):
|
||||
await tool_manager.handle_call(ToolCallPart(tool_name='failing_tool', args={}))
|
||||
assert await tool_manager.handle_call(ToolCallPart(tool_name='flaky_tool', args={})) == 2
|
||||
assert tool_manager.failed_tools == {'failing_tool'}
|
||||
assert tool_manager.succeeded_tools == {'flaky_tool'}
|
||||
|
||||
# Availability moves mid-step: same `run_step`, but the deferred capability is now loaded.
|
||||
# (A fresh context carries no retries of its own, as the graph's does not.)
|
||||
step_2_loaded = replace(step_1, run_step=2, loaded_capability_ids={'secrets'})
|
||||
rebuilt = await tool_manager.for_run_step(step_2_loaded)
|
||||
|
||||
assert rebuilt is not tool_manager
|
||||
# The step transition is not re-run, so the dynamic toolset's factory doesn't fire again.
|
||||
assert factory_calls == 2
|
||||
assert rebuilt.ctx is not None
|
||||
# Retries carry through untouched: neither reset by the fresh context nor charged twice.
|
||||
assert rebuilt.ctx.retries == snapshot({'failing_tool': 1, 'flaky_tool': 1})
|
||||
# Both per-step accumulators survive, so the next step's carry-over sees this step's outcomes.
|
||||
assert rebuilt.failed_tools == {'failing_tool'}
|
||||
assert rebuilt.succeeded_tools == {'flaky_tool'}
|
||||
|
||||
# Availability unchanged: no rebuild, the same manager is returned.
|
||||
assert await rebuilt.for_run_step(step_2_loaded) is rebuilt
|
||||
|
||||
# Advancing the step charges the failure exactly once and clears the recovered tool's count,
|
||||
# and does re-run the transition.
|
||||
advanced = await rebuilt.for_run_step(replace(step_2_loaded, run_step=3))
|
||||
assert factory_calls == 3
|
||||
assert advanced.ctx is not None
|
||||
assert advanced.ctx.retries == snapshot({'failing_tool': 2})
|
||||
assert advanced.failed_tools == set()
|
||||
assert advanced.succeeded_tools == set()
|
||||
|
||||
|
||||
async def test_tool_manager_availability_snapshot_is_independent_of_the_shared_set():
|
||||
"""The recorded availability has to be a snapshot, and removal has to invalidate as well as addition.
|
||||
|
||||
The run's `loaded_capability_ids` is a single mutable set shared by reference with every
|
||||
`RunContext` of the run: `_refresh_loaded_capability_ids` clears and updates it in place rather
|
||||
than handing over a replacement, precisely so the copies stay in sync. A manager that recorded
|
||||
the live set instead of a snapshot of it would compare it against itself and never invalidate.
|
||||
Exercised here by mutating the very set the manager resolved against, which a test that passes
|
||||
a fresh set per step does not do.
|
||||
"""
|
||||
toolset = FunctionToolset[None]()
|
||||
|
||||
@toolset.tool_plain
|
||||
def plain_tool() -> int: # pragma: no cover
|
||||
return 1
|
||||
|
||||
secrets = Capability[None](id='secrets', description='Secret tools.', defer_loading=True)
|
||||
loaded_capability_ids: set[str] = set()
|
||||
ctx = replace(
|
||||
build_run_context(None, run_step=1),
|
||||
capabilities={'secrets': secrets},
|
||||
loaded_capability_ids=loaded_capability_ids,
|
||||
)
|
||||
assert ctx.loaded_capability_ids is loaded_capability_ids
|
||||
|
||||
tool_manager = await ToolManager[None](toolset).for_run_step(ctx)
|
||||
assert tool_manager.resolved_capability_ids == snapshot(frozenset())
|
||||
|
||||
# Mutated in place, exactly as the run's own refresh does it.
|
||||
loaded_capability_ids.add('secrets')
|
||||
rebuilt = await tool_manager.for_run_step(ctx)
|
||||
assert rebuilt is not tool_manager
|
||||
assert rebuilt.resolved_capability_ids == snapshot(frozenset({'secrets'}))
|
||||
|
||||
# And back out: a processor can drop a load pair as easily as add one.
|
||||
loaded_capability_ids.remove('secrets')
|
||||
re_rebuilt = await rebuilt.for_run_step(ctx)
|
||||
assert re_rebuilt is not rebuilt
|
||||
assert re_rebuilt.resolved_capability_ids == snapshot(frozenset())
|
||||
|
||||
|
||||
async def test_tool_manager_retry_logic():
|
||||
"""Test the retry logic with failed_tools and for_run_step method."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user