@tool stamps a plain return value as status=success: returning a ToolResult that reports the real outcome #3752
auxiliar-ag
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
I spent a while this week reading
strands/tools/decorator.pyin the Python SDK, and one detail changed how I write tools that call the outside world.DecoratedFunctionTool._wrap_tool_resulthas two branches. If the returned object is adictcarrying bothstatusandcontent, it is treated as a ToolResult and passed through. Everything else — a string, a number, a Pydantic model, and also a dict like{"ok": False, "error": "blocked"}that has neither key — is serialised and stamped with"status": "success". There is no inspection of what came back. A scraper that returns a cookie wall, a search wrapper that returns zero hits, a fetch that returns an empty string: all of them reach the model as a successful tool call. I pinned that against the installed package rather than trusting my reading of it, intests/test_framework_contract.py:COOKIE_WALLis the string"Just a moment... Enable JavaScript and cookies to continue.". The tool ran, so the framework says success. That is the correct framework behaviour, because@toolcannot know what your task needed. It does mean the judgement has to live in the tool.Everything below is against
strands-agents==1.51.0(published 2026-08-07, tagpython/v1.51.0), on Python 3.11.The branch that lets a tool speak for itself
The passthrough branch is the way out. A
dictcarrying bothstatusandcontentis handed back as theToolResultessentially unchanged: the framework overwritestoolUseIdwith the real one and leaves the rest alone. So a tool that wants to report a failed task returns the mapping itself:ToolResultContentinstrands/types/tools.pyallowsjson,text,imageanddocumentblocks, so the first block can be structured data rather than prose. That is the whole idea here: block zero is always a machine-readable receipt describing what the tool actually achieved, and block one is the payload or a short failure line.Keeping the transport out of the model's reach
The tool takes a URL and pass criteria. It does not take providers, clients, keys or timeouts, because anything in the signature becomes part of the JSON schema the model gets to fill in. Providers arrive instead through the agent's invocation state, which
@tool(context=True)exposes:Three things about
ToolContextthat cost me time:@dataclass, so it istool_context.tool_use["toolUseId"], nottool_context["tool_use"]["toolUseId"]. The example in the@tooldocstring in 1.51.0 still shows the subscript form, which raisesTypeError: 'ToolContext' object is not subscriptable.inject_special_parametersreadsinvocation_state["agent"]with an unguarded subscript. A realAgentsets that key itself, but if you drive acontext=Truetool directly in a test you have to supply it or you get a confusingKeyError: 'agent'reported as a tool error.inputSchema, which is what makes this a safe capability channel rather than just another argument.On the caller side, pass capabilities as
invocation_state=, not as loose keyword arguments. Bare**kwargsonAgent.invoke_asyncstill work in 1.51.0, but they emit a warning that the parameter is deprecating. It is raised with no category, so it arrives as aUserWarningrather than aDeprecationWarning, which matters if you filter warnings by category in CI:The receipt
The receipt is a
TypedDictwith a version marker, so a consumer can tell when the shape changes:Each entry in
attempts_logrecordsprovider,outcome,http_statusand a shortdetail.outcomeis aLiteralofok,transport_error,http_error,unusable_contentandmalformed_response, which keeps the two failure families distinct: the request never landed, versus the request landed and the body was useless.One naming caveat, since a receipt is meant to be read by something other than its author:
fallback_usedmeans "more than one provider was attempted", not "a fallback rescued the call". After two failed attempts it istruewhileprovider_usedisnull.provider_usedis the field that answers who actually produced the content.A run where the primary returns HTTP 200 with an interstitial and the fallback returns the real page produces this:
{ "schema": "verified_web_lookup.receipt/v1", "ok": true, "url": "https://example.test/strands/tools", "attempts": 2, "max_attempts": 2, "providers_available": 2, "provider_used": "backup", "fallback_used": true, "failure_reason": null, "content_chars": 169, "attempts_log": [ { "provider": "primary", "outcome": "unusable_content", "http_status": 200, "detail": "interstitial page detected ('just a moment')" }, { "provider": "backup", "outcome": "ok", "http_status": 200, "detail": "usable content" } ] }The
ToolResult.statusfor that run issuccess, and the model can still see that the primary failed and why. When every attempt fails,statusiserror,okisfalse,failure_reasonis one ofno_providers_configured,all_providers_failedorattempt_budget_exhausted, and no page text is returned at all.Two details worth stealing regardless of the rest.
max_attemptsis a model-facing parameter, so it is clamped into a fixed range before use; a model asking for 99 attempts gets the library ceiling. And a provider that returns something other than the declared response type is classified asmalformed_responserather than raising, because anAttributeErrorescaping the tool tells the model nothing useful.Redaction, because the error path is model-visible
DecoratedFunctionTool.streamcatches exceptions and formats them asf"Error: {error_type} - {error_msg}"in the result content. That string goes into the conversation. Provider errors habitually quote the full request URL, and request URLs habitually carry keys, so an uncaught exception is a credential disclosure into model context and then into whatever stores that transcript.So the tool catches provider exceptions itself and scrubs before recording:
redact_textandredact_urlblank sensitive query-string values and a handful of well-known token shapes, and the same scrubbing runs over the URL echoed in the receipt and over the returned page text. It is a coarse net in both directions. It misses opaque session identifiers, since names such assession,sid,cookieandstateare not on the sensitive-key list, and it misses a short all-alphabetic secret, since the value heuristic wants a digit, a separator or real length. It also over-matches, which is why I keep a test asserting that ordinary prose such asAuthentication: required.survives untouched: an over-eager redactor quietly corrupts the content you fetched.Testing it with no model and no network
AgentTool.streamis the interface the event loop uses, and its last event is theToolResultEvent. Driving a tool through it exercises input validation, context injection and result wrapping without a model:For the full loop I use a small
Modelsubclass that replays canned assistant turns as stream events, in the same spirit as the SDK's ownstrands-py/tests/fixtures/mocked_model_provider.py. That covers tool-spec advertisement, the tool use, and theToolResultgoing back into the conversation, still with no credentials and no sockets. The providers are plain objects with afetchmethod that return a scripted response, raise a scripted exception, or deliberately return the wrong type.What this is and is not
This is a pattern, not a package. There is no NativePort integration for Strands and nothing here is an official Strands component; it is ordinary application code you would paste into your own repository and adapt. The code blocks above are excerpts from the modules I run locally, quoted inline in this post; they are not attached to it, and a fence on its own is not a runnable file.
verified_web_lookupalone needs four neighbours to run —providers,receipt,redactionandvalidation— and the test excerpts need the harness and fakes as well.Known limits: the usability check is deliberately crude (length, interstitial markers, one required substring) and belongs to the calling application, not the framework; redaction is pattern-based, with the specific gaps above; and the fallback ordering is static rather than health-aware.
Disclosures
I work on NativePort, which routes agents to 32 provider-native APIs behind one key and one credit balance and passes per-call provider prices through without a per-request markup. That is the only reason multi-provider fallback is on my mind at all, and it is why the receipt tracks which provider answered. How we measure providers is written up at our measurement methodology. Nothing in this post requires NativePort, and the fakes in the tests are the only providers involved.
I used an AI assistant while reading the SDK source and drafting both the code and this write-up. Every claim in this post about how Strands behaves is asserted in a test against
strands-agents==1.51.0, because that is the only way I would trust either of us on it. That includes the small ones it would be easy to assert from memory: the docstring text, theToolResultContentkey set, the Bedrock request formatter and the tool-call span all have their own assertions. Exactly one claim is source-reading rather than execution: the Anthropic model'sis_errormapping, because theanthropicextra is not installed in my environment. It is marked inline where it appears.Getting that sentence right took three attempts, so here is how it is now checked rather than asserted. A test holds a table mapping every SDK claim in this post to the test functions that execute it, and a second table holds the read-only ones; the suite fails if a named test does not exist, or if the number of read-only claims stops matching the number of inline markers in this text. The suite is also hermetic by construction: a fail-closed guard raises on any outbound socket, and a second guard raises if anything asks botocore to resolve credentials, so the Bedrock tests cannot quietly reach the EC2 metadata service the way an earlier draft of them did.
One question
For the failure case I return
status="error"with the receipt in ajsonblock and no page text, on the theory that a model reasons better from an explicit failure than from a plausible-looking empty payload.Working out what that choice actually costs took longer than writing the tool. As far as I can tell in 1.51.0,
ToolResult.statusis not a control-flow signal: after tool execution the loop callsrecurse_event_loopunconditionally, andModelRetryStrategy.is_retryablematchesModelThrottledExceptiononly, which concerns model calls rather than tool results.Where it does land is three places, and the first surprised me. Reaching the model is conditional on Bedrock:
_format_request_message_contentforwardsstatusonly when_should_include_tool_result_status()is true, and the default"auto"setting resolves against_MODELS_INCLUDE_STATUS, which contains the single prefixanthropic.claude. So on Bedrock the status survives to Claude and is dropped for Nova, Llama, Mistral and DeepSeek unless you setinclude_tool_result_status=True. I ran the formatter across those model ids to check, with a boto session holding fake static credentials so nothing is resolved and no socket is opened — worth spelling out, because constructingBedrockModelwithout explicit credentials makes botocore walk the AWS credential chain and call the EC2 metadata service, which is how my first version of that test accidentally started talking to169.254.169.254. The Anthropic model, by contrast, mapsstatusto a tool-resultis_errorflag unconditionally — that line is the one claim in this post I read rather than ran. Second,statusincrementsToolMetrics.success_countorerror_count. Third, it becomesgen_ai.tool.statuson the tool-call span, and anerrorstatus ends that span with the first text block as its error message.So the choice looks like a reporting decision rather than a loop-behaviour one, and a partly provider-dependent reporting decision at that. For a partially useful outcome — a page that came back genuinely thin rather than blocked — is the intended idiom in Strands to return
status="success"and let the receipt carry the nuance, keeping the error counters for real failures? Or to returnstatus="error", accept that it marks the result as an error in your metrics and traces and, on the models that forward it, to the model as well, and treat "thin" as a failure? I could not find guidance on where that line is meant to sit, and it changes both what a dashboard of tool error rates means and what some models are told.All reactions