Skip to content

Commit 6dee0a7

Browse files
committed
feat(aws-strands): filter the template agent's tools per request
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.
1 parent 33b1caf commit 6dee0a7

18 files changed

Lines changed: 2118 additions & 8 deletions

integrations/aws-strands/ARCHITECTURE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ This document explains how the AWS Strands integration inside `integrations/aws-
150150
| `tool_behaviors: Dict[str, ToolBehavior]` | Per-tool overrides keyed by the Strands tool name. |
151151
| `state_context_builder` | Callable that enriches the outgoing prompt with the current shared state (useful for reiterating plan steps, recipes, etc.). |
152152
| `session_manager_provider` | Factory invoked once per thread to produce a per-thread `SessionManager`. |
153+
| `template_tools_provider` | Per-request choice of which template tools this request may see, applied to the live per-thread registry. |
153154
| `emit_messages_snapshot` | Global opt-out of the four-point `MESSAGES_SNAPSHOT` emission. Default `True`. |
154155
| `replay_history_into_strands` | Global opt-out of the per-run Strands history reconciliation. Default `True`. |
155156

@@ -206,6 +207,7 @@ typescript/src/
206207
├── agent.ts ← StrandsAgent (port of agent.py)
207208
├── client-proxy-tool.ts ← sync of RunAgentInput.tools into Strands registry
208209
├── config.ts ← StrandsAgentConfig, ToolBehavior, helpers
210+
├── template-tools.ts ← per-request filter over the template agent's tools
209211
├── endpoint.ts ← Express route registration + capabilities endpoint
210212
├── logger.ts ← injectable Logger interface + internal default
211213
├── session-reconcile.ts ← port of session_reconcile.py, snapshot-shaped

integrations/aws-strands/error-codes.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@
170170
"sides": ["python", "typescript"],
171171
"messages": ["{}"]
172172
},
173+
{
174+
"code": "TEMPLATE_TOOLS_PROVIDER_ERROR",
175+
"sides": ["python", "typescript"],
176+
"messages": ["Failed to resolve the template tools for this request: {}"],
177+
"note": "Reported when the per-request template-tools hook throws. Unlike the per-thread agent hook beside it, the option carries one name on both sides (template_tools_provider / templateToolsProvider) and returns the same thing, a selection of the template's tools, so there is one code and one sentence. Terminal rather than degrading to an unfiltered run: the hook exists to withhold tools from a caller, and failing open would hand the model exactly what the caller meant to keep back."
178+
},
173179
{
174180
"code": "THREAD_AGENT_CONFIG_ERROR",
175181
"sides": ["typescript"],

integrations/aws-strands/python/README.md

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,14 @@ See [ARCHITECTURE.md](../ARCHITECTURE.md) for diagrams and a deeper dive.
7777

7878
## Key Files
7979

80-
| File | Description |
81-
| ------------------------------- | ------------------------------------------------------------------------------- |
82-
| `src/ag_ui_strands/agent.py` | Core wrapper translating Strands streams into AG-UI events |
83-
| `src/ag_ui_strands/config.py` | Config primitives (`StrandsAgentConfig`, `ToolBehavior`, `PredictStateMapping`) |
84-
| `src/ag_ui_strands/endpoint.py` | FastAPI endpoint helper |
85-
| `src/ag_ui_strands/utils.py` | `create_strands_app`, multimodal conversion, and `UrlFetchPolicy` |
86-
| `examples/server/api/*.py` | Ready-to-run demo apps |
80+
| File | Description |
81+
| ------------------------------------- | ------------------------------------------------------------------------------- |
82+
| `src/ag_ui_strands/agent.py` | Core wrapper translating Strands streams into AG-UI events |
83+
| `src/ag_ui_strands/config.py` | Config primitives (`StrandsAgentConfig`, `ToolBehavior`, `PredictStateMapping`) |
84+
| `src/ag_ui_strands/template_tools.py` | Per-request filter over the template agent's tools |
85+
| `src/ag_ui_strands/endpoint.py` | FastAPI endpoint helper |
86+
| `src/ag_ui_strands/utils.py` | `create_strands_app`, multimodal conversion, and `UrlFetchPolicy` |
87+
| `examples/server/api/*.py` | Ready-to-run demo apps |
8788

8889
## Amazon Bedrock AgentCore considerations
8990

@@ -198,6 +199,66 @@ trusted values from client-controlled `forwarded_props`; derive them from
198199
authenticated request context instead. Custom routes can pass the same state
199200
directly with `agent.run(input_data, invocation_state={...})`.
200201

202+
## Per-request tool filtering
203+
204+
`StrandsAgentConfig.template_tools_provider` decides which of the template
205+
agent's tools one request may see. It is called once per request with that
206+
request's `RunAgentInput`, so the answer can vary turn by turn on a single
207+
thread:
208+
209+
```python
210+
from ag_ui.core import RunAgentInput
211+
from ag_ui_strands import StrandsAgent, StrandsAgentConfig
212+
213+
READ_ONLY = ["search_docs", "get_order"]
214+
215+
def tools_for(input_data: RunAgentInput):
216+
# Derive the role from authenticated request context in production;
217+
# forwarded_props is client-controlled.
218+
if (input_data.forwarded_props or {}).get("role") == "admin":
219+
return None # no filtering: every template tool stays available
220+
return READ_ONLY
221+
222+
agui_agent = StrandsAgent(
223+
strands_agent,
224+
name="assistant",
225+
config=StrandsAgentConfig(template_tools_provider=tools_for),
226+
)
227+
```
228+
229+
Return the tools themselves or their names. `None` declines to filter; an empty
230+
list is a real answer and withholds all of them. A name the template does not
231+
contribute is dropped with a warning, because the hook narrows the wrapped
232+
agent's tools and cannot add one. The provider may be async.
233+
234+
The filter is applied to the tool registry the thread's live Strands `Agent`
235+
already owns, the same way client-declared tools are synchronised, and never by
236+
rebuilding that agent. The per-thread instance holds the thread's
237+
`SessionManager`, its native interrupt checkpoint and its history, so replacing
238+
it to change a tool list would discard a conversation and any approval waiting
239+
inside it.
240+
241+
Three consequences follow from that:
242+
243+
- **A parked call is never orphaned.** A tool in the batch a live interrupt
244+
checkpoint would resume stays registered whatever the provider returns: the
245+
human's answer is about to be routed back into that batch, and an absent tool
246+
turns it into a "tool not found" the model re-fires. Filtering resumes once
247+
the pause closes. This is the rule `sync_proxy_tools` already applies to a
248+
proxy parked in a frontend-tool interrupt.
249+
- **History is never rewritten.** A filtered-out tool's earlier calls and
250+
results stay in the thread's messages, so the model can still read what it
251+
did with a tool it can no longer call.
252+
- **A failure is terminal.** If the provider raises, the run yields `RUN_ERROR`
253+
with code `TEMPLATE_TOOLS_PROVIDER_ERROR` and stops, matching
254+
`thread_agent_kwargs`. A filter that failed open would hand the model exactly
255+
the tools the caller meant to withhold.
256+
257+
Scope is the template's own tools. Client-declared tools on
258+
`RunAgentInput.tools` are re-synchronised from the request every turn already,
259+
so a caller that wants fewer of those sends fewer. The hook is not applied on
260+
the multi-agent orchestrator path, which has no template registry to filter.
261+
201262
## Human-in-the-loop (native Strands interrupts)
202263

203264
Python frontend tools explicitly configured with

integrations/aws-strands/python/src/ag_ui_strands/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
from .citations import CITATIONS_METADATA_KEY
2020
from .client_proxy_tool import create_proxy_tool, sync_proxy_tools
21+
from .template_tools import sync_template_tools
2122
from .utils import (
2223
DEFAULT_URL_FETCH_POLICY,
2324
InvocationStateProvider,
@@ -34,6 +35,7 @@
3435
ToolStreamEventContext,
3536
PredictStateMapping,
3637
SessionManagerProvider,
38+
TemplateToolsProvider,
3739
ToolStreamEventHandler,
3840
)
3941
from ag_ui.core import (
@@ -57,6 +59,7 @@
5759
"CITATIONS_METADATA_KEY",
5860
"create_proxy_tool",
5961
"sync_proxy_tools",
62+
"sync_template_tools",
6063
"create_strands_app",
6164
"UrlFetchPolicy",
6265
"UrlFetchPolicyError",
@@ -71,6 +74,7 @@
7174
"ToolStreamEventContext",
7275
"PredictStateMapping",
7376
"SessionManagerProvider",
77+
"TemplateToolsProvider",
7478
"ToolStreamEventHandler",
7579
"Interrupt",
7680
"ResumeEntry",

integrations/aws-strands/python/src/ag_ui_strands/agent.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,6 +1364,10 @@ def _error_events(
13641364
sync_proxy_tools,
13651365
waits_for_frontend_call,
13661366
)
1367+
from .template_tools import (
1368+
parked_batch_tool_names,
1369+
sync_template_tools,
1370+
)
13671371
from .frontend_tool_interrupt import (
13681372
frontend_tool_response_schema,
13691373
index_frontend_tool_interrupts,
@@ -4156,6 +4160,38 @@ async def _run_raw(
41564160
except Exception as e:
41574161
logger.warning(f"Failed to set agui_context on strands_agent.state: {e}")
41584162

4163+
# Filter the tools the template contributed, per request. Applied to
4164+
# the registry this thread's live agent already owns: that instance
4165+
# carries the thread's session manager, its interrupt checkpoint and
4166+
# its history, so rebuilding it to change a tool list would discard a
4167+
# conversation and any approval waiting inside it.
4168+
if self.config.template_tools_provider is not None:
4169+
try:
4170+
template_tool_selection = await maybe_await(
4171+
self.config.template_tools_provider(input_data)
4172+
)
4173+
except Exception as e: # noqa: BLE001 - surfaced as RUN_ERROR
4174+
logger.error(
4175+
"template_tools_provider failed: %s", e, exc_info=True
4176+
)
4177+
# Deliberately terminal rather than unfiltered: a filter that
4178+
# fails open hands the model tools the caller meant to withhold.
4179+
ev_started, ev_error = _error_events(
4180+
input_data,
4181+
"Failed to resolve the template tools for this request: "
4182+
f"{e}",
4183+
"TEMPLATE_TOOLS_PROVIDER_ERROR",
4184+
)
4185+
yield ev_started
4186+
yield ev_error
4187+
return
4188+
sync_template_tools(
4189+
strands_agent.tool_registry,
4190+
self._tools,
4191+
template_tool_selection,
4192+
exempt_names=parked_batch_tool_names(strands_agent),
4193+
)
4194+
41594195
# Sync proxy tools from client-defined tools. A proxy parked in a live
41604196
# frontend-tool interrupt is exempt from removal: Strands is about to
41614197
# resume that tool, and an absent registry entry turns the client's

integrations/aws-strands/python/src/ag_ui_strands/config.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,16 @@ class ToolBehavior:
139139
"""
140140

141141

142+
TemplateToolsProvider = Callable[
143+
["RunAgentInput"],
144+
Awaitable[Optional[Iterable[Any]]] | Optional[Iterable[Any]],
145+
]
146+
"""Chooses which of the template's tools one request may see.
147+
148+
See :attr:`StrandsAgentConfig.template_tools_provider`.
149+
"""
150+
151+
142152
@dataclass
143153
class StrandsAgentConfig:
144154
"""Top-level configuration for the Strands agent adapter."""
@@ -169,6 +179,57 @@ class StrandsAgentConfig:
169179
the run yields ``RUN_ERROR`` and the thread is not cached, so the next
170180
request retries it.
171181
"""
182+
template_tools_provider: Optional["TemplateToolsProvider"] = None
183+
"""Which of the template agent's tools this request may see.
184+
185+
Called once per request with that request's ``RunAgentInput``, so the answer
186+
can vary turn by turn on one thread: the caller's identity is in
187+
``forwarded_props`` or ``context``, and a tool the request must not reach is
188+
simply left out of the returned iterable. May be async.
189+
190+
Return the tools themselves or their names, whichever is to hand. Return
191+
``None`` to decline filtering, which leaves every template tool available;
192+
an empty iterable is a real answer and leaves none of them. A name the
193+
template does not contribute is dropped with a warning, because this hook
194+
narrows the wrapped agent's tools and cannot add one.
195+
196+
Applied to the live per-thread agent's tool registry, never by rebuilding
197+
that agent: the instance holds the thread's ``SessionManager``, its native
198+
interrupt checkpoint and its history, so replacing it to change a tool list
199+
would discard a conversation and any approval waiting inside it.
200+
201+
Three consequences worth knowing:
202+
203+
- A tool in the batch a live interrupt checkpoint would resume stays
204+
registered whatever this returns. The human's answer is about to be
205+
routed back into that batch, and an absent tool turns it into a "tool not
206+
found" the model re-fires. Filtering resumes once the pause closes. This
207+
is the rule ``sync_proxy_tools`` already applies to a proxy parked in a
208+
frontend-tool interrupt.
209+
- History is never rewritten. A filtered-out tool's earlier calls and
210+
results stay in the thread's messages, so the model can still read what
211+
it did with a tool it can no longer call, and a provider that returns
212+
different sets across turns does not invalidate the transcript.
213+
- If it raises, the run yields ``RUN_ERROR`` with code
214+
``TEMPLATE_TOOLS_PROVIDER_ERROR`` and stops, matching
215+
``thread_agent_kwargs``. A filter that fails open would hand the model
216+
tools the caller meant to withhold.
217+
218+
Client-declared tools on ``RunAgentInput.tools`` are outside this hook:
219+
they are re-synchronised from the request every turn already, so a caller
220+
that wants fewer of those sends fewer. Not applied on the multi-agent
221+
orchestrator path, which has no template registry to filter.
222+
223+
Example::
224+
225+
StrandsAgentConfig(
226+
template_tools_provider=lambda input_data: (
227+
["read_docs"]
228+
if (input_data.forwarded_props or {}).get("role") != "admin"
229+
else None
230+
)
231+
)
232+
"""
172233
session_manager_provider: Optional[SessionManagerProvider] = None
173234
"""Optional factory for creating per-thread SessionManager instances.
174235

0 commit comments

Comments
 (0)