mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
5374dd47c5
commit
bf8d9672e1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user