Skip to content

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 invoke receives tool_call_id separately (L2296):
    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":
    # Pass both provider correlation and framework occurrence identity to middleware.
    middleware_context.metadata["call_id"] = call_id
  • invoke hands the context, not the tool_call_id, to the function's context parameter:
    call_kwargs[self._context_parameter_name] = effective_context
  • FunctionInvocationContext has no attribute for the call (function, arguments, session, metadata, result, kwargs, tools, parent_service_session_state_keys):
    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 FunctionMiddleware so 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_call contents 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_id argument of FunctionTool.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.

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 16, 2026
  2. added
    agentsUsage: [Issues, PRs], Target: Single agent
    middlewareUsage: [Issues, PRs], Target: middleware related features
    on Oct 16, 2026
  3. removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

agentsUsage: [Issues, PRs], Target: Single agentmiddlewareUsage: [Issues, PRs], Target: middleware related featurespythonUsage: [Issues, PRs], Target: Python

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions