Skip to content

Commit 635edbc

Browse files
authored
feat(context): surface context window size from LLM response metadata
Add latest_context_size to EventLoopMetrics and context_size to AgentResult, exposing the inputTokens count from the most recent LLM call as a measure of current context window usage.
1 parent cda2a55 commit 635edbc

File tree

4 files changed

+83
-0
lines changed

4 files changed

+83
-0
lines changed

src/strands/agent/agent_result.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ class AgentResult:
3535
interrupts: Sequence[Interrupt] | None = None
3636
structured_output: BaseModel | None = None
3737

38+
@property
39+
def context_size(self) -> int | None:
40+
"""Most recent context size in tokens from the last LLM call.
41+
42+
Returns:
43+
The input token count from the most recent cycle, or None if no data is available.
44+
"""
45+
return self.metrics.latest_context_size
46+
3847
def __str__(self) -> str:
3948
"""Return a string representation of the agent result.
4049

src/strands/telemetry/metrics.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,19 @@ class EventLoopMetrics:
202202
accumulated_usage: Usage = field(default_factory=lambda: Usage(inputTokens=0, outputTokens=0, totalTokens=0))
203203
accumulated_metrics: Metrics = field(default_factory=lambda: Metrics(latencyMs=0))
204204

205+
@property
206+
def latest_context_size(self) -> int | None:
207+
"""Most recent context size from the last LLM call.
208+
209+
This represents the current context size as reported by the model.
210+
211+
Returns:
212+
The input token count from the most recent cycle, or None if no data is available.
213+
"""
214+
if self.agent_invocations and self.agent_invocations[-1].cycles:
215+
return self.agent_invocations[-1].cycles[-1].usage.get("inputTokens")
216+
return None
217+
205218
@property
206219
def _metrics_client(self) -> "MetricsClient":
207220
"""Get the singleton MetricsClient instance."""

tests/strands/agent/test_agent_result.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,17 @@ def test__str__empty_interrupts_returns_agent_message(mock_metrics, simple_messa
370370

371371
# Empty list is falsy, should fall through to text content
372372
assert message_string == "Hello world!\n"
373+
374+
375+
def test_context_size_delegates_to_metrics(mock_metrics, simple_message: Message):
376+
"""Test that context_size delegates to metrics.latest_context_size."""
377+
mock_metrics.latest_context_size = 12345
378+
result = AgentResult(stop_reason="end_turn", message=simple_message, metrics=mock_metrics, state={})
379+
assert result.context_size == 12345
380+
381+
382+
def test_context_size_none_when_no_data(mock_metrics, simple_message: Message):
383+
"""Test that context_size returns None when metrics has no data."""
384+
mock_metrics.latest_context_size = None
385+
result = AgentResult(stop_reason="end_turn", message=simple_message, metrics=mock_metrics, state={})
386+
assert result.context_size is None

tests/strands/telemetry/test_metrics.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,3 +566,50 @@ def test_reset_usage_metrics(usage, event_loop_metrics, mock_get_meter_provider)
566566

567567
# Verify accumulated_usage is NOT cleared
568568
assert event_loop_metrics.accumulated_usage["inputTokens"] == 11
569+
570+
571+
def test_latest_context_size_no_invocations(event_loop_metrics):
572+
assert event_loop_metrics.latest_context_size is None
573+
574+
575+
def test_latest_context_size_invocation_with_no_cycles(event_loop_metrics):
576+
event_loop_metrics.reset_usage_metrics()
577+
assert event_loop_metrics.latest_context_size is None
578+
579+
580+
def test_latest_context_size_returns_last_cycle(event_loop_metrics, mock_get_meter_provider):
581+
event_loop_metrics.reset_usage_metrics()
582+
event_loop_metrics.start_cycle(attributes={"event_loop_cycle_id": "c1"})
583+
event_loop_metrics.update_usage(Usage(inputTokens=100, outputTokens=50, totalTokens=150))
584+
585+
event_loop_metrics.start_cycle(attributes={"event_loop_cycle_id": "c2"})
586+
event_loop_metrics.update_usage(Usage(inputTokens=250, outputTokens=80, totalTokens=330))
587+
588+
assert event_loop_metrics.latest_context_size == 250
589+
590+
591+
def test_latest_context_size_returns_from_latest_invocation(event_loop_metrics, mock_get_meter_provider):
592+
# First invocation
593+
event_loop_metrics.reset_usage_metrics()
594+
event_loop_metrics.start_cycle(attributes={"event_loop_cycle_id": "c1"})
595+
event_loop_metrics.update_usage(Usage(inputTokens=100, outputTokens=50, totalTokens=150))
596+
597+
# Second invocation
598+
event_loop_metrics.reset_usage_metrics()
599+
event_loop_metrics.start_cycle(attributes={"event_loop_cycle_id": "c2"})
600+
event_loop_metrics.update_usage(Usage(inputTokens=500, outputTokens=80, totalTokens=580))
601+
602+
assert event_loop_metrics.latest_context_size == 500
603+
604+
605+
def test_latest_context_size_missing_input_tokens_key(event_loop_metrics):
606+
"""Returns None when usage dict is missing inputTokens (e.g. provider bug)."""
607+
event_loop_metrics.reset_usage_metrics()
608+
invocation = event_loop_metrics.agent_invocations[-1]
609+
invocation.cycles.append(
610+
strands.telemetry.metrics.EventLoopCycleMetric(
611+
event_loop_cycle_id="c1",
612+
usage={"outputTokens": 50, "totalTokens": 50},
613+
)
614+
)
615+
assert event_loop_metrics.latest_context_size is None

0 commit comments

Comments
 (0)