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
35 changes: 30 additions & 5 deletions docs/daemon-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4337,11 +4337,36 @@ shape is persisted under the tool message's `metadata.tool_hunks`.
The start of a regular agent turn includes
`{"busy":true,"turn_id":"initial-user-message-uuid"}`. That id stays stable
across tool calls, model retries, and accepted steering input. For the terminal
transition, `busy_changed` includes
`{"busy":false,"outcome":"completed|error|aborted","turn_id":"..."}`
and the following `done` frame repeats the same `outcome`. Other busy cycles
such as compaction may omit it. Clients should only treat `completed` as a
successful turn.
transition, `busy_changed` includes the turn-wide usage summary:

```json
{
"busy": false,
"outcome": "completed",
"turn_id": "initial-user-message-uuid",
"usage": {
"prompt_tokens": 44100,
"completion_tokens": 2100,
"total_tokens": 46200,
"cache_read_tokens": 32000,
"cache_write_tokens": 0,
"reasoning_tokens": 500,
"has_data": true
}
}
```

The following `done` frame repeats the same `outcome`, `turn_id`, and `usage`.
The summary adds every accounted model step in the turn, including tool-call
round trips. It is not another incremental delta to add to preceding `usage`
or `model_step_finish` events. `has_data` is true only when at least one model
step was accounted and every included step used provider-reported usage; if
ACECode estimated any included step, counts still include that estimate but
`has_data` is false. `context_breakdown`, when present, is also summed across
included steps.

Other busy cycles such as compaction may omit `outcome`, `turn_id`, and
`usage`. Clients should only treat `completed` as a successful turn.

Transient pure-sampling failures use `agent_progress` rather than transcript
messages. While waiting, the payload is:
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/expose-turn-token-usage/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-21
25 changes: 25 additions & 0 deletions openspec/changes/expose-turn-token-usage/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Context

OpenAI-compatible provider 已把 Codex2API 流中的 `usage` 解析为 `TokenUsage`。`AgentLoop::call_provider_and_collect` 在一次成功 model step 后发出逐 step `usage`,而 `run_agent_with_input` 负责一个用户 turn 内的多次 model step 和最终 `busy_changed` / `done` 边界。

## Goals / Non-Goals

目标是在 daemon API 的 terminal turn event 上提供可直接消费的本 turn 汇总,并保持逐 step 事件兼容。非目标包括计费金额换算、修改 Codex2API、重算历史 turn、增加独立 REST endpoint 或改变 session 累计统计。

## Decisions

在 `run_agent_with_input` 内维护局部 `TokenUsage` accumulator,只在 model step 已通过 provider error/abort 判定并完成现有 usage 入账后累加。这样内部 provider retry 的 provisional usage、失败请求和未入账中止请求不会重复计数。

计数字段按 model step 求和:`prompt_tokens`、`completion_tokens`、`total_tokens`、cache read/write 与 reasoning。`context_breakdown` 同样按类别求和,代表本 turn 所有请求的输入构成总和。

`has_data` 表示汇总是否完全来自 provider 上报:至少有一个已入账 step 且所有已入账 step 的 `has_data=true` 时为 true;混入 ACECode 估算或完全没有模型请求时为 false。

regular turn 的 terminal `busy_changed` 和紧随其后的 `done` 都携带同一 `turn_id` 与 `usage`。重复是有意的:状态型客户端可只监听 `busy_changed`,完成型客户端可只监听 `done`。原有逐 step `usage` / `model_step_finish` 不变。

## Risks / Trade-offs

同一汇总出现在两个 terminal frame 会增加少量 payload,但避免客户端必须耦合某一种终止事件。客户端若自行累加逐 step `usage`,不得再把 terminal `usage` 叠加;文档会明确 terminal usage 是摘要。

## Validation

用脚本 provider 构造同一 turn 的两次模型请求,分别返回 usage,断言 terminal `busy_changed` 与 `done` 的 `turn_id` 一致、字段求和正确且逐 step usage 仍保持两条。再覆盖 provider usage 与估算混合时 `has_data=false`。运行对应 `acecode_unit_tests` 过滤用例、OpenSpec strict validate、`git diff --check`;按用户要求不进行完整编译。
21 changes: 21 additions & 0 deletions openspec/changes/expose-turn-token-usage/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Why

ACEModel(Codex2API)已经在每次模型请求的流式响应中返回 token usage,但一个 ACECode turn 可能包含多次模型请求和工具往返。现有 daemon API 只逐 step 推送 `usage`,turn 结束的 `busy_changed` / `done` 事件没有本 turn 汇总,API 客户端无法在明确的 turn 边界直接读取本轮总消耗。

## What Changes

- 在一个 regular agent turn 内累加所有已入账 model step 的 token usage。
- turn 结束时,在 terminal `busy_changed` 和 `done` API 事件中返回同一份 `usage` 汇总及 `turn_id`。
- 保留现有逐 step `usage` 和 `model_step_finish` 事件,避免破坏现有消费者。
- provider 未返回 usage 时沿用 ACECode 估算值;只要本 turn 有任一步为估算,汇总的 `has_data` 为 `false`。

## Capabilities

### New Capabilities
- `turn-token-usage-api`: daemon session event 在 turn 边界暴露本 turn token 消耗。

### Modified Capabilities

## Impact

影响 `AgentLoop` 的 turn 级计量聚合、daemon WebSocket session event 契约、对应单元测试与 `docs/daemon-api.md`;不改变 ACEModel/Codex2API 请求格式,不新增 HTTP 请求,也不改变持久化 session 累计用量。
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## ADDED Requirements

### Requirement: Terminal turn events expose aggregate usage
For every regular agent turn, the daemon session event stream SHALL include a `usage` object on both the terminal `busy_changed` event and the following `done` event. Both events SHALL include the same `turn_id`, `outcome`, and aggregate usage values.

#### Scenario: Multi-step ACEModel turn completes
- **WHEN** one user turn performs multiple successful model steps because tools are called
- **THEN** terminal `busy_changed` and `done` each contain the stable turn id
- **AND** each event's `usage` equals the sum of all accounted model-step token fields in that turn
- **AND** existing per-step `usage` and `model_step_finish` events remain available

#### Scenario: Turn uses only provider-reported usage
- **WHEN** every accounted model step includes provider-reported usage
- **THEN** terminal `usage.has_data` is `true`

#### Scenario: Turn includes estimated usage
- **WHEN** at least one accounted model step lacks provider-reported usage and ACECode estimates it
- **THEN** terminal token counts include the estimate
- **AND** terminal `usage.has_data` is `false`

#### Scenario: Turn ends before a model request is accounted
- **WHEN** a regular turn is blocked or fails before any model step is accounted
- **THEN** terminal usage contains zero counts
- **AND** terminal `usage.has_data` is `false`

### Requirement: Aggregate usage is a summary, not an incremental delta
Terminal turn usage SHALL summarize the whole turn and SHALL NOT replace or alter the semantics of incremental per-step usage events.

#### Scenario: Client already consumes step usage
- **WHEN** a client receives per-step `usage` events followed by terminal turn usage
- **THEN** the terminal value can be used as the authoritative turn summary
- **AND** it is not an additional usage delta to add to the preceding step events
12 changes: 12 additions & 0 deletions openspec/changes/expose-turn-token-usage/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## 1. 规范与测试
- [x] 1.1 定义 regular turn terminal event 的 usage 聚合契约。
- [x] 1.2 添加同 turn 多 model step usage 聚合回归测试。

## 2. 实现
- [x] 2.1 在 AgentLoop turn 生命周期内累加已入账 step usage。
- [x] 2.2 在 terminal busy_changed 与 done payload 暴露 turn_id 和 usage。
- [x] 2.3 更新 daemon API 文档。

## 3. 验证
- [x] 3.1 按用户要求不编译;完成 OpenSpec 与静态差异校验,并提交目标回归测试供 CI 执行。
- [x] 3.2 运行 OpenSpec strict validate、git diff --check 和提交前差异审查。
96 changes: 75 additions & 21 deletions src/agent_loop.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,33 @@ nlohmann::json model_step_usage_to_json(const TokenUsage& usage) {
return value;
}

