Completion receipts for Google ADK web tools: separating transport success from task success #6637
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.
ADK's own function-tool guidance already nudges you in a useful direction: a tool function should return a dict with a
statuskey ("success", "error", "pending") so the model gets "a clear signal about the operation's state." I have been extending that convention one layer further for tools that call external web services (search, extraction, page retrieval): the dict aFunctionToolreturns should carry a small, structured completion receipt, not just a status string.The problem this solves is narrow but recurring. A
FunctionTool-wrapped function can call an external API, get back a200, and still have nothing usable — an empty result set, a stale cache hit, a schema mismatch. If the wrapped function just returns{"status": "success", "data": {...}}based on the HTTP layer alone, the agent treats "the call didn't error" as "the task was accomplished." Those are different claims.What the receipt records
Every tool call gets a compact verdict attached to its result, independent of whether it ultimately succeeded:
{ "tool_name": "web_search", "outcome": "invalid_result", "transport_success": true, "task_success": false, "reason": "empty_results", "latency_ms": 11.4, "attempts": 1, "fallback_used": false, "fallback_reason": null }transport_successandtask_successare tracked separately on purpose. Atransport_error(connection refused, timeout, non-2xx) is a different failure mode than aninvalid_result(200 OK, but the payload doesn't satisfy the task), and downstream retry logic should treat them differently — a transport error might justify an immediate retry, an invalid result might justify a fallback provider instead.Where this sits in ADK's tool model
The wrapped function itself stays plain:
def web_search_with_receipt(query: str, tool_context: ToolContext) -> dict.FunctionToolinspects the signature, detectstool_contextby its type annotation, and excludes it from the LLM-facingparameters_json_schemaautomatically — the model only ever seesquery. The transport client (real or fake) is bound via closure when the tool is built, not passed as a model-visible argument. Validation runs as ordinary Python aftertransport.fetch()returns, before the receipt-wrapped dict goes back throughrun_async.One detail worth calling out for anyone doing the same thing: error messages from a failed transport call can carry an API key in a query string, or a bearer token in an authorization header. If that string ends up verbatim in the receipt's
reasonfield, it leaks into traces, logs, and anything the model echoes back. Redacting known secret patterns before they enter the receipt is cheap and easy to forget.This is a sample pattern, not a packaged ADK integration — there's no PyPI package, no
pip install. It is a smallFunctionToolplus a validation layer you would adapt to your own tool's schema. I tested it withgoogle-adk2.6.2 against deterministic fixture transports.I wrote up the fixed-task grading and versioning rules I use for the underlying evaluation layer as a public benchmark methodology, for anyone curious about the harder problem of comparing providers behind a tool, not just validating one call.
For other ADK users: where are you attaching this kind of verdict — inside
ToolContext.statefor cross-turn retry decisions, or handled entirely within theis_long_running/FunctionResponseSchedulingpath for slower validations that can't resolve in a single turn?Disclosure: I work with NativePort. This post was drafted with AI assistance and reviewed against the current
google/adk-pythonsource and docs before posting.All reactions