|
44 | 44 | # "session_manager" is excluded: it is supplied per-thread via |
45 | 45 | # StrandsAgentConfig.session_manager_provider (see run()). Forwarding a |
46 | 46 | # 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. |
47 | 51 | _AGUI_EXPLICIT_PARAMS = { |
48 | 52 | "self", |
49 | 53 | "model", |
|
52 | 56 | "messages", |
53 | 57 | "hooks", |
54 | 58 | "session_manager", |
| 59 | + "plugins", |
55 | 60 | } |
56 | 61 |
|
57 | 62 |
|
@@ -171,6 +176,63 @@ def _read(prop: property) -> Any: |
171 | 176 | return _MISSING |
172 | 177 |
|
173 | 178 |
|
| 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 | + |
174 | 236 | def _element_type(annotation: Any) -> Any: |
175 | 237 | """The element type of a ``list[X]``-shaped annotation, or ``None``. |
176 | 238 |
|
@@ -2997,6 +3059,7 @@ def __init__( |
2997 | 3059 | description: str = "", |
2998 | 3060 | config: "StrandsAgentConfig | None" = None, |
2999 | 3061 | hooks: "list | None" = None, |
| 3062 | + plugins: "list | None" = None, |
3000 | 3063 | agents_by_thread: "Dict[str, Any] | None" = None, |
3001 | 3064 | ): |
3002 | 3065 | # Detect a multi-agent orchestrator structurally. A Graph or Swarm has |
@@ -3065,9 +3128,18 @@ def __init__( |
3065 | 3128 | self._unreadable_params = [] |
3066 | 3129 | self._template_owned_params = [] |
3067 | 3130 |
|
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. |
3071 | 3143 | self._unforwardable_params = [ |
3072 | 3144 | *self._unreadable_params, |
3073 | 3145 | *self._template_owned_params, |
@@ -3096,6 +3168,43 @@ def __init__( |
3096 | 3168 | # observability / loop-cap / policy-enforcement hook actually fires. |
3097 | 3169 | self._hooks = list(hooks) if hooks else [] |
3098 | 3170 |
|
| 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 | + |
3099 | 3208 | self.name = name |
3100 | 3209 | self.description = description |
3101 | 3210 | self.config = config or StrandsAgentConfig() |
@@ -3580,25 +3689,57 @@ def _report_uncarried_params(self, core_kwargs: dict) -> None: |
3580 | 3689 | Said once per param, and only about params this thread's kwargs did not |
3581 | 3690 | supply, so acting on it makes it stop without the first thread becoming |
3582 | 3691 | 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. |
3583 | 3700 | """ |
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 | + ) |
3602 | 3743 |
|
3603 | 3744 | async def run( |
3604 | 3745 | self, |
@@ -3825,6 +3966,12 @@ async def _run_raw( |
3825 | 3966 | core_kwargs = dict(self._agent_kwargs) |
3826 | 3967 | if self._hooks: |
3827 | 3968 | 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) |
3828 | 3975 | # The caller's per-thread kwargs go on last, so they can |
3829 | 3976 | # supply what the template cannot carry and override what |
3830 | 3977 | # it can. See StrandsAgentConfig.thread_agent_kwargs. |
@@ -3854,8 +4001,6 @@ async def _run_raw( |
3854 | 4001 | return |
3855 | 4002 | core_kwargs.update(dict(extra or {})) |
3856 | 4003 | self._report_uncarried_params(core_kwargs) |
3857 | | - if self.config.thread_agent_kwargs is None: |
3858 | | - self._report_uncarried_params(core_kwargs) |
3859 | 4004 | # Re-asserted after the caller: these keep threads apart |
3860 | 4005 | # and a run coherent, so they stay the adapter's to set. |
3861 | 4006 | for owned in ("model", "system_prompt", "tools", "session_manager"): |
|
0 commit comments