Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/benchflow/acp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,11 +233,21 @@ def record_agent_timeout(

def record_prompt_usage(self, usage: object | None) -> None:
"""Record cumulative ACP token usage returned by session/prompt."""
if self._record_usage_snapshot(usage):
self._notify_change()

def _record_usage_snapshot(self, usage: object | None) -> bool:
snapshot = normalize_acp_usage(usage)
if snapshot is None:
return
return False
self.usage_snapshots.append(snapshot)
self._notify_change()
return True

def _record_update_usage(self, update: dict) -> bool:
snapshot_source = update.get("usage")
if snapshot_source is not None and self._record_usage_snapshot(snapshot_source):
return True
return self._record_usage_snapshot(update)

def latest_usage_totals(self) -> ACPUsageSnapshot | None:
"""Return the latest cumulative ACP usage snapshot, if any."""
Expand Down Expand Up @@ -274,12 +284,15 @@ def handle_update(self, update: dict) -> None:
"""Process a session/update notification."""
self._events_active = True
update_type = update.get("sessionUpdate")
usage_recorded = self._record_update_usage(update)
# Unknown update types (future ACP versions, agent-specific
# extensions) mutate no state and must not trigger a no-op
# snapshot. Mark events_active so the snapshot path stays on
# the modern branch, but skip _notify_change for unrecognized
# types.
# the modern branch, but only notify for unrecognized types when they
# carried usage telemetry.
if update_type not in self._RECOGNIZED_UPDATE_TYPES:
if usage_recorded:
self._notify_change()
return

if update_type == "tool_call":
Expand Down
1 change: 1 addition & 0 deletions src/benchflow/rollout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,7 @@ async def soft_verify(self) -> tuple[dict | None, str | None, str | None]:
async def cleanup(self) -> None:
"""Close ACP client and stop the environment."""
self._capture_partial_acp_trajectory()
self._collect_native_acp_usage()
await self.disconnect()

if self._env and self._config.export_generated_skills_to:
Expand Down
89 changes: 89 additions & 0 deletions tests/test_native_acp_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,95 @@ def test_rollout_native_acp_usage_uses_cumulative_deltas():
}


def test_acp_session_records_usage_from_session_update():
"""Guards PR #934 / issue #933: timeout accounting uses update usage."""
from benchflow.acp.session import ACPSession

session = ACPSession("session-1")

session.handle_update(
{
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "working"},
"usage": {
"inputTokens": 10,
"outputTokens": 4,
"totalTokens": 16,
"cachedReadTokens": 2,
"cachedWriteTokens": 1,
"thoughtTokens": 1,
},
}
)

assert session.latest_usage_totals() == {
"input_tokens": 10,
"output_tokens": 4,
"total_tokens": 16,
"cached_read_tokens": 2,
"cached_write_tokens": 1,
"thought_tokens": 1,
}


@pytest.mark.asyncio
async def test_cleanup_collects_update_usage_after_timed_out_prompt(tmp_path):
"""Guards PR #934 / issue #933: cleanup preserves streamed usage."""
from benchflow.acp.session import ACPSession
from benchflow.rollout import Rollout, RolloutConfig
from benchflow.rollout._usage import _zero_native_acp_usage_metrics

session = ACPSession("session-1")
session.handle_update(
{
"sessionUpdate": "usage",
"usage": {
"inputTokens": 20,
"outputTokens": 7,
"totalTokens": 30,
"cachedReadTokens": 1,
"cachedWriteTokens": 2,
"thoughtTokens": 3,
},
}
)

rollout = Rollout.__new__(Rollout)
rollout._session = session
rollout._acp_client = SimpleNamespace(session=session)
rollout._trajectory = []
rollout._session_traj_count = 0
rollout._session_tool_count = 0
rollout._terminal_timeout = True
rollout._partial_trajectory = False
rollout._trajectory_source = "none"
rollout._n_tool_calls = 0
rollout._usage_runtime = None
rollout._usage_metrics = {"usage_source": "unavailable"}
rollout._native_usage_checkpoint = None
rollout._native_usage_metrics = _zero_native_acp_usage_metrics()
rollout._agent_launch = ""
rollout._env = None
rollout._environment = None
rollout._error = None
rollout._config = RolloutConfig(task_path=tmp_path)

await rollout.cleanup()

assert rollout._session is None
assert rollout._usage_metrics == {
"n_input_tokens": 20,
"n_output_tokens": 7,
"n_cache_read_tokens": 1,
"n_cache_creation_tokens": 2,
"total_tokens": 30,
"cost_usd": None,
"usage_source": "agent_native_acp",
"price_source": None,
"usage_details": {"thought_tokens": 3},
}


def test_rollout_provider_usage_wins_over_native_acp_usage():
"""Guards PR #613 follow-up: LiteLLM provider telemetry remains authoritative."""
from benchflow.rollout import Rollout
Expand Down
6 changes: 3 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading