Repository navigation
Python: [Feature]: Expose the tool call's id on FunctionInvocationContext on every execution path #9454
Description
Problem
A tool that takes a FunctionInvocationContext parameter cannot learn which provider call it is
serving. The automatic function-calling loop knows the id: it passes tool_call_id to
FunctionTool.invoke on both execution paths, but invoke uses it for the tracing span only, and
the context object handed to the function has no field for it. On the path taken when no function
middleware is registered, the context carries no call id at all. On the middleware path, the loop
writes it as metadata["call_id"], a string key the public API does not name, "to middleware".
So today the only way for a tool to read its own call id is to register a function middleware that
does nothing, in order to switch the loop onto the path that records the key, and then read an
undocumented metadata entry.
Who it affects: anyone whose tool execution is correlated by the provider's call id outside the
framework. In our case the tool call is proxied to a server that records it under that id, so the
stored result pairs with the call on later turns.
Use Case and Evidence
Our tools are proxied through a service that records each call by the provider's call_id; the
recorded conversation is replayed to the model on later turns, and the tool result has to pair
with the tool_calls entry the model produced. The function needs the id at dispatch time.
Where the loop handles the id (python/packages/core/agent_framework/_tools.py on main):
- The branch between the two execution paths:
if middleware_pipeline is None or not middleware_pipeline.has_middlewares: - Direct path: the context is built without the id (L2282) and
invokereceivestool_call_idseparately (L2296):agent-framework/python/packages/core/agent_framework/_tools.py
Lines 2282 to 2296 in cae4955
direct_context = FunctionInvocationContext( function=tool, arguments=args, session=invocation_session, kwargs=runtime_kwargs.copy(), parent_service_session_state_keys=parent_service_session_state_keys, tools=live_tools, ) direct_context.metadata[_PARENT_TOOL_APPROVAL_SOURCE_IDS_CONTEXT_KEY] = parent_approval_source_ids if host_payload_budget is not None: direct_context.metadata[_FUNCTION_RESULT_PAYLOAD_BUDGET_CONTEXT_KEY] = host_payload_budget function_result = await tool.invoke( arguments=args, context=direct_context, tool_call_id=function_call_content.call_id, - Middleware path: the id is written to
metadata["call_id"]under "Pass both provider correlation and framework occurrence identity to middleware":agent-framework/python/packages/core/agent_framework/_tools.py
Lines 2345 to 2346 in cae4955
# Pass both provider correlation and framework occurrence identity to middleware. middleware_context.metadata["call_id"] = call_id invokehands the context, not thetool_call_id, to the function's context parameter:call_kwargs[self._context_parameter_name] = effective_context FunctionInvocationContexthas no attribute for the call (function,arguments,session,metadata,result,kwargs,tools,parent_service_session_state_keys):agent-framework/python/packages/core/agent_framework/_middleware.py
Lines 428 to 502 in cae4955
class FunctionInvocationContext: """Context object for function middleware invocations. This context is passed through the function middleware pipeline and contains all information about the function invocation. Attributes: function: The function being invoked. arguments: The function arguments. In the automatic function-calling loop, schema-compatible provider arguments retain the existing normalized mapping contract. If provisional normalization rejects provider arguments, middleware instead receives the raw JSON-parsed mapping and may repair it before calling ``call_next()``. The innermost handler validates changed or previously invalid arguments immediately before execution, then stores the normalized mapping back on this attribute. Middleware that short-circuits without calling ``call_next()`` skips final validation and function execution. session: The agent session for this invocation, if any. metadata: Metadata dictionary for sharing data between function middleware. result: Function execution result. This attribute carries no guaranteed type. The pipeline assigns :meth:`FunctionTool.invoke`'s output — a ``list[Content]``, or the wrapped function's raw return value when the tool is configured with ``result_parser=SKIP_PARSING`` — at the innermost link of the chain, so only the innermost middleware observes it directly. Every middleware above observes whatever the ones below it left behind, and any middleware may overwrite it with a value of any type. On the way out, only ``list[Content]`` and ``str`` survive intact: every other value, a bare ``Content`` included, is stringified into a single text item, so rebuild the list rather than assigning one item back. The exception is a bare ``Content`` of type ``function_approval_request``, which the invocation layer passes through untouched to drive the approval flow. kwargs: Additional runtime keyword arguments forwarded to the function invocation. parent_service_session_state_keys: Provider-owned keys in ``session.state`` declared by the invoking parent agent and its client. The automatic function-calling loop supplies this value. Custom loops that directly invoke an agent tool with session propagation must provide it explicitly, including an empty collection when the parent declares no such keys. tools: The live, mutable list of tools available to the model for the current agent run, or ``None`` when the function is invoked outside of a function-calling loop (for example via ``FunctionTool.invoke`` directly). Tools can add or remove tools during execution using :meth:`add_tools` and :meth:`remove_tools` (progressive tool exposure). Mutations take effect on the **next** model iteration, not the in-flight batch. Examples: .. code-block:: python from agent_framework import FunctionMiddleware, FunctionInvocationContext class ValidationMiddleware(FunctionMiddleware): async def process(self, context: FunctionInvocationContext, call_next): print(f"Function: {context.function.name}") print(f"Arguments: {context.arguments}") # Validate arguments if not self.validate(context.arguments): raise MiddlewareTermination("Validation failed") # Continue execution await call_next() Progressive tool exposure from inside a tool: .. code-block:: python from agent_framework import FunctionInvocationContext, tool @tool(approval_mode="never_require") def load_math_tools(ctx: FunctionInvocationContext) -> str: ctx.add_tools([factorial, fibonacci]) return "Math tools are now available." """ def __init__(
The .NET implementation exposes it: Microsoft.Extensions.AI's FunctionInvocationContext.CallContent is the FunctionCallContent being invoked, with its CallId ("Gets or sets the function call content information associated with this invocation."): https://github.com/dotnet/extensions/blob/d95193abf4059fa83b75d4ebb60d406f5a26fc89/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs
Minimal repro, agent-framework-core==1.22.0, Python 3.12, no network (the chat client is scripted):
import asyncio
from collections.abc import Mapping, Sequence
from typing import Any
from agent_framework import (
Agent,
BaseChatClient,
ChatMiddlewareLayer,
ChatResponse,
Content,
FunctionInvocationContext,
FunctionInvocationLayer,
FunctionMiddleware,
Message,
tool,
)
class ScriptedClient(FunctionInvocationLayer, ChatMiddlewareLayer, BaseChatClient):
"""First call: ask for the tool. Second call: answer."""
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.calls = 0
async def _inner_get_response(
self, *, messages: Sequence[Message], stream: bool, options: Mapping[str, Any], **kwargs: Any
) -> ChatResponse:
self.calls += 1
if self.calls == 1:
call = Content.from_function_call("call-1", "lookup", arguments={"q": "x"})
return ChatResponse(messages=[Message("assistant", [call])], finish_reason="tool_calls")
return ChatResponse(messages=[Message("assistant", [Content.from_text("done")])], finish_reason="stop")
seen: list[dict[str, Any]] = []
@tool(name="lookup", description="look something up")
async def lookup(context: FunctionInvocationContext, q: str) -> str:
seen.append(dict(context.metadata))
return "found"
class PassThrough(FunctionMiddleware):
async def process(self, context: FunctionInvocationContext, call_next) -> None:
await call_next()
async def main() -> None:
for label, middleware in (("no middleware", None), ("pass-through middleware", [PassThrough()])):
seen.clear()
client = ScriptedClient(middleware=middleware) if middleware else ScriptedClient()
agent = Agent(client, "You are a test.", tools=[lookup])
await agent.run("go")
print(f"{label}: metadata keys = {sorted(seen[0])}; call_id = {seen[0].get('call_id')!r}")
asyncio.run(main())Output on 1.22.0:
no middleware: metadata keys = ['_function_result_payload_budget', '_parent_tool_approval_source_ids', '_prepared_function_arguments']; call_id = None
pass-through middleware: metadata keys = ['_auto_prepare_function_arguments', '_function_result_payload_budget', '_parent_tool_approval_source_ids', '_prepared_function_arguments', 'call_id', 'function_call_occurrence_id']; call_id = 'call-1'
Expected: the function can read the id of the call it serves on both paths, through a documented
attribute. Actual: None on the direct path; an undocumented metadata key on the middleware path.
Desired Outcome
A tool function, and a function middleware, can read the provider call id (and ideally the
function_call content it came from) from FunctionInvocationContext through a documented
attribute, whether or not any middleware is registered. Existing code reading
metadata["call_id"] keeps working.
Alternatives Considered
- Registering a pass-through
FunctionMiddlewareso the loop takes the path that writes
metadata["call_id"], then reading that key. This is what we do today. It depends on an
undocumented key and on which internal path the loop takes. - Correlating from the response stream: the
function_callcontents carry the id before the call
executes, so a consumer can match name and arguments to an id. It duplicates the loop's own
argument accumulation and is ambiguous for two identical calls in one round. - Using the
tool_call_idargument ofFunctionTool.invoke: it is not forwarded to the function.
Proposed Direction
Parity with .NET's FunctionInvocationContext.CallContent: the function_call content of the
call being invoked, set on both execution paths. Additive; metadata["call_id"] stays.
Scope and Non-Goals
Python only; the .NET side already has it. No change to how ids are generated or to the
function_call_occurrence_id fallback.
Language
Python
Contribution Intent
I am willing to implement this
Acknowledgements
- I searched existing issues and did not find a duplicate.
- I will wait for explicit maintainer agreement before starting implementation of a non-trivial change.
AI assistance
The text of this issue and the repro script were drafted with an AI assistant. I motivated the
request and validated the repro and the cited lines.
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity