Skip to content

Commit 1c60523

Browse files
authored
Merge pull request #2612 from ag-ui-protocol/fix/aws-strands-python-plugins-and-agent-bound-warning
fix(aws-strands): forward plugins per-thread and warn on agent-bound params
2 parents a6fa2a5 + 277b34f commit 1c60523

4 files changed

Lines changed: 628 additions & 27 deletions

File tree

integrations/aws-strands/python/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,41 @@ The integration has three main layers:
8282

8383
See [ARCHITECTURE.md](../ARCHITECTURE.md) for diagrams and a deeper dive.
8484

85+
## Per-thread agents: hooks and plugins
86+
87+
The wrapper does not run the agent you hand it. That one is a template: the
88+
adapter reads its constructor settings back off the instance and builds a fresh
89+
`strands.Agent` per `thread_id`, so one conversation cannot see another's
90+
history. Most settings survive that rebuild automatically.
91+
92+
Two do not, because Strands consumes them during construction rather than
93+
keeping the list you passed. Hooks become a `HookRegistry`, and plugins are run
94+
against the agent that received them and recorded in a registry bound to it.
95+
Neither can be read back or handed to a second agent, so a template is the one
96+
place they will not work. Pass them to the wrapper instead and every per-thread
97+
agent gets its own:
98+
99+
```python
100+
agui_agent = StrandsAgent(
101+
agent=strands_agent,
102+
name="my_agent",
103+
hooks=[MyHookProvider()],
104+
plugins=[AgentSkills(skills="./skills/")],
105+
)
106+
```
107+
108+
Set either on the template and the adapter logs a warning naming the setting
109+
the first time a thread is built, rather than dropping it in silence. For a
110+
value that has to differ per thread, build it in
111+
`StrandsAgentConfig.thread_agent_kwargs`, which runs per request and wins over
112+
both routes above.
113+
114+
| Scenario | Support boundary |
115+
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
116+
| `hooks=[...]` | Supported on every release this package supports. |
117+
| `plugins=[...]` | Requires `strands-agents >= 1.28.0`, the release that added `plugins` to `Agent`. On an older release the wrapper raises `TypeError` when it is constructed, not on the first request. |
118+
| `hooks` / `plugins` with a multi-agent orchestrator | Ignored. An orchestrator is invoked directly, so there is no per-thread agent to attach them to. |
119+
85120
## Key Files
86121

87122
| File | Description |

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

Lines changed: 168 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
# "session_manager" is excluded: it is supplied per-thread via
4545
# StrandsAgentConfig.session_manager_provider (see run()). Forwarding a
4646
# template-level session_manager would make every thread share one session_id.
47+
# "plugins" is excluded: Agent consumes the list during init, registering each
48+
# plugin's hooks and tools into its own registries and keeping only a registry
49+
# bound to that agent, so there is no list to read back. Callers supply them
50+
# per-thread through the explicit StrandsAgent(plugins=...) kwarg.
4751
_AGUI_EXPLICIT_PARAMS = {
4852
"self",
4953
"model",
@@ -52,6 +56,7 @@
5256
"messages",
5357
"hooks",
5458
"session_manager",
59+
"plugins",
5560
}
5661

5762

@@ -171,6 +176,63 @@ def _read(prop: property) -> Any:
171176
return _MISSING
172177

173178

179+
# Whether the installed Strands takes ``plugins`` on its Agent constructor.
180+
# The plugin system arrived after this package's declared strands-agents floor,
181+
# so the adapter's own ``plugins=`` kwarg can be handed a release with nowhere
182+
# to put it. Probed off the signature rather than compared against a version,
183+
# for the same reason the forwarding probe is: what matters is the parameter
184+
# being there, not which release put it there.
185+
_STRANDS_ACCEPTS_PLUGINS = (
186+
"plugins" in inspect.signature(StrandsAgentCore.__init__).parameters
187+
)
188+
189+
190+
# Strands namespaces the plugins it registers on every Agent itself, and
191+
# registers them whether or not the caller passed any. Anything under this
192+
# prefix is therefore the SDK's, not a setting to report as dropped.
193+
_SDK_PLUGIN_NAME_PREFIX = "strands:"
194+
195+
196+
def _template_plugin_names(agent: Any) -> List[str]:
197+
"""Names of the plugins the caller put on the template.
198+
199+
``plugins`` is handled through an explicit kwarg, so the generic probe
200+
skips it and would never report it. Reading the registry here is what
201+
lets a caller who set plugins on the template be told they do not carry,
202+
instead of getting silence.
203+
204+
Strands' own plugins are filtered out by name. Every Agent is built with
205+
at least one of them, so counting them would warn every caller about a
206+
setting nobody made. A caller plugin that borrowed the SDK's prefix would
207+
be missed by this, which is the harmless direction: the cost is one
208+
warning not said, against a warning said to everyone.
209+
"""
210+
for attr in _candidate_attributes("plugins"):
211+
try:
212+
holder = getattr(agent, attr, None)
213+
except Exception: # noqa: BLE001 - a raising property is not a plugin list
214+
continue
215+
if holder is None:
216+
continue
217+
if isinstance(holder, (list, tuple)):
218+
contents: Any = holder
219+
else:
220+
contents = _registry_contents(holder)
221+
if contents is _MISSING or not contents:
222+
continue
223+
names = []
224+
for plugin in contents:
225+
name = getattr(plugin, "name", None)
226+
# An entry with no readable name cannot be attributed to the SDK,
227+
# so it counts as the caller's rather than being dropped silently.
228+
label = name if isinstance(name, str) else type(plugin).__name__
229+
if not label.startswith(_SDK_PLUGIN_NAME_PREFIX):
230+
names.append(label)
231+
if names:
232+
return names
233+
return []
234+
235+
174236
def _element_type(annotation: Any) -> Any:
175237
"""The element type of a ``list[X]``-shaped annotation, or ``None``.
176238
@@ -2997,6 +3059,7 @@ def __init__(
29973059
description: str = "",
29983060
config: "StrandsAgentConfig | None" = None,
29993061
hooks: "list | None" = None,
3062+
plugins: "list | None" = None,
30003063
agents_by_thread: "Dict[str, Any] | None" = None,
30013064
):
30023065
# Detect a multi-agent orchestrator structurally. A Graph or Swarm has
@@ -3065,9 +3128,18 @@ def __init__(
30653128
self._unreadable_params = []
30663129
self._template_owned_params = []
30673130

3068-
# Params wired to the template are a known structural limit, not a
3069-
# surprise, so they are recorded without a warning. Params this adapter
3070-
# could not read at all are the ones worth interrupting for.
3131+
# ``plugins`` is handled explicitly, so the generic probe above skips
3132+
# it and cannot report it. A template built with plugins is still a
3133+
# dropped setting, so record it here and let it be reported through the
3134+
# same route as every other param that will not carry.
3135+
if self._orchestrator is None and _template_plugin_names(agent):
3136+
self._template_owned_params.append("plugins")
3137+
3138+
# Both kinds of param will fail to reach per-thread agents, and both
3139+
# are reported when a thread is built. They are kept apart because they
3140+
# ask for different reading: an unreadable param is a gap in this
3141+
# adapter that a later release may close, while one the SDK wired to
3142+
# the agent that received it will never carry.
30713143
self._unforwardable_params = [
30723144
*self._unreadable_params,
30733145
*self._template_owned_params,
@@ -3096,6 +3168,43 @@ def __init__(
30963168
# observability / loop-cap / policy-enforcement hook actually fires.
30973169
self._hooks = list(hooks) if hooks else []
30983170

3171+
# Plugins forwarded to each per-thread StrandsAgentCore.
3172+
#
3173+
# A dedicated kwarg for the same reason ``hooks`` has one, one step
3174+
# further along. Strands consumes the plugin list during init: it calls
3175+
# each plugin's ``init_agent`` and registers its hooks and tools into
3176+
# that agent's registries, keeping only a registry bound to that agent.
3177+
# There is no list left to read back, and the registry cannot be handed
3178+
# to a second agent. Since the template never serves a request, a
3179+
# plugin registered there never runs against the agents that do, and a
3180+
# plugin whose whole behaviour lives in ``init_agent`` silently does
3181+
# nothing. Taking them from the caller instead lets every per-thread
3182+
# agent build its own.
3183+
self._plugins = list(plugins) if plugins else []
3184+
# Refused at wrap time rather than on the first request. Without this
3185+
# the kwarg reaches a constructor with no parameter for it and Strands
3186+
# raises a bare TypeError from inside per-thread construction, which
3187+
# escapes the run generator: the caller sees a traceback pointing at
3188+
# the SDK rather than at the argument they passed, and only once a
3189+
# request arrives. This is a static misconfiguration, knowable the
3190+
# moment the wrapper is built, so it is answered there.
3191+
# Not raised for an orchestrator, which never builds a per-thread agent
3192+
# and so ignores plugins on every release. Refusing only the old ones
3193+
# there would report a version problem for something the new ones do
3194+
# not do either.
3195+
if (
3196+
self._plugins
3197+
and self._orchestrator is None
3198+
and not _STRANDS_ACCEPTS_PLUGINS
3199+
):
3200+
raise TypeError(
3201+
"plugins= was supplied, but the installed strands-agents "
3202+
f"({distribution_version('strands-agents')}) has no `plugins` "
3203+
"parameter on Agent, so they cannot be forwarded to per-thread "
3204+
"agents. Upgrade strands-agents to a release that supports "
3205+
"plugins, or drop the argument."
3206+
)
3207+
30993208
self.name = name
31003209
self.description = description
31013210
self.config = config or StrandsAgentConfig()
@@ -3580,25 +3689,57 @@ def _report_uncarried_params(self, core_kwargs: dict) -> None:
35803689
Said once per param, and only about params this thread's kwargs did not
35813690
supply, so acting on it makes it stop without the first thread becoming
35823691
the policy for every later one.
3692+
3693+
The two kinds get their own message. An unreadable param is a gap in
3694+
this adapter, and a caller reading that can reasonably wait for a later
3695+
release to close it. A param the SDK wired to the agent that received
3696+
it is a structural limit rather than a gap: no adapter release will
3697+
carry it, so the per-thread route is the whole answer rather than a
3698+
stopgap. One sentence for both would send half the readers after a fix
3699+
that is not coming.
35833700
"""
3584-
still_missing = sorted(
3585-
name
3586-
for name in self._unreadable_params
3587-
if name not in core_kwargs and name not in self._reported_uncarried
3588-
)
3589-
if not still_missing:
3590-
return
3591-
self._reported_uncarried.update(still_missing)
3592-
# Phrased as a capability, not an accusation: an unreadable param is
3593-
# unreadable whether or not the caller set one, so this cannot say that
3594-
# anything was actually lost.
3595-
logger.warning(
3596-
"this Strands release stores these Agent constructor params where the "
3597-
"adapter cannot read them back, so a value set on the template through "
3598-
"them will not reach per-thread agents: %s. Supply them per thread "
3599-
"with StrandsAgentConfig.thread_agent_kwargs.",
3600-
", ".join(still_missing),
3601-
)
3701+
3702+
def _unreported(names: List[str]) -> List[str]:
3703+
return sorted(
3704+
name
3705+
for name in names
3706+
if name not in core_kwargs and name not in self._reported_uncarried
3707+
)
3708+
3709+
unreadable = _unreported(self._unreadable_params)
3710+
template_owned = _unreported(self._template_owned_params)
3711+
self._reported_uncarried.update(unreadable)
3712+
self._reported_uncarried.update(template_owned)
3713+
3714+
if unreadable:
3715+
# Phrased as a capability, not an accusation: an unreadable param
3716+
# is unreadable whether or not the caller set one, so this cannot
3717+
# say that anything was actually lost.
3718+
logger.warning(
3719+
"this Strands release stores these Agent constructor params where the "
3720+
"adapter cannot read them back, so a value set on the template through "
3721+
"them will not reach per-thread agents: %s. Supply them per thread "
3722+
"with StrandsAgentConfig.thread_agent_kwargs.",
3723+
", ".join(unreadable),
3724+
)
3725+
if template_owned:
3726+
# ``plugins`` is the one of these with a dedicated kwarg, so point
3727+
# at it rather than making every caller write a hook for the case
3728+
# the adapter already has an answer to.
3729+
route = (
3730+
"Pass them to StrandsAgent(plugins=[...])"
3731+
if template_owned == ["plugins"]
3732+
else "Supply them per thread with "
3733+
"StrandsAgentConfig.thread_agent_kwargs"
3734+
)
3735+
logger.warning(
3736+
"these Agent constructor params are consumed by the Strands Agent "
3737+
"that received them and cannot be handed to another agent, so a "
3738+
"value set on the template will not reach per-thread agents: %s. "
3739+
"%s.",
3740+
", ".join(template_owned),
3741+
route,
3742+
)
36023743

36033744
async def run(
36043745
self,
@@ -3825,6 +3966,12 @@ async def _run_raw(
38253966
core_kwargs = dict(self._agent_kwargs)
38263967
if self._hooks:
38273968
core_kwargs["hooks"] = list(self._hooks)
3969+
# Same falsy-omission rule as hooks, for the same reason:
3970+
# ``plugins=[]`` is a value a future StrandsAgentCore could
3971+
# read as "disable the defaults", which is not what an
3972+
# absent setting means.
3973+
if self._plugins:
3974+
core_kwargs["plugins"] = list(self._plugins)
38283975
# The caller's per-thread kwargs go on last, so they can
38293976
# supply what the template cannot carry and override what
38303977
# it can. See StrandsAgentConfig.thread_agent_kwargs.
@@ -3854,8 +4001,6 @@ async def _run_raw(
38544001
return
38554002
core_kwargs.update(dict(extra or {}))
38564003
self._report_uncarried_params(core_kwargs)
3857-
if self.config.thread_agent_kwargs is None:
3858-
self._report_uncarried_params(core_kwargs)
38594004
# Re-asserted after the caller: these keep threads apart
38604005
# and a run coherent, so they stay the adapter's to set.
38614006
for owned in ("model", "system_prompt", "tools", "session_manager"):

0 commit comments

Comments
 (0)