Skip to content

[Feature]: Documentation/ergonomics report. FunctionInvocationContext.result is documented as the function's result but observes as list[Content], and the attribute's read and write contracts differ #7575

Description

Description

Summary

FunctionInvocationContext.result documents itself as "Function execution result. Can be observed after calling call_next() to see the actual execution result or can be set to override the execution result." Read naturally, that says the attribute holds what the tool function returned. After call_next() it actually holds FunctionTool.invoke's parsed output — a list[Content] — even when the wrapped function is annotated -> str and returns a plain string.

The mismatch is silent in both directions that matter. A middleware that gates on isinstance(context.result, str) and returns early never fires, never raises, and never logs. And because the same attribute accepts a raw value on the write side (_tools.py passes it to Content.from_function_result(result=...), which takes anything), the natural test for such a middleware — build a FunctionInvocationContext, assign result = "some tool output", assert the middleware acted — passes. Logic tests and production disagree, and nothing reports the disagreement.

Where the reading comes from

  • FunctionInvocationContext's attribute docs (_middleware.py) describe result once, covering observe and override together, with no mention of the parsed shape. result: Any on __init__ adds no constraint.
  • FunctionMiddleware.process's docstring repeats it: "observe context.result after calling call_next() for actual results."
  • The correct fact is documented, but somewhere a middleware author has no reason to look: in FunctionTool.invoke's return docs ("list[Content] by default. The raw function return value (Any) when skip_parsing=True"). Nothing on the middleware side links the two.

The read and write contracts are not the same type

This is the part that makes the wrong reading stable rather than quickly corrected: setting context.result = "text" works, so a middleware that both reads and writes appears internally consistent while only its write half is behaving as documented. FunctionMiddlewarePipeline.execute assigns context.result from final_handler(context) (i.e. tool.invoke(...), parsed), and _execute_function_call accepts whatever is on the way out. One attribute, two contracts, one docstring line.

The shape is also not stable across tools

result_parser=SKIP_PARSING (or skip_parsing=True) makes invoke return the raw value, so a middleware written against list[Content] is also not universally correct. A generic middleware — one that does not know how the tools it wraps were constructed — cannot write a correct type check from the public documentation at all. It has to handle both and hope.

Upstream code appears to have hit this already: security.py carries a private _ensure_content_list helper whose docstring reads "After call_next(), context.result is typically list[Content] from FunctionTool.invoke(). This helper handles legacy cases where middleware or tests set raw strings, dicts, or single Content items." That is the same normalization every downstream consumer has to rediscover, and the hedge in "typically" is precisely the ambiguity being described here.

What it looked like in the field

An app with several small function middlewares that inspect tool result text — appending a caveat to a task-registry answer that quietly lost its tasks, redacting a failed child agent's raw provider error before it reaches the transcript, recording delegation prompts to an audit log. All three opened with if not isinstance(context.result, str): return. All three had passing unit tests. None had ever run.

The audit one is what exposed it, because its symptom was legible: an empty audit store and no log line from either the success or the failure branch, which narrowed it to an early return before any work. The other two had no such tell — a caveat that is never appended and a redaction that never fires both look exactly like "no matching result yet".

Suggested directions

Ordered by how cheap they are; the first alone would have prevented this.

  1. State the shape where middleware authors read. In FunctionInvocationContext's attribute docs and FunctionMiddleware.process, say that after call_next() the value is FunctionTool.invoke's output — list[Content] for a normally constructed tool, the raw return under SKIP_PARSING — and that the write side accepts a raw value. Documenting the asymmetry explicitly is most of the fix, because it is the asymmetry that makes the wrong belief self-consistent.
  2. Offer a public accessor for the common intent. The overwhelmingly common thing a function middleware wants is "the text this call produced" and "replace that text, keep the shape". A supported context.result_text (or a public equivalent of _ensure_content_list plus a text join) would remove the shape question from every consumer, and would let security.py drop its private copy.
  3. Consider making the type honest at the seam. If result is always list[Content] on the read side by construction, typing and normalizing it as such — including for values middleware writes — would close the gap rather than document it. That is a behavior change with compatibility cost, so it is listed last; the ambiguity is worth removing, but not at the price of breaking middleware that currently writes strings.

Testing note, offered because it generalizes

A middleware test that builds a context and assigns result cannot detect this class of defect — it asserts the middleware's logic against a shape production never produces. What catches it is a test that drives the middleware through a real run: a scripted chat client, a real @tool, and the actual function-invocation layer in between. If the repository's middleware sample or testing guidance showed that shape, third-party middleware would inherit the guard by imitation.

Code Sample

Language/SDK

Both

Metadata

Metadata

Assignees

No one assigned

    Labels

    triageUsage: [Issues], Target: All issues that still need to be triaged

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions