microsoft / microsoft/agent-framework
Python: [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
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 13.6k
- Forks
- 2.3k
- Avg merge
- 2d 45m
- Merged PRs (30d)
- 358
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) describeresultonce, covering observe and override together, with no mention of the parsed shape.result: Anyon__init__adds no constraint.FunctionMiddleware.process's docstring repeats it: "observecontext.resultafter callingcall_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) whenskip_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.
- State the shape where middleware authors read. In
FunctionInvocationContext's attribute docs andFunctionMiddleware.process, say that aftercall_next()the value isFunctionTool.invoke's output —list[Content]for a normally constructed tool, the raw return underSKIP_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. - 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_listplus a text join) would remove the shape question from every consumer, and would letsecurity.pydrop its private copy. - Consider making the type honest at the seam. If
resultis alwayslist[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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.