Repository navigation
Description
Summary
A @tool parameter named agent is silently excluded from the generated inputSchema and never populated at call time. No error, no warning — the parameter just doesn't exist as far as the model is concerned, and the function receives its default.
FunctionToolMetadata._is_special_parameter reserves {"self", "cls", "agent"} (plus the configured context param). self/cls are unambiguous and the context param is opt-in via @tool(context=...), but agent is a perfectly ordinary word for a tool parameter — agent, as in "which agent", "agent id", "agent name" — and reserving it invisibly is a footgun.
Reproduction
import asyncio, inspect
from typing import Literal
from strands.tools.decorator import tool
@tool(name="delegate")
async def delegate(task: str, agent: Literal["reviewer", "explorer"] = "reviewer") -> dict:
"""Delegate a task.
Args:
task: The task.
agent: Which agent to use.
"""
return {"task": task, "agent": agent}
print(delegate.tool_spec["inputSchema"]["json"]["properties"].keys())
# -> dict_keys(['task']) # `agent` is gone, silently
async def call(inp):
out = None
async for ev in delegate.stream({"toolUseId": "1", "name": "delegate", "input": inp}, {}):
out = ev
return out.tool_result
print(asyncio.run(call({"task": "x", "agent": "explorer"})))
# -> content: {"task": "x", "agent": "reviewer"} # model's value discardedRenaming the parameter to role makes both the schema entry and the pydantic Literal enforcement appear as expected, which is how I eventually found it.
Impact
The failure is invisible in both directions: the tool author sees a working tool with a missing parameter, and the model sees a schema that doesn't mention the option, so it can never pass it. It surfaced while prototyping a config-derived schema for a delegation tool (harness-sdk#3392 design pass) where agent was the natural name for the "which child identity" selector — the parameter simply evaporated and the tool appeared to ignore the model.
Suggested fix
One of, in preference order:
- Warn loudly. In
FunctionToolMetadata.__init__, if a non-injected parameter collides with a reserved name, emit alogger.warning(or raise, since it's a construction-time programming error) naming the parameter and the reason. Same treatmentToolContextalready gets in_validate_signature, which does raise on misuse. - Make
agentinjection opt-in the way the context param is (@tool(context=...)), so the reservation only applies when the author asks for it. This is the real fix;self/clscan stay unconditional. - At minimum, document the reserved names in the
@tooldocstring — it currently documents thecontextparameter but never mentions thatagentis taken.
Happy to send a PR for (1) + (3), and for (2) if you agree it's the right direction.
Metadata
Metadata
Assignees
Labels
Type
Fields
Language
Activity
ebarkhordar commentedon Jul 27, 2026
The schema omission reproduces at HEAD
58a501c7, but the call-time half reads differently under a realAgent, and the difference changes which fix is right.agentis not left at its default._execute_event_loop_cyclesetsinvocation_state["agent"] = selfunconditionally (strands-py/src/strands/agent/agent.py:1352, commented "AddAgentto invocation_state to keep backwards-compatibility"), andinject_special_parameterscopies that into the validated input (decorator.py:416-417). The tool body receives theAgentobject. Your repro drivesdelegate.stream(tool_use, {})with an emptyinvocation_state, which is the one context where the declared default survives.Injection also lands after validation.
validate_input(decorator.py:383) builds frominput_model, which_create_input_modelassembled without theagentfield (decorator.py:205), so theLiteral["reviewer", "explorer"]annotation is never applied to the injected value.Verified at HEAD
58a501c7, cleanpython:3.12-slimcontainer,pip install .from the repo copy (installeddecorator.pysha256e9506354...66b122, byte-identical to HEAD;pip show strands-agentsreports the pretend version0.0.0, so it is the local build and not PyPI). Your exact tool, invoked once through your harness and once throughAgentwith the repo's owntests/fixtures/mocked_model_provider.pyemitting thetoolUse:So a tool that branches on
agent == "explorer"silently takes the other branch, and one that calls a string method on it raisesAttributeErrorfrom inside the tool body rather than at construction. That is a worse failure than falling back to the default, and it argues for your option 1 raising rather than warning: once a parameter namedagentcarries a non-Agentannotation, there is no execution in which both the annotation and the injection can be honoured.On your option 3, the reserved set is exactly
{"self", "cls", "agent"}plus the opt-in context param (decorator.py:433-437), and the publictool()docstring documentscontextbut never mentionsagent.Solaris-star commentedon Aug 2, 2026
This is a real footgun —
agentis a perfectly normal business parameter name.Looking at
FunctionToolMetadata._is_special_parameter, the fix options I see:Remove
agentfrom the hardcoded reserved set. The agent injection is already opt-in via@tool(context=...)— if the user explicitly names their context paramagent, that's their choice.self/clsare the only truly unambiguous reserved names.Warn instead of silently dropping. If a parameter name collides with the reserved set, emit a warning at tool registration time so the author knows their parameter is being excluded.
I'd lean toward (1) + (2): remove
agentfrom the hardcoded set, and add a warning for any future collision with the configured context param name. That wayself/clsstay reserved (nobody names a tool paramself), the context param collision is still caught, butagentas a normal param just works.Happy to implement once there's agreement on direction.
Solaris-star commentedon Aug 2, 2026
Thanks @ebarkhordar for the deeper analysis — the runtime injection is worse than I initially understood. The tool receiving an
Agentobject where it expects aLiteral["reviewer", "explorer"]is a silent type corruption, not just a missing parameter.Revised position: option 1 should raise at registration time when a parameter named
agentcarries a non-Agentannotation, rather than silently reserving or warning. The collision is irreconcilable — there's no execution where both the annotation and the injection can be honoured, as you pointed out.Concretely:
self/cls: stay silently reserved (nobody names a tool paramself)agent: if the annotation isAgentor untyped, allow the injection as-is. If the annotation is anything else (str, Literal, int, etc.), raiseValueErrorat@toolregistration with a clear message: "parameter 'agent' is reserved for Agent injection; rename your parameter or annotate it as Agent"contextparam: keep current behavior (opt-in, documented)This turns a silent runtime corruption into a loud build-time error. Happy to implement if maintainers agree.
surlymochan commentedon Oct 28, 2026
Opened #5622 with a fix along the lines discussed here: when a tool is defined, a parameter named
agentwhose annotation cannot hold anAgent(e.g.Literal[...],str) now emits aUserWarningat definition time, and the@tooldocstring documents the reserved names (self,cls,agent).One deliberate deviation from the raise-at-registration proposal:
test_stream_with_agentcodifies injection into anagent: dictparameter as supported behavior, so a hardValueErrorwould break existing tools that rely on it. The check lives inFunctionToolMetadata._validate_agent_parameterif maintainers prefer to make it stricter later. Unannotated/Anyparameters,Agent/Agent | Noneannotations, and a context parameter namedagentare all unaffected.