Python: Aggregate token usage across tool-call loop iterations in invoke_agent span (#4739)

* Fix invoke_agent span to aggregate token usage across LLM calls (#4062)

The FunctionInvocationLayer._get_response() loop was overwriting the
response on each iteration, so usage_details only reflected the last
chat completion call. Now tracks aggregated_usage across all iterations
using add_usage_details() and sets it on the returned response.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Apply pre-commit auto-fixes

* Remove reproduction report artifact

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Apply pre-commit auto-fixes

* Apply pre-commit auto-fixes

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-03-19 07:41:33 +01:00
committed by GitHub
Unverified
parent 5374dd47c5
commit bf8d9672e1
2 changed files with 148 additions and 0 deletions
@@ -72,6 +72,7 @@ if TYPE_CHECKING:
Content,
Message,
ResponseStream,
UsageDetails,
)
else:
@@ -2095,6 +2096,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
ChatResponse,
ChatResponseUpdate,
ResponseStream,
add_usage_details,
)
super_get_response = super().get_response # type: ignore[misc]
@@ -2160,6 +2162,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
prepped_messages = list(messages)
fcc_messages: list[Message] = []
response: ChatResponse[Any] | None = None
aggregated_usage: UsageDetails | None = None
loop_enabled = self.function_invocation_configuration.get("enabled", True)
max_iterations = self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS)
@@ -2191,6 +2194,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
client_kwargs=filtered_kwargs,
),
)
aggregated_usage = add_usage_details(aggregated_usage, response.usage_details)
if response.conversation_id is not None:
_update_conversation_id(kwargs, response.conversation_id, mutable_options)
@@ -2207,6 +2211,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
execute_function_calls=execute_function_calls,
)
if result.get("action") == "return":
response.usage_details = aggregated_usage
return response
total_function_calls += result.get("function_call_count", 0)
if result.get("action") == "stop":
@@ -2262,6 +2267,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
client_kwargs=filtered_kwargs,
),
)
aggregated_usage = add_usage_details(aggregated_usage, response.usage_details)
response.usage_details = aggregated_usage
if fcc_messages:
for msg in reversed(fcc_messages):
response.messages.insert(0, msg)
@@ -17,6 +17,7 @@ from agent_framework import (
ChatResponseUpdate,
Content,
Message,
RawAgent,
ResponseStream,
SupportsAgentRun,
UsageDetails,
@@ -3047,3 +3048,143 @@ def test_get_meter_typeerror_fallback():
meter = get_meter(name="test", attributes={"key": "val"})
assert meter is not None
assert call_count == 2
# region Agent token usage aggregation
@tool(name="get_weather", description="Get weather for a city", approval_mode="never_require")
def _get_weather(city: str) -> str:
"""Get weather for a city."""
return "Sunny, 72°F"
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
async def test_agent_invoke_span_aggregates_usage_across_tool_calls(span_exporter: InMemorySpanExporter):
"""The invoke_agent span should sum token usage from all chat completions in the function invocation loop."""
from tests.core.conftest import MockBaseChatClient
class _InstrumentedAgent(AgentTelemetryLayer, RawAgent):
pass
client = MockBaseChatClient()
client.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city": "Seattle"}')
],
),
usage_details=UsageDetails(input_token_count=2239, output_token_count=192),
),
ChatResponse(
messages=Message(role="assistant", text="The weather in Seattle is sunny."),
usage_details=UsageDetails(input_token_count=2569, output_token_count=99),
),
]
agent = _InstrumentedAgent(client=client, name="test_agent", id="test_agent_id")
span_exporter.clear()
await agent.run(
messages="What is the weather in Seattle?",
options={"tools": [_get_weather], "tool_choice": "auto"},
)
spans = span_exporter.get_finished_spans()
invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
assert len(invoke_spans) == 1
agent_span = invoke_spans[0]
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
assert len(chat_spans) == 2
# Individual chat spans retain their own usage
assert chat_spans[0].attributes.get(OtelAttr.INPUT_TOKENS) == 2239
assert chat_spans[0].attributes.get(OtelAttr.OUTPUT_TOKENS) == 192
assert chat_spans[1].attributes.get(OtelAttr.INPUT_TOKENS) == 2569
assert chat_spans[1].attributes.get(OtelAttr.OUTPUT_TOKENS) == 99
# The invoke_agent span must report the aggregate across all LLM round-trips
assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 2239 + 2569
assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 192 + 99
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
async def test_agent_invoke_span_usage_single_call(span_exporter: InMemorySpanExporter):
"""When only one chat completion occurs, the invoke_agent span usage equals that single call."""
from tests.core.conftest import MockBaseChatClient
class _InstrumentedAgent(AgentTelemetryLayer, RawAgent):
pass
client = MockBaseChatClient()
client.run_responses = [
ChatResponse(
messages=Message(role="assistant", text="Hello!"),
usage_details=UsageDetails(input_token_count=100, output_token_count=50),
),
]
agent = _InstrumentedAgent(client=client, name="test_agent", id="test_agent_id")
span_exporter.clear()
await agent.run(messages="Hi")
spans = span_exporter.get_finished_spans()
invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
assert len(invoke_spans) == 1
assert invoke_spans[0].attributes.get(OtelAttr.INPUT_TOKENS) == 100
assert invoke_spans[0].attributes.get(OtelAttr.OUTPUT_TOKENS) == 50
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
async def test_agent_invoke_span_aggregates_usage_on_max_iterations_exhaustion(span_exporter: InMemorySpanExporter):
"""When the function invocation loop exhausts max_iterations, the final response aggregates usage
from all rounds."""
from tests.core.conftest import MockBaseChatClient
class _InstrumentedAgent(AgentTelemetryLayer, RawAgent):
pass
client = MockBaseChatClient(
function_invocation_configuration={"max_iterations": 1},
)
client.run_responses = [
# Iteration 0: model returns a tool call
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city": "Seattle"}')
],
),
usage_details=UsageDetails(input_token_count=500, output_token_count=100),
),
# Exhaustion path: consumed by tool_choice="none" final call (mock ignores usage)
ChatResponse(
messages=Message(role="assistant", text="placeholder"),
usage_details=UsageDetails(input_token_count=300, output_token_count=60),
),
]
agent = _InstrumentedAgent(client=client, name="test_agent", id="test_agent_id")
span_exporter.clear()
await agent.run(
messages="What is the weather in Seattle?",
options={"tools": [_get_weather], "tool_choice": "auto"},
)
spans = span_exporter.get_finished_spans()
invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
assert len(invoke_spans) == 1
agent_span = invoke_spans[0]
# The invoke_agent span must aggregate usage from the in-loop call and the final exhaustion call
assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 500
assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 100