void accumulate_turn_usage(TokenUsage& aggregate,
bool& initialized,
const TokenUsage& step) {
aggregate.prompt_tokens += step.prompt_tokens;
aggregate.completion_tokens += step.completion_tokens;
aggregate.total_tokens += step.total_tokens;
aggregate.cache_read_tokens += step.cache_read_tokens;
aggregate.cache_write_tokens += step.cache_write_tokens;
aggregate.reasoning_tokens += step.reasoning_tokens;

auto& total_context = aggregate.context_breakdown;
const auto& step_context = step.context_breakdown;
total_context.system_prompt += step_context.system_prompt;
total_context.project_rules += step_context.project_rules;
total_context.skills += step_context.skills;
total_context.builtin_tools += step_context.builtin_tools;
total_context.mcp_tools += step_context.mcp_tools;
total_context.conversation += step_context.conversation;
total_context.dynamic_context += step_context.dynamic_context;
total_context.has_data = total_context.has_data || step_context.has_data;

aggregate.has_data = initialized
? aggregate.has_data && step.has_data
: step.has_data;
initialized = true;
}

std::string provider_error_summary_for_log(const ProviderErrorInfo& info) {
std::string message = info.display_message;
if (message.empty()) message = info.pretty_json;
Expand Down Expand Up @@ -1027,6 +1054,10 @@ void AgentLoop::worker_main() {
worker_task_active_ = true;
worker_task_kind_ = task.kind;
}
if (task.kind == WorkerTask::Kind::Chat) {
active_turn_usage_ = TokenUsage{};
active_turn_usage_initialized_ = false;
}
try {
switch (task.kind) {
case WorkerTask::Kind::Chat:
Expand Down Expand Up @@ -1109,9 +1140,15 @@ void AgentLoop::recover_worker_task_error(const char* detail, bool chat_task) {
attempt([&] {
if (chat_task && callbacks_.on_turn_finished) callbacks_.on_turn_finished("error");
});
const nlohmann::json idle = {
nlohmann::json idle = {
{"busy", false}, {"outcome", "error"}, {"turn_id", turn_id}};
const nlohmann::json done = {{"outcome", "error"}};
nlohmann::json done = {{"outcome", "error"}};
if (chat_task) {
const auto usage = model_step_usage_to_json(active_turn_usage_);
idle["usage"] = usage;
done["turn_id"] = turn_id;
done["usage"] = usage;
}
attempt([&] { record_terminal_trajectory_events(idle, done); });
attempt([&] {
if (callbacks_.on_busy_changed) callbacks_.on_busy_changed(false);
Expand Down Expand Up @@ -5399,16 +5436,25 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
if (callbacks_.on_turn_finished) {
callbacks_.on_turn_finished("error");
}
record_terminal_trajectory_events(
{{"busy", false}, {"outcome", "error"}},
{{"outcome", "error"}});
const std::string turn_id = generate_uuid();
const auto usage = model_step_usage_to_json(active_turn_usage_);
const nlohmann::json idle = {
{"busy", false},
{"outcome", "error"},
{"turn_id", turn_id},
{"usage", usage},
};
const nlohmann::json done = {
{"outcome", "error"},
{"turn_id", turn_id},
{"usage", usage},
};
record_terminal_trajectory_events(idle, done);
if (callbacks_.on_busy_changed) callbacks_.on_busy_changed(false);
record_turn_outcome("error");
busy_ = false;
events_.emit(SessionEventKind::BusyChanged, nlohmann::json{
{"busy", false}, {"outcome", "error"}});
events_.emit(SessionEventKind::Done, nlohmann::json{
{"outcome", "error"}});
events_.emit(SessionEventKind::BusyChanged, idle);
events_.emit(SessionEventKind::Done, done);
maybe_continue_goal();
return;
}
Expand Down Expand Up @@ -5789,11 +5835,16 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
estimated_usage.completion_tokens = estimate_message_tokens({estimated_response});
estimated_usage.total_tokens = estimated_usage.prompt_tokens + estimated_usage.completion_tokens;
estimated_usage.has_data = false;
estimated_usage.context_breakdown = reconcile_context_usage_breakdown(
bundle.context_usage_estimate,
estimated_usage.prompt_tokens);
step_usage = estimated_usage;
account_goal_usage(estimated_usage.total_tokens, false);
if (callbacks_.on_usage) callbacks_.on_usage(estimated_usage);
if (session_manager_) session_manager_->record_token_usage(estimated_usage);
}
accumulate_turn_usage(
active_turn_usage_, active_turn_usage_initialized_, step_usage);

record_model_response(
current_model_step, provider_result, step_usage, "completed");
Expand Down Expand Up @@ -6019,11 +6070,19 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
if (callbacks_.on_turn_finished) {
callbacks_.on_turn_finished(turn_timing_status);
}
record_terminal_trajectory_events(
{{"busy", false},
{"outcome", turn_timing_status},
{"turn_id", turn_info.active_turn_id}},
{{"outcome", turn_timing_status}});
const auto usage = model_step_usage_to_json(active_turn_usage_);
const nlohmann::json idle = {
{"busy", false},
{"outcome", turn_timing_status},
{"turn_id", turn_info.active_turn_id},
{"usage", usage},
};
const nlohmann::json done = {
{"outcome", turn_timing_status},
{"turn_id", turn_info.active_turn_id},
{"usage", usage},
};
record_terminal_trajectory_events(idle, done);
if (callbacks_.on_busy_changed) {
callbacks_.on_busy_changed(false);
}
Expand All @@ -6035,13 +6094,8 @@ void AgentLoop::run_agent_with_input(const UserInput& input,
}
record_turn_outcome(turn_timing_status);
busy_ = false;
events_.emit(SessionEventKind::BusyChanged, nlohmann::json{
{"busy", false},
{"outcome", turn_timing_status},
{"turn_id", turn_info.active_turn_id},
});
events_.emit(SessionEventKind::Done, nlohmann::json{
{"outcome", turn_timing_status}});
events_.emit(SessionEventKind::BusyChanged, idle);
events_.emit(SessionEventKind::Done, done);
if (terminate_session_after_turn_) {
// There must be no provider-visible state left for a deleted session.
// The post-turn action owns writer teardown and persistent cleanup.
Expand Down
6 changes: 6 additions & 0 deletions src/agent_loop.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,12 @@ class AgentLoop {
// Latest server-reported total active-context usage. For providers that do
// not return total_tokens, prompt_tokens is used as the fallback.
std::atomic<int> last_api_total_tokens_{0};
// Aggregate usage for the regular turn currently owned by the worker.
// Kept as worker state (rather than a stack local) so the outer worker
// recovery boundary can still publish an accurate terminal summary after
// an exception unwinds run_agent_with_input().
TokenUsage active_turn_usage_;
bool active_turn_usage_initialized_ = false;
// PA 兜底的 episode 进度(见 run_pa_overflow_rescue)。服务端收下请求即
// 清零;回合开始也清零。只在回合线程上读写。
pa::RescueState pa_rescue_state_;
Expand Down
4 changes: 2 additions & 2 deletions src/session/session_client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ enum class SessionEventKind {
GoalCleared, // payload: {"session_id":"..."}
TodoUpdated, // payload: {"session_id":"...", "todos": [...], "summary": {...}}
SessionUpdated, // payload: {"session_id":"...", "title":"...", ...}
BusyChanged, // payload: {"busy": bool,"outcome"?:completed|error|aborted}
Done, // payload: {"outcome"?:completed|error|aborted}
BusyChanged, // payload: {"busy":bool,"outcome"?:...,"turn_id"?:...,"usage"?:{...}}
Done, // regular turn: {"outcome":...,"turn_id":"...","usage":{...}}
Error, // payload: {"reason":"...", "request_id":"..."(可选)}
};

Expand Down
Loading
Loading