feat(aws-strands): filter the template agent's tools per request - #2616
Conversation
Add `template_tools_provider` / `templateToolsProvider` to `StrandsAgentConfig`. It is called once per request with that request's `RunAgentInput` and returns the template tools the request may see, or nothing to leave every one of them available, so the set can vary turn by turn on a single thread by caller identity. The filter is applied to the tool registry the thread's live Strands `Agent` already owns, the same way client-declared tools are already synchronised. That instance is load-bearing: it carries the thread's `SessionManager`, its native interrupt checkpoint and its history, so rebuilding it to change a tool list would discard a conversation and any approval waiting inside it. Only the template's own tools are touched, and only by identity, so a client proxy or an auto-injected A2UI tool sharing a name is left in place rather than dropped. Three consequences are pinned by tests. A tool in the batch a live interrupt checkpoint would resume stays registered whatever the provider returns, the rule `sync_proxy_tools` already applies to a proxy parked in a frontend-tool interrupt. History is never rewritten, so a filtered-out tool's earlier calls and results stay in the thread's messages. A provider that raises ends the run with `RUN_ERROR` / `TEMPLATE_TOOLS_PROVIDER_ERROR` rather than degrading to an unfiltered run, matching the per-thread agent hook beside it.
Three conflicts, all additive collisions in lists main also grew: - `ARCHITECTURE.md`: main added `thread_agent_kwargs`, `a2ui` and `url_fetch_policy` rows to the Python config table. Kept all of them and reinserted `template_tools_provider` after `session_manager_provider`. - `python/README.md`: main expanded the Key Files table with five more modules. Kept them and reinserted `template_tools.py` beside `client_proxy_tool.py`, the other module that syncs the tool registry. - `typescript/src/__tests__/exports.test.ts`: main appended `DEFAULT_URL_FETCH_POLICY` and `UrlFetchPolicyError` to the expected export list. Kept both alongside `syncTemplateTools` and `parkedBatchToolNames`. `error-codes.json` merged clean and stayed alphabetical: `TEMPLATE_TOOLS_PROVIDER_ERROR` sits before `THREAD_AGENT_CONFIG_ERROR`, and main's new `URL_FETCH_POLICY_INVALID` last. Both READMEs' new "Terminal error codes" sections point at that file and enumerate only the divergences, so a shared code carrying a byte-identical template on both sides needs no entry there. Nothing in main's token-usage or URL-fetch-policy work touches the request path this branch adds, and the per-request template-tools sync still sits between the interrupt-session gate and the proxy sync on both sides. Verified after the merge: 1356 passed / 1 skipped (Python), 1714 passed (TypeScript), 78 passed (TypeScript examples), `tsc --noEmit` clean.
Python Preview PackagesVersion
Install with uvAdd the TestPyPI index to your [[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = trueThen install the packages you need: # Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1788526324' --index testpypi
# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1788526324' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1788526324' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1788526324' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1788526324' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1788526324' --index testpypiInstall with pippip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
ag-ui-protocol==0.0.0.dev1788526324
Commit: 07784a0 |
@ag-ui/a2a-middleware
@ag-ui/a2ui-middleware
@ag-ui/event-throttle-middleware
@ag-ui/mcp-apps-middleware
@ag-ui/mcp-middleware
@ag-ui/a2a
@ag-ui/adk
@ag-ui/ag2
@ag-ui/agno
@ag-ui/aws-strands
@ag-ui/claude-agent-sdk
@ag-ui/claude-managed-agents
@ag-ui/crewai
@ag-ui/langchain
@ag-ui/langgraph
@ag-ui/llamaindex
@ag-ui/mastra
@ag-ui/pydantic-ai
@ag-ui/vercel-ai-sdk
@ag-ui/watsonx
@ag-ui/a2ui-toolkit
create-ag-ui-app
@ag-ui/client
@ag-ui/core
@ag-ui/encoder
@ag-ui/proto
commit: |
mme
left a comment
There was a problem hiding this comment.
Really nice piece of work — the module docstrings are unusually good at explaining why the filter has to mutate the live registry rather than rebuild the agent, and the test coverage (740 + 625 lines) is thorough. I ran a review pass over it and came up with four things worth a look before merge. One is a behavioural gap I'd like your read on; the other three are smaller edge cases. All of them apply to both the Python and TypeScript implementations, so any fix likely lands twice.
|
|
||
| registered: Set[str] = set() | ||
| for name, tool in template_index.items(): | ||
| keep = allowed is None or name in allowed or name in exempt |
There was a problem hiding this comment.
The exemption may outlive the checkpoint it exists for.
The reasoning for exempt_names in parked_batch_tool_names is sound — a tool missing from the registry during resume turns the human's answer into a "tool not found" the model then re-fires. But the exemption is applied once, here, and then the registry stays that way for the rest of the invocation.
So if a resume request narrows the selection, every tool in the parked batch stays registered past the point where it's needed: Strands re-dispatches the batch, and then makes its next model call from the same registry. That call still advertises the denied tools — including non-interrupting siblings that already completed — and the model can call them again before the next request gets a chance to re-run the filter.
Is the intent that the exemption only covers checkpoint dispatch? If so, it'd need to be re-narrowed once the batch closes. Happy to be wrong here if Strands rebuilds tool specs somewhere I've missed.
(Same in typescript/src/template-tools.ts.)
| else: | ||
| # Something else answers to this name now. Overwriting it would | ||
| # make a filter that allows a tool destroy another producer's. | ||
| logger.debug( | ||
| "Template tool %s is shadowed by another registered tool; " | ||
| "leaving it in place", | ||
| name, | ||
| ) |
There was a problem hiding this comment.
Ordering hazard between this branch and sync_proxy_tools.
Leaving another producer's entry alone is clearly the right call — overwriting it would let an allowing filter destroy a proxy. But because sync_template_tools runs before sync_proxy_tools in agent.py, there's a sequence where an allowed template tool ends up registered nowhere:
- Turn 1: provider filters out template tool
foo; the client declares a tool namedfoo, so a proxy takes the name. - Turn 2: provider allows
fooagain, but the client no longer declares it. sync_template_toolsruns first, sees the proxy underfoo, takes this branch and leaves it.sync_proxy_toolsthen removesfooas a stale tracked name.
Net result: foo was allowed for this request and isn't in the registry. If the client does still declare it, the proxy shadows the allowed native tool instead, which the native-wins logic elsewhere is meant to prevent.
Probably fixable by syncing the other producers first, or doing a second restoration pass after them. (Same ordering in the TypeScript agent.)
| syncTemplateTools( | ||
| strandsAgent.toolRegistry, | ||
| this._templateFields.tools ?? [], | ||
| selection, | ||
| { | ||
| exemptNames: parkedBatchToolNames(strandsAgent), | ||
| log: this._log, | ||
| }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
A lazy selection can throw outside the guarded path.
The try wraps the provider call, but not the consumption of what it returns. Since TemplateToolsProvider returns Iterable<TemplateToolSelectionEntry>, a provider can legitimately hand back a generator that constructs fine and throws on first iteration — which happens inside syncTemplateTools, below the catch.
When that happens the run skips TEMPLATE_TOOLS_PROVIDER_ERROR entirely and the stream dies after RUN_STARTED, which is exactly the terminal-error contract the comment above is careful to establish. Pulling the materialization and the sync inside the try would close it.
Python has the same shape — sync_template_tools sits outside the try — though there it fails before RUN_STARTED rather than after.
| for (const entry of selection) { | ||
| const name = | ||
| typeof entry === "string" | ||
| ? entry | ||
| : (entry as { name?: unknown } | null | undefined)?.name; |
There was a problem hiding this comment.
A bare string type-checks but filters out everything.
TemplateToolSelectionEntry = Tool | string, so Iterable<TemplateToolSelectionEntry> is satisfied by a plain string — JS strings are iterable over their characters. A provider written as:
templateToolsProvider: () => "read_docs"compiles cleanly, then iterates as "r", "e", "a", "d", … each of which misses templateIndex, logs an "agent does not contribute" warning, and gets dropped. allowed comes back empty, so every template tool is filtered out — the opposite of what the author asked for, and only visible in the logs.
Given that returning a single name feels like a natural thing to write, it might be worth either treating a bare string as a singleton selection, or narrowing the return type so it doesn't type-check. Python's Iterable[Any] behaves the same way.
Not a blocker, but a footgun that costs a debugging session when someone hits it.
mme
left a comment
There was a problem hiding this comment.
Had a careful read through this one (and ran both suites locally — TS 79 files / 1714 tests and the Python test_template_tool_filtering.py + test_terminal_error_paths.py 54 tests all green). The rescope rationale in the description is genuinely convincing, and the cached-agent-identity assertion in the tests is the right thing to pin — nice touch, since that's exactly the assertion a rebuild-based implementation would fail.
I also double-checked the Python parked-batch derivation against the SDK version strands-agents>=1.15.0 actually resolves today (1.18.0), and _interrupt_state.context["tool_use_message"] is correct there — event_loop.py:143 and :486 write and read exactly that shape. So that part is solid.
Four things I think are worth a look before merge, plus one small robustness nit. Left them inline:
- The return contract accepts containers it probably shouldn't — a permission-map style return silently authorizes on Python. This is the one I'd most want fixed.
- The error boundary doesn't cover consuming the selection, so some provider mistakes bypass
TEMPLATE_TOOLS_PROVIDER_ERROR. - Ownership by object identity can no-op the filter in the
agentsByThread/agents_by_threaddeployment shape, depending on how the template's tools are built. - Filtering runs before stale-producer cleanup, which can drop an allowed tool for exactly one request.
None of these touch the core design decision, which I think is the right one — they're all at the boundary. Happy to be wrong on any of them.
Reviewed with Codex, with each finding verified against the code and the resolved SDK before posting. One finding Codex raised (that the Python checkpoint read was against a legacy shape) turned out to be incorrect, so it isn't included here.
| return None | ||
|
|
||
| allowed: Set[str] = set() | ||
| for entry in selection: |
There was a problem hiding this comment.
The selection is read as a plain iterable, so containers that aren't really name lists get interpreted as one:
def tools_for(input_data):
return {"delete_record": False} # authorizes delete_record — the value is never readPython iterates the dict's keys and allows delete_record; the False is never consulted. A permission map feels like a very natural thing for someone to reach for on a hook shaped like this, and the failure is silent and in the permissive direction.
TypeScript diverges here too: for...of over a plain object throws not iterable, so the same mistake fails loudly on one side and quietly authorizes on the other — worth reconciling given the PR commits to parity on the return contract.
A bare string is the same class of thing in both languages: return "delete_record" iterates characters, so you get 13 unknown-name warnings and an empty allow-set. That one at least fails closed.
Would it be worth validating the container explicitly at the boundary — accept list/tuple/set (and reject str/Mapping) rather than anything iterable? A TEMPLATE_TOOLS_PROVIDER_ERROR on a mapping seems friendlier than silently reading its keys.
| yield ev_started | ||
| yield ev_error | ||
| return | ||
| sync_template_tools( |
There was a problem hiding this comment.
The try covers the call and the await, but sync_template_tools — which is where the selection actually gets iterated and validated — sits outside it. So a few plausible provider mistakes skip the documented terminal contract:
- returning a non-iterable (
return 42) →TypeErrorout ofresolve_template_tool_selection - returning a generator that raises partway through iteration
- (TS) returning something that isn't runtime-iterable
Capability-wise this still fails closed, which is the important half — the model never runs unfiltered. But neither language emits TEMPLATE_TOOLS_PROVIDER_ERROR, and on the TS path RUN_STARTED has already gone out, so the stream just rejects with no RUN_ERROR behind it. That's the one shape the error-codes.json entry says can't happen.
Pulling the sync_template_tools call inside the same try would cover all of it, and the existing error path already does the right thing once it's reached.
| name, | ||
| ) | ||
| continue | ||
| if existing is tool: |
There was a problem hiding this comment.
Ownership is inferred from object identity (existing is tool here, existing === tool at template-tools.ts:180), with a non-match read as "another producer owns this name, leave it alone". That's the right instinct, but it means the filter no-ops when the registry holds an equivalent but not identical instance.
The case I'd worry about is the documented purpose of agentsByThread (agent.ts:2084-2090: "allowing agent instances (and their interrupt state) to survive across adapter re-instantiations (e.g. request-scoped wrappers in serverless runtimes)"). In that shape the wrapper is rebuilt per request, so self._tools / _templateFields.tools is re-captured from whatever template that request constructed, while the cached thread agent's registry still holds the first request's objects.
Whether that bites depends on how the template's tools are built:
- module-level
@toolfunctions → same objects every time, identity holds, all fine - tools built per request (a factory, or a closure over a request-scoped client/db handle) → every template tool in a cached thread agent is non-identical, so a deny-all selection removes nothing and the filter is a silent no-op
Since the failure is in the permissive direction on a hook whose whole job is withholding capability, it might be worth either tracking template provenance per thread explicitly, or falling back to a name+ownership check rather than pure identity. At minimum a note in the config docstring that the template's tools should be stable objects across re-instantiation would help.
Same reasoning applies to a hot-reloaded dynamic tool once Strands swaps the registered instance.
| ); | ||
| return; | ||
| } | ||
| syncTemplateTools( |
There was a problem hiding this comment.
Small ordering hazard: this runs before the stale-producer cleanup below it (syncProxyTools at :2872, and the prior-turn A2UI removal), so an allowed template tool can go missing for exactly one request:
- Template contributes
delete_record. - Request 1 filters it out, and the client declares a proxy also named
delete_record→ proxy registered under that name. - Request 2 allows the template tool but no longer declares the proxy.
- Template sync sees the proxy sitting on the name, correctly declines to clobber another producer's entry, and skips the restore.
syncProxyToolsthen removes that proxy as stale (client_proxy_tool.py:185).- Registry ends the request with neither entry — the tool the provider explicitly allowed isn't there. Request 3 restores it.
Same shape on the Python path (agent.py:4493 filtering, :4525 proxy sync).
Niche, since it needs a name collision between a client proxy and a template tool, but it's a "the provider said yes and the model still couldn't see it" outcome. Moving the template sync after producer cleanup — or re-running the restore pass afterwards — would close it.
| and the batch answers all three at once. | ||
| """ | ||
| state = getattr(agent, "_interrupt_state", None) | ||
| if state is None or getattr(state, "activated", False) is not True: |
There was a problem hiding this comment.
Tiny robustness nit, take it or leave it: when the checkpoint is activated but the shape doesn't decode (missing context, no tool_use_message, unexpected nesting), this returns an empty set — which the caller can't distinguish from "there is nothing parked", so filtering proceeds and could remove a tool the resume is about to re-dispatch.
Correct on 1.18.0 as-is, so this is purely about the open-ended strands-agents>=1.15.0 range. Given the never-orphan-a-parked-tool guarantee is stated pretty strongly in the docs, "activated but undecodable" might be safer treated as "exempt everything" (or as an error) rather than "exempt nothing" — the conservative direction costs one unfiltered turn instead of a broken resume.
Same note applies to parkedBatchToolNames at template-tools.ts:114.
All six are at the edges of the hook rather than in the design, and all six land in both bridges. **The return contract is checked, not merely iterated.** A mapping and a bare name are both iterable and both mean something other than what iterating them produces. Python read a permission map's keys as the allow-list and never looked at its values, so a name mapped to `False` was authorized silently and in the permissive direction, while TypeScript threw a bare `TypeError` on the same mistake; a bare name came apart into characters on both sides and denied everything. Both are now refused with `TEMPLATE_TOOLS_PROVIDER_ERROR`, which also gives the two bridges one return contract rather than two. Lists, tuples, sets, arrays and generators are all still accepted. **The error boundary covers reading the answer, not just asking for it.** The selection is materialized and validated inside the same guarded step that calls the provider, so a non-container, a mapping, a bare name and a generator that raises partway through iteration all report the documented code. Applying the resolved selection to the registry is guarded separately and reports through the terminal-error classifier: past that point a failure is the adapter's, and on the TypeScript path this block runs before the main try/catch, where an escape ended the stream after `RUN_STARTED` with nothing terminal behind it. **Ownership of a registry entry no longer rests on object identity alone.** An external per-thread agent map exists so a request-scoped wrapper can be rebuilt while the cached thread agent survives. A template whose tools are built per request then hands each new wrapper equivalent but not identical objects, and reading a non-match as "another producer owns this name" made a deny-everything answer remove nothing. Identity still settles it when it holds; otherwise ownership is by elimination, the name being one the template contributes and the entry not being one of the adapter's other producers (a client proxy or an auto-injected A2UI tool). **A name collision with a client tool no longer loses an allowed tool.** The template sync runs before the proxy sync, so a proxy squatting a filtered-out template tool's name blocked the restore, and the proxy sync then removed that proxy as stale, ending the request with neither entry. An allowed template tool now reclaims its name from a proxy, and the proxy sync that follows re-decides the client's side and skips a name a native tool holds, which is the collision rule that already applied. A proxy the parked batch is answering keeps its name. **The parked-batch exemption no longer outlives the checkpoint.** It kept a denied tool registered so a resume could reach it, and Strands then carried on in the same run: it re-dispatched the batch and made its next model call from the same registry, still advertising what the request denied, until the next request narrowed again. The narrowing is now re-applied inside the run once a batch has been dispatched, with the exemption recomputed. The two bridges hook different SDK events for this, because the SDKs read the tool specs at different points relative to the events they dispatch: Python dispatches `BeforeModelCallEvent` before that read, while TypeScript reads the specs as the first statement of its model call, so it hooks `AfterToolsEvent`, which Python does not have. **An unreadable parked batch holds every template tool instead of none.** An activated checkpoint carrying a tool batch this adapter cannot decode is an SDK shape it does not know; holding everything costs one unfiltered turn where filtering anyway would break a resume a human is waiting on. An activated checkpoint with no pending tool execution at all is not that case and holds nothing, because an interrupt raised before any tool ran parks exactly that way. Every fix is pinned by a test that fails when the fix is reverted, verified by reverting each one. Two existing tests changed with the behaviour: the parked-approval case no longer asserts the tool is still registered when the run ends, because the re-narrowing has correctly removed it by then, and the "another producer's entry is left alone" case now uses a real client proxy, since a plain foreign object under a template tool's name is now the template's to remove. 1370 passed / 1 skipped (Python), 1731 passed (TypeScript), 78 passed (TypeScript examples), `tsc --noEmit` clean.
…endent The newest-SDK lane caught this: the re-narrowing added in the previous commit worked on the pinned TypeScript SDK and did nothing on 1.16, so a denied tool stayed advertised for the rest of a resumed run there. The hook recomputed the parked-batch exemption when it fired, and what that question answers moved between releases. The TypeScript SDK clears its pending tool execution before the tool step in 1.1 and after it in 1.16, so the same read holds the whole batch exempt on one release and holds nothing on the other. On 1.16 that meant the narrowing was a no-op. No exemption is passed now. By the time the hook fires the parked batch has been dispatched, which is the entire reason the exemption existed, so the recompute was asking a question whose answer no longer mattered. Asking nothing is both simpler and identical on every release, and it takes the hook off the SDK's interrupt internals altogether. One residual, recorded in both modules: a run the SDK is cancelling can replay a skipped batch after the hook has fired, and a tool the narrowing removed would be missing from that replay. The run is being torn down at that point, and the next request restores whatever a live checkpoint still needs before anything reads the registry again. Verified the way the lane does, against `@strands-agents/sdk@1.16.0` installed over the pinned 1.1.0: 1731 passed and `tsc --noEmit` clean on both. Also 1370 passed / 1 skipped (Python) and 78 passed (TypeScript examples) on the pinned version.
Adds a per-request tools hook to the AWS Strands adapter, in both languages, so a deployment can decide by caller identity which of the template agent's tools one request may see.
Rescoped from #1372. The issue asks for
tools_provider, which it frames as building a thread's tool list. Most of what it describes is already reachable, so what is added here is narrower: the filtering of the tools the template contributes, per request.Why the shape is a registry sync rather than a new agent
The adapter keeps one Strands
Agentper thread, and that instance is load-bearing. It is constructed with the thread'ssession_manager;index_frontend_tool_interruptsreads parked frontend-tool waits off it;_pending_interrupts_by_threadand_parked_orchestrators_by_threadare keyed alongside it;recorded_frontend_call_idslives on its state. Recreating it when a resolved tool set changes would discard a thread's persisted history and any in-flight human-in-the-loop approval at the same time.So the filter is applied to the registry that live instance already owns, which is the mechanism the adapter already uses for the tools that vary per request:
RunAgentInput.toolsare re-synced into the registry every request bysync_proxy_tools.thread_agent_kwargscan set a thread's tools, but it runs once when that thread's agent is built, so it cannot vary within a conversation.What had no per-request channel is the set the wrapped template contributed once, at construction. That is what this hook governs.
The surface
StrandsAgentConfig.template_tools_provider(Python) andStrandsAgentConfig.templateToolsProvider(TypeScript). Called once per request with that request'sRunAgentInput; may be sync or async.None(ornull/undefined) declines to filter. An empty iterable is a real answer and withholds all of them. A name the template does not contribute is dropped with a warning: the hook narrows what the wrapped agent already gave the adapter and cannot add a capability the template did not carry.The container is checked rather than merely iterated. A string and a mapping are both iterable and both mean something other than what iterating them produces: a bare name would come apart into characters, and a permission map would have its keys read as an allow-list while its values went unread, so a name mapped to false would still be allowed. Both are refused with
TEMPLATE_TOOLS_PROVIDER_ERROR, which is also what gives the two bridges one return contract rather than two, since TypeScript'sfor...ofalready refused a plain object where Python happily read a dict's keys. Lists, tuples, sets, arrays and generators are all accepted.Naming: the issue's
tools_providerbecametemplate_tools_providerbecause the hook filters the template's tools rather than providing tools, and a name that reads as the latter would be documentation debt from day one. The two languages carry the same option name (modulo case), the same return contract and one shared error code, which is not true of the per-thread agent hook next to it.The decisions the rescope asked to settle
A tool with a parked call awaiting an answer is never filtered out, and the exemption does not outlive the checkpoint it exists for. The precedent is
exempt_namesinsync_proxy_tools, and the rule is the same: a parked run is about to be resumed, and a tool absent from the registry at that moment turns the human's answer into a "tool not found" the model then re-fires.The granularity differs because the attribution differs. A frontend wait names its own tool call, so
sync_proxy_toolscan exempt exactly that proxy. A template tool can park through the approval hook, through an interrupt of its own, or through a hook, and a generic native interrupt does not name the tool it came from. What is recoverable in every case is the tool batch the checkpoint would resume, which Strands stores on the checkpoint itself (context["tool_use_message"]in Python,pendingToolExecution.assistantMessageDatain TypeScript). EverytoolUsein that batch is exempt, because that is precisely the set Strands re-dispatches on resume.The exemption is then withdrawn inside the same run. Strands does not stop after re-dispatching the batch: it clears the checkpoint and makes its next model call from the same registry, which would still be advertising what the request denied, and only the following request would narrow again. So the narrowing is re-applied once a batch has been dispatched, with the exemption recomputed rather than reused. The two bridges hook different SDK events for this, because the SDKs read the tool specs at different points relative to the events they dispatch: Python's loop dispatches
BeforeModelCallEventbefore it reads the specs off the registry, while the TypeScript agent reads them as the first statement of its model call, so it hooksAfterToolsEvent, which Python does not have.An activated checkpoint carrying a tool batch the adapter cannot decode holds every template tool rather than none. That is an SDK shape this code does not know, and holding everything costs one unfiltered turn where filtering anyway would break a resume a human is waiting on. An activated checkpoint with no pending tool execution at all is not that case and holds nothing, because an interrupt raised before any tool ran parks exactly that way.
Separately, a request that submits no answer against a parked checkpoint is refused with
PENDING_INTERRUPTSbefore the tool sync runs at all, so on that path the provider is never consulted and the pause is out of a filter's reach entirely. Both suites pin that.A filtered-out tool that already appears in this thread's history is removed from the registry, and history is not rewritten. The hook answers "what may this request call", not "what happened on this thread". Rewriting history to match would leave an assistant tool-use block with no result behind it, and would mean a provider returning different sets across turns invalidated the transcript. So the earlier calls and results stay in the thread's messages, and the model reads them as what it already did with a tool it can no longer call. Removal is not destructive either: the template tool objects outlive the registry entry, so a later request that allows the name again restores the same instance.
The removal is from the registry and not only from the advertised tool specs, deliberately. Withholding a tool from the specs while leaving it registered would make the filter advice a model can ignore; a model that calls the name anyway, primed by a stale turn or by the visible history, has to be refused by the dispatcher. Both suites pin that too.
A provider that raises fails the run, and so does a provider whose answer cannot be read.
RUN_ERRORwith codeTEMPLATE_TOOLS_PROVIDER_ERRORand the messageFailed to resolve the template tools for this request: {}, matching theTHREAD_AGENT_KWARGS_ERROR/THREAD_AGENT_CONFIG_ERRORprecedent rather than degrading to an unfiltered run. Degrading would hand the model exactly the tools the caller meant to withhold, which is the one outcome the hook exists to prevent.The answer is materialized and validated inside the same guarded step that calls the provider, so a non-container, a mapping, a bare name and a generator that raises partway through iteration all report that code rather than escaping past it. Applying the resolved selection to the registry is guarded separately and reports through the adapter's terminal-error classifier: past that point a failure is the adapter's rather than the provider's, and on the TypeScript path this block runs ahead of the main try/catch, where an escape would have ended the stream after
RUN_STARTEDwith nothing terminal behind it.The code and its text are recorded in
error-codes.jsonas shared, and each side's terminal-path suite drives the real bridge to it.Which registry entry the filter may touch
Only the template's own tools, and the adapter has exactly two other producers: proxies for client-declared tools, and the A2UI tool it injects itself. Both are re-decided every turn by the code that owns them, so neither is this filter's to remove.
Ownership is settled by object identity where identity holds, but it cannot rest on identity alone. An external per-thread agent map exists so that a request-scoped wrapper can be rebuilt while the cached thread agent and its interrupt state survive. A template whose tools are built per request, a factory or a closure over a request-scoped handle, then hands each new wrapper equivalent but not identical objects, and reading a non-match as "another producer owns this name" would make a deny-everything answer remove nothing at all: a silent failure in the permissive direction on a hook whose whole job is withholding capability. So the fallback is ownership by elimination, the name being one the template contributes and the entry not being one of the other two producers. Stable tool objects are still the simpler thing to hand the adapter, and the config docstring says so.
One collision needed handling in the other direction. The template sync runs before the proxy sync, so a client proxy squatting a filtered-out template tool's name would block the restore when the provider allowed that tool again, and the proxy sync would then remove that proxy as stale, ending the request with neither entry: the provider said yes and the model still could not see the tool. An allowed template tool now reclaims its name from a proxy, and the proxy sync that follows re-decides the client's side and skips a name a native tool holds, which is the collision rule that already applied. A proxy the parked batch is answering keeps its name.
Scope and known differences
RUN_STARTEDhas not been emitted yet when the hook runs, so the error path emits it before theRUN_ERROR, where the TypeScript path has already sent it. The wire result is identical.Tests
New:
python/tests/test_template_tool_filtering.pyandtypescript/src/__tests__/template-tool-filtering.test.ts. Both drive the real Strands SDK: a genuineAgent, its realToolRegistry, and a scripted model that records the tool specs each turn was offered, because the offered specs are the only place the filter is observable from outside the adapter.They cover, in both languages:
FileSessionManageron purpose: with no session manager configured the thread's history is reconciled againstRunAgentInput.messagesevery turn, so what a filter did to it could not be told apart from what the replay did.Plus unit coverage for selection resolution, the registry sync (same-instance restore, each ownership case, exempt names, the exempt-everything sentinel), and the parked-batch derivation including the undecodable and no-batch shapes.
Every one of the six boundary fixes in the second commit is pinned by a test that goes red when that fix alone is reverted, checked by reverting each.
Existing suites pass in both languages: 1370 passed and 1 skipped (Python), 1731 passed (TypeScript), plus the TypeScript examples suite (78) and
tsc --noEmit.Docs
A "Per-request tool filtering" section in each README stating the surface, the return contract, all three decisions and the deployment note about stable tool objects; the config row in
ARCHITECTURE.md; the new module in the module layout and both Key Files tables; and the new code inerror-codes.jsonwith a note on why it is terminal.