Skip to content

Commit 20bb06c

Browse files
authored
Merge pull request #2616 from ag-ui-protocol/claude/objective-cohen-fb1f7d
feat(aws-strands): filter the template agent's tools per request
2 parents faee4b1 + 83643a5 commit 20bb06c

18 files changed

Lines changed: 3559 additions & 1 deletion

integrations/aws-strands/ARCHITECTURE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ This document explains how the AWS Strands integration inside `integrations/aws-
170170
| `tool_behaviors: Dict[str, ToolBehavior]` | Per-tool overrides keyed by the Strands tool name. |
171171
| `state_context_builder` | Callable that enriches the outgoing prompt with the current shared state (useful for reiterating plan steps, recipes, etc.). |
172172
| `session_manager_provider` | Factory invoked once per thread to produce a per-thread `SessionManager`. |
173+
| `template_tools_provider` | Per-request choice of which of the template agent's tools this request may see, applied to the live per-thread registry. TypeScript's equivalent field is `templateToolsProvider`. |
173174
| `thread_agent_kwargs` | Callable returning extra constructor kwargs for one thread's `StrandsAgentCore`. TypeScript's `threadAgentConfig` returns a partial `AgentConfig` instead. |
174175
| `emit_messages_snapshot` | Global opt-out of the four-point `MESSAGES_SNAPSHOT` emission. Default `True`. |
175176
| `replay_history_into_strands` | Global opt-out of the per-run Strands history reconciliation. Default `True`. |
@@ -247,6 +248,7 @@ typescript/src/
247248
├── logger.ts ← injectable Logger interface + internal default
248249
├── server.ts ← createStrandsApp factory + CORS/auth wiring
249250
├── session-reconcile.ts ← port of session_reconcile.py, snapshot-shaped
251+
├── template-tools.ts ← per-request filter over the template agent's tools
250252
├── types.ts ← internal SeenToolCall bookkeeping
251253
├── utils.ts ← content conversion + UrlFetchPolicy
252254
└── index.ts ← public exports

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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ both routes above.
129129
| `src/ag_ui_strands/a2ui_tool.py` | A2UI tool injection and the validate-and-retry recovery loop |
130130
| `src/ag_ui_strands/session_reconcile.py` | Frontend-result reconciliation against a persisted session |
131131
| `src/ag_ui_strands/client_proxy_tool.py` | Frontend tools registered into the Strands tool registry |
132+
| `src/ag_ui_strands/template_tools.py` | Per-request filter over the template agent's own tools |
132133
| `src/ag_ui_strands/frontend_tool_interrupt.py` | The native checkpoint a waiting frontend tool parks in |
133134
| `examples/server/api/*.py` | Ready-to-run demo apps |
134135

@@ -251,6 +252,98 @@ trusted values from client-controlled `forwarded_props`; derive them from
251252
authenticated request context instead. Custom routes can pass the same state
252253
directly with `agent.run(input_data, invocation_state={...})`.
253254

255+
## Per-request tool filtering
256+
257+
`StrandsAgentConfig.template_tools_provider` decides which of the template
258+
agent's tools one request may see. It is called once per request with that
259+
request's `RunAgentInput`, so the answer can vary turn by turn on a single
260+
thread:
261+
262+
```python
263+
from ag_ui.core import RunAgentInput
264+
from ag_ui_strands import StrandsAgent, StrandsAgentConfig
265+
266+
READ_ONLY = ["search_docs", "get_order"]
267+
268+
def tools_for(input_data: RunAgentInput):
269+
# Derive the role from authenticated request context in production;
270+
# forwarded_props is client-controlled.
271+
if (input_data.forwarded_props or {}).get("role") == "admin":
272+
return None # no filtering: every template tool stays available
273+
return READ_ONLY
274+
275+
agui_agent = StrandsAgent(
276+
strands_agent,
277+
name="assistant",
278+
config=StrandsAgentConfig(template_tools_provider=tools_for),
279+
)
280+
```
281+
282+
Return the tools themselves or their names. `None` declines to filter; an empty
283+
list is a real answer and withholds all of them. A name the template does not
284+
contribute is dropped with a warning, because the hook narrows the wrapped
285+
agent's tools and cannot add one. The provider may be async.
286+
287+
Two boundary rules follow from that:
288+
289+
- **The return value is checked, not merely iterated.** A string and a mapping
290+
are both iterable and both mean something other than what iterating them
291+
produces: a bare name would come apart into characters, and a permission map
292+
would have its keys read as an allow-list while its values went unread, so a
293+
name mapped to `False` would still be allowed. Both are refused with
294+
`TEMPLATE_TOOLS_PROVIDER_ERROR`. Lists, tuples, sets and generators are all
295+
accepted, and a generator that raises partway through iteration reports the
296+
same code, because the answer is read inside the same guarded step that calls
297+
the provider.
298+
- **The filter reaches the registry, not only the advertised tool specs.** A
299+
model that calls a withheld name anyway, primed by a stale turn or by the
300+
visible history, is refused by the dispatcher rather than served.
301+
302+
The filter is applied to the tool registry the thread's live Strands `Agent`
303+
already owns, the same way client-declared tools are synchronised, and never by
304+
rebuilding that agent. The per-thread instance holds the thread's
305+
`SessionManager`, its native interrupt checkpoint and its history, so replacing
306+
it to change a tool list would discard a conversation and any approval waiting
307+
inside it.
308+
309+
Three consequences follow from that:
310+
311+
- **A parked call is never orphaned.** A tool in the batch a live interrupt
312+
checkpoint would resume stays registered whatever the provider returns: the
313+
human's answer is about to be routed back into that batch, and an absent tool
314+
turns it into a "tool not found" the model re-fires. Filtering resumes once
315+
the pause closes. This is the rule `sync_proxy_tools` already applies to a
316+
proxy parked in a frontend-tool interrupt.
317+
- **History is never rewritten.** A filtered-out tool's earlier calls and
318+
results stay in the thread's messages, so the model can still read what it
319+
did with a tool it can no longer call.
320+
- **A failure is terminal.** If the provider raises, the run yields `RUN_ERROR`
321+
with code `TEMPLATE_TOOLS_PROVIDER_ERROR` and stops, matching
322+
`thread_agent_kwargs`. A filter that failed open would hand the model exactly
323+
the tools the caller meant to withhold.
324+
325+
The narrowing is also re-applied inside the run, once a tool batch has been
326+
dispatched. The exemption above keeps a denied tool registered so a human's
327+
answer can reach it, and Strands then carries on in the same run: it
328+
re-dispatches the batch and makes its next model call from the same registry,
329+
which would otherwise still be advertising what the request denied. The two
330+
bridges hook different SDK events for this, because the SDKs read the tool
331+
specs at different points relative to the events they dispatch; the effect is
332+
the same on both.
333+
334+
Scope is the template's own tools. Client-declared tools on
335+
`RunAgentInput.tools` are re-synchronised from the request every turn already,
336+
so a caller that wants fewer of those sends fewer. The hook is not applied on
337+
the multi-agent orchestrator path, which has no template registry to filter.
338+
339+
One deployment note. With an external per-thread agent map, a request-scoped
340+
wrapper is rebuilt per request while the cached thread agent keeps the registry
341+
it already had. If the template's tools are built per request too, the adapter
342+
is handed equivalent but not identical objects, so which registry entry belongs
343+
to the template is decided by name plus "not one of the adapter's other
344+
producers" rather than by object identity alone. Stable tool objects are still
345+
the simpler thing to hand it.
346+
254347
## Human-in-the-loop (native Strands interrupts)
255348

256349
Python frontend tools configured with

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
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 (
22+
EXEMPT_EVERY_TEMPLATE_TOOL,
23+
TemplateToolsSelectionError,
24+
sync_template_tools,
25+
)
2126
from .utils import (
2227
DEFAULT_URL_FETCH_POLICY,
2328
InvocationStateProvider,
@@ -34,6 +39,7 @@
3439
ToolStreamEventContext,
3540
PredictStateMapping,
3641
SessionManagerProvider,
42+
TemplateToolsProvider,
3743
ToolStreamEventHandler,
3844
)
3945
from ag_ui.core import (
@@ -57,6 +63,9 @@
5763
"CITATIONS_METADATA_KEY",
5864
"create_proxy_tool",
5965
"sync_proxy_tools",
66+
"sync_template_tools",
67+
"TemplateToolsSelectionError",
68+
"EXEMPT_EVERY_TEMPLATE_TOOL",
6069
"create_strands_app",
6170
"UrlFetchPolicy",
6271
"UrlFetchPolicyError",
@@ -71,6 +80,7 @@
7180
"ToolStreamEventContext",
7281
"PredictStateMapping",
7382
"SessionManagerProvider",
83+
"TemplateToolsProvider",
7484
"ToolStreamEventHandler",
7585
"Interrupt",
7686
"ResumeEntry",

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1438,6 +1438,14 @@ def _error_events(
14381438
sync_proxy_tools,
14391439
waits_for_frontend_call,
14401440
)
1441+
from .template_tools import (
1442+
TemplateToolsNarrowingHook,
1443+
apply_template_tool_selection,
1444+
index_template_tools,
1445+
parked_batch_tool_names,
1446+
record_template_tool_selection,
1447+
resolve_template_tool_selection,
1448+
)
14411449
from .frontend_tool_interrupt import (
14421450
frontend_tool_response_schema,
14431451
index_frontend_tool_interrupts,
@@ -3383,6 +3391,14 @@ def __init__(
33833391
if interrupt_tools:
33843392
self._hooks = [StrandsInterruptHook(interrupt_tools), *self._hooks]
33853393

3394+
# Re-narrow the per-request tool filter before each model call. The
3395+
# parked-batch exemption holds a denied tool registered so a resume can
3396+
# reach it, and Strands then continues the same run against the same
3397+
# registry; without this the run would keep advertising what the
3398+
# request denied until the next request narrowed again.
3399+
if self.config.template_tools_provider is not None:
3400+
self._hooks = [*self._hooks, TemplateToolsNarrowingHook(self._tools)]
3401+
33863402
# Detect the common footgun: session_manager set on the template Agent
33873403
# (stored as `_session_manager` by Strands) with no per-thread provider.
33883404
# Forwarding it would make every AG-UI thread share one session_id.
@@ -4486,6 +4502,65 @@ async def _run_raw(
44864502
except Exception as e:
44874503
logger.warning(f"Failed to set agui_context on strands_agent.state: {e}")
44884504

4505+
# Filter the tools the template contributed, per request. Applied to
4506+
# the registry this thread's live agent already owns: that instance
4507+
# carries the thread's session manager, its interrupt checkpoint and
4508+
# its history, so rebuilding it to change a tool list would discard a
4509+
# conversation and any approval waiting inside it.
4510+
if self.config.template_tools_provider is not None:
4511+
# Calling the provider and reading its answer are guarded
4512+
# together. Reading is where a mapping, a bare name or a generator
4513+
# that raises partway through is caught, and those are provider
4514+
# mistakes: leaving them outside this arm would let them bypass the
4515+
# documented code and, on the TypeScript side, end the stream with
4516+
# nothing terminal behind it.
4517+
try:
4518+
template_tool_allowed = resolve_template_tool_selection(
4519+
await maybe_await(
4520+
self.config.template_tools_provider(input_data)
4521+
),
4522+
index_template_tools(self._tools),
4523+
)
4524+
except Exception as e: # noqa: BLE001 - surfaced as RUN_ERROR
4525+
logger.error(
4526+
"template_tools_provider failed: %s", e, exc_info=True
4527+
)
4528+
# Deliberately terminal rather than unfiltered: a filter that
4529+
# fails open hands the model tools the caller meant to withhold.
4530+
ev_started, ev_error = _error_events(
4531+
input_data,
4532+
"Failed to resolve the template tools for this request: "
4533+
f"{e}",
4534+
"TEMPLATE_TOOLS_PROVIDER_ERROR",
4535+
)
4536+
yield ev_started
4537+
yield ev_error
4538+
return
4539+
# Guarded separately, and not as a provider error: past this point
4540+
# a failure is this adapter's, and it still must not escape as a
4541+
# stream that stops with nothing terminal behind it.
4542+
try:
4543+
apply_template_tool_selection(
4544+
strands_agent.tool_registry,
4545+
self._tools,
4546+
template_tool_allowed,
4547+
exempt_names=parked_batch_tool_names(strands_agent),
4548+
)
4549+
except Exception as e: # noqa: BLE001 - surfaced as RUN_ERROR
4550+
logger.error(
4551+
"Applying the template tool filter failed: %s", e, exc_info=True
4552+
)
4553+
ev_started, ev_error = _error_events(
4554+
input_data, str(e), _terminal_error_code(e)
4555+
)
4556+
yield ev_started
4557+
yield ev_error
4558+
return
4559+
# Published for the re-narrowing hook: the exemption above holds a
4560+
# denied tool registered so a resume can reach it, and Strands then
4561+
# continues the same run from this registry.
4562+
record_template_tool_selection(strands_agent, template_tool_allowed)
4563+
44894564
# Sync proxy tools from client-defined tools. A proxy parked in a live
44904565
# frontend-tool interrupt is exempt from removal: Strands is about to
44914566
# resume that tool, and an absent registry entry turns the client's

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

Lines changed: 76 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,72 @@ 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+
The container is checked rather than merely iterated. A ``str`` and a
197+
``Mapping`` are both refused: a bare name would come apart into characters,
198+
and a permission map would have its keys read as an allow-list while its
199+
values went unread, so a name mapped to ``False`` would still be allowed.
200+
Lists, tuples, sets and generators are all accepted.
201+
202+
Applied to the live per-thread agent's tool registry, never by rebuilding
203+
that agent: the instance holds the thread's ``SessionManager``, its native
204+
interrupt checkpoint and its history, so replacing it to change a tool list
205+
would discard a conversation and any approval waiting inside it.
206+
207+
Three consequences worth knowing:
208+
209+
- A tool in the batch a live interrupt checkpoint would resume stays
210+
registered whatever this returns. The human's answer is about to be
211+
routed back into that batch, and an absent tool turns it into a "tool not
212+
found" the model re-fires. This is the rule ``sync_proxy_tools`` already
213+
applies to a proxy parked in a frontend-tool interrupt. The exemption
214+
does not outlast what it is for: the narrowing is re-applied inside the
215+
run once the batch has been dispatched, before the model is asked again.
216+
- History is never rewritten. A filtered-out tool's earlier calls and
217+
results stay in the thread's messages, so the model can still read what
218+
it did with a tool it can no longer call, and a provider that returns
219+
different sets across turns does not invalidate the transcript.
220+
- If it raises, the run yields ``RUN_ERROR`` with code
221+
``TEMPLATE_TOOLS_PROVIDER_ERROR`` and stops, matching
222+
``thread_agent_kwargs``. A filter that fails open would hand the model
223+
tools the caller meant to withhold.
224+
225+
Client-declared tools on ``RunAgentInput.tools`` are outside this hook:
226+
they are re-synchronised from the request every turn already, so a caller
227+
that wants fewer of those sends fewer. Not applied on the multi-agent
228+
orchestrator path, which has no template registry to filter.
229+
230+
One deployment note. With an external ``agents_by_thread`` map a
231+
request-scoped wrapper is rebuilt per request while the cached thread agent
232+
keeps the registry it already had, so a template whose tools are built per
233+
request hands the adapter equivalent but not identical objects. Ownership
234+
of a registry entry therefore falls back from object identity to the tool's
235+
name plus "not one of the adapter's other producers". Stable tool objects
236+
are still the simpler thing to hand it.
237+
238+
Example::
239+
240+
StrandsAgentConfig(
241+
template_tools_provider=lambda input_data: (
242+
["read_docs"]
243+
if (input_data.forwarded_props or {}).get("role") != "admin"
244+
else None
245+
)
246+
)
247+
"""
172248
session_manager_provider: Optional[SessionManagerProvider] = None
173249
"""Optional factory for creating per-thread SessionManager instances.
174250

0 commit comments

Comments
 (0)