agentus 0.15.2 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
4
4
  "description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence \u2014 the agent lives in spawned CLI subprocesses.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -92,7 +92,9 @@
92
92
  "tus-mode-sweep": "node scripts/qa/tus-mode-sweep.mjs",
93
93
  "usage-row-sweep": "node scripts/qa/usage-row-sweep.mjs",
94
94
  "call-brief-sweep": "node scripts/qa/call-brief-sweep.mjs",
95
- "reply-on-done": "node scripts/qa/reply-on-done.mjs"
95
+ "reply-on-done": "node scripts/qa/reply-on-done.mjs",
96
+ "turn-usage": "tsx scripts/qa/turn-usage.mts",
97
+ "turn-usage-sweep": "node scripts/qa/turn-usage-sweep.mjs"
96
98
  },
97
99
  "engines": {
98
100
  "node": ">=22.5"
@@ -949,7 +949,23 @@ const agent = () => ({
949
949
  used: s.used,
950
950
  size: picked > 0 ? picked : Number(process.env.MOCK_USAGE_SIZE || 200_000),
951
951
  });
952
- return { stopReason: "end_turn" };
952
+ // The prompt response's `usage`, shaped like the hermes adapter's (issue #70). ACP's semantics
953
+ // are "Total … across all turns", so the counters ACCUMULATE on the session state — a cockpit
954
+ // that forgot to diff would show the whole conversation on every turn, and a two-turn QA sweep
955
+ // is what proves it does not. `cachedWriteTokens` is filled too, because the fork's adapter
956
+ // still omits it and the cockpit must not depend on that omission.
957
+ const outChars = words.join(" ").length + MOCK_MARKDOWN.length;
958
+ s.tokens = s.tokens || {
959
+ totalTokens: 0, inputTokens: 0, outputTokens: 0, thoughtTokens: 0,
960
+ cachedReadTokens: 0, cachedWriteTokens: 0,
961
+ };
962
+ s.tokens.inputTokens += text.length + 200;
963
+ s.tokens.cachedReadTokens += 4096;
964
+ s.tokens.cachedWriteTokens += 128;
965
+ s.tokens.outputTokens += Math.ceil(outChars / 4);
966
+ s.tokens.thoughtTokens += 32;
967
+ s.tokens.totalTokens = s.tokens.inputTokens + s.tokens.outputTokens + s.tokens.thoughtTokens;
968
+ return { stopReason: "end_turn", usage: s.tokens };
953
969
  },
954
970
  });
955
971
 
@@ -49,6 +49,8 @@ import type {
49
49
  SessionModeState,
50
50
  StatusNoteItem,
51
51
  TurnTrace,
52
+ TurnUsage,
53
+ UsageTotals,
52
54
  } from "../../../shared/src/index.js";
53
55
  import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, clampTitle, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
54
56
  import { PLAN_GUIDANCE_ACTIVE, PLAN_GUIDANCE_INTRO, PLAN_GUIDANCE_NATIVE } from "../../../shared/src/index.js";
@@ -1564,13 +1566,30 @@ export class SessionManager {
1564
1566
  // The agent has the block (a cancelled turn still received it): NOW the boundary is spent.
1565
1567
  settleVoice();
1566
1568
  this.#finalizePlan(sessionId, terminalState);
1567
- // Turn END is the second and last ordering event of a turn (issue #50): start moved the session,
1568
- // this settles it where the conversation now is. Mid-turn rows deliberately do NOT touch — see
1569
- // #touch.
1569
+ const durationMs = Date.now() - turnStartedAt;
1570
+ // issue #70: the agent's CUMULATIVE token counters arrive on the prompt RESPONSE — a frame
1571
+ // nothing else reads — so the per-turn bill and the cache-hit rate were simply thrown away.
1572
+ // One row per turn, at the tail of the reply it bills: persisted, because a reload must show
1573
+ // the same numbers (the agent will never re-send them), and it sits AFTER the reply by seq.
1574
+ const bill = turnUsageOf(res?.usage, s.info.usage?.totals ?? null, durationMs);
1575
+ if (bill) {
1576
+ s.info.usage = {
1577
+ ...(s.info.usage ?? { used: 0, size: 0, cost: null, at: Date.now() }),
1578
+ totals: bill.totals,
1579
+ at: Date.now(),
1580
+ };
1581
+ }
1570
1582
  this.#touch(s);
1571
- this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs: Date.now() - turnStartedAt });
1583
+ const usageRow = this.#store.appendMessage({
1584
+ sessionId,
1585
+ kind: "turn-usage",
1586
+ payload: bill ? bill.bill : { available: false, durationMs },
1587
+ createdAt: Date.now(),
1588
+ });
1589
+ this.#emit({ t: "message", message: usageRow });
1590
+ this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs, ...(bill ? { usage: bill.bill } : {}) });
1572
1591
  // #59: 谁在等这一轮结束(跨会话 send --reply-on-done)
1573
- this.#settleTurnReplies(sessionId, mine, { stopReason: res?.stopReason ?? null, durationMs: Date.now() - turnStartedAt });
1592
+ this.#settleTurnReplies(sessionId, mine, { stopReason: res?.stopReason ?? null, durationMs });
1574
1593
  } catch (err) {
1575
1594
  this.#finalizePlan(sessionId, "failed");
1576
1595
  this.#touch(s);
@@ -2654,6 +2673,10 @@ export class SessionManager {
2654
2673
  size: Number.isFinite(size) ? size : 0,
2655
2674
  cost: uu.cost && typeof uu.cost.amount === "number" ? uu.cost.amount : null,
2656
2675
  at: Date.now(),
2676
+ // issue #70: this frame carries NO token breakdown, so the cumulative baseline that the
2677
+ // per-turn bills are subtracted from must survive it — replacing the object wholesale is
2678
+ // what would make the next bill report the whole session again.
2679
+ ...(live.info.usage?.totals ? { totals: live.info.usage.totals } : {}),
2657
2680
  };
2658
2681
  this.#updateSession(live);
2659
2682
  this.#emit({ t: "usage", sessionId: live.info.id, usage: live.info.usage });
@@ -3520,6 +3543,65 @@ function normConfigOptions(raw: unknown): ConfigOptionView[] {
3520
3543
  });
3521
3544
  }
3522
3545
 
3546
+ /** One turn's token bill, from the agent's CUMULATIVE counters (issue #70).
3547
+ *
3548
+ * ACP documents `PromptResponse.usage` as "Total … across all turns", so a turn's number is a
3549
+ * subtraction against the baseline kept beside the context gauge (`UsageView.totals`). Three
3550
+ * deliberate rules:
3551
+ * · a bucket the agent did not report stays ABSENT — a Qoder session must read 「未上报」, never a
3552
+ * made-up zero;
3553
+ * · a total that SHRANK means the agent's counters reset (a resumed process): the raw value is
3554
+ * then this turn's own number and the baseline resets, instead of emitting a negative bill;
3555
+ * · a turn that reported nothing at all returns null (the row still says 「未上报」).
3556
+ * The FIRST bill of a session that predates this baseline is therefore cumulative — one turn of
3557
+ * honest over-reporting beats inventing a difference. */
3558
+ export function turnUsageOf(
3559
+ raw: unknown, base: UsageTotals | null | undefined, durationMs: number,
3560
+ ): { bill: TurnUsage; totals: UsageTotals } | null {
3561
+ const u = (raw && typeof raw === "object" ? raw : null) as Record<string, unknown> | null;
3562
+ if (!u) return null;
3563
+ const num = (v: unknown): number | null =>
3564
+ (typeof v === "number" && Number.isFinite(v) ? Math.max(0, Math.round(v)) : null);
3565
+ const total = num(u.totalTokens);
3566
+ const input = num(u.inputTokens);
3567
+ const output = num(u.outputTokens);
3568
+ const thought = num(u.thoughtTokens);
3569
+ const cacheRead = num(u.cachedReadTokens);
3570
+ const cacheWrite = num(u.cachedWriteTokens);
3571
+ if ([total, input, output, thought, cacheRead, cacheWrite].every((x) => x === null)) return null;
3572
+ const cur: UsageTotals = {
3573
+ total: total ?? (input ?? 0) + (output ?? 0),
3574
+ input: input ?? 0,
3575
+ output: output ?? 0,
3576
+ thought: thought ?? 0,
3577
+ cacheRead: cacheRead ?? 0,
3578
+ cacheWrite: cacheWrite ?? 0,
3579
+ };
3580
+ const delta = (now: number, before: number | undefined): number =>
3581
+ (before === undefined ? now : now >= before ? now - before : now);
3582
+ const bill: TurnUsage = {
3583
+ total: delta(cur.total, base?.total),
3584
+ input: delta(cur.input, base?.input),
3585
+ output: delta(cur.output, base?.output),
3586
+ thought: delta(cur.thought, base?.thought),
3587
+ cacheRead: delta(cur.cacheRead, base?.cacheRead),
3588
+ cacheWrite: delta(cur.cacheWrite, base?.cacheWrite),
3589
+ durationMs,
3590
+ // Webui's denominator: the FULL prompt = input + cache read + cache write. Only when the agent
3591
+ // reports a cache bucket at all — otherwise the page says 「未上报」, not a 0% hit rate.
3592
+ cacheHitPercent: cacheRead === null && cacheWrite === null
3593
+ ? null
3594
+ : (() => {
3595
+ const prompt = (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0);
3596
+ return prompt > 0 ? Math.min(100, Math.round(((cacheRead ?? 0) / prompt) * 100)) : null;
3597
+ })(),
3598
+ };
3599
+ // Average speed: this turn's OWN output over wall time (tool calls included, so a long tool
3600
+ // turn honestly reads slow rather than being dressed up as model speed).
3601
+ bill.tps = bill.output > 0 && durationMs > 0 ? Math.round((bill.output / (durationMs / 1000)) * 10) / 10 : null;
3602
+ return { bill, totals: cur };
3603
+ }
3604
+
3523
3605
  function errMessage(e: unknown): string {
3524
3606
  if (e && typeof e === "object" && "message" in e) return String((e as { message: unknown }).message);
3525
3607
  return String(e);
@@ -132,6 +132,41 @@ export interface UsageView {
132
132
  size: number;
133
133
  cost?: number | null;
134
134
  at: number;
135
+ /** SESSION-CUMULATIVE token counters from `PromptResponse.usage`, as the agent reported them
136
+ * (ACP's own semantics: "Total ... across all turns"). Kept beside the context gauge because
137
+ * that is the only place a reload can learn them again — `usage_update` carries no token
138
+ * breakdown, and the per-turn numbers are differences of these. Absent = the agent never
139
+ * reported any (Qoder), which reads as "not available", NEVER as measured zero. */
140
+ totals?: UsageTotals | null;
141
+ }
142
+
143
+ /** Cumulative token buckets a turn's usage is measured against (ACP `Usage`). */
144
+ export interface UsageTotals {
145
+ total: number;
146
+ input: number;
147
+ output: number;
148
+ thought: number;
149
+ cacheRead: number;
150
+ cacheWrite: number;
151
+ }
152
+
153
+ /** One turn's token bill (issue #70) — the DELTA of the agent's cumulative counters.
154
+ *
155
+ * `cacheHitPercent` uses the webui's definition (prompt cache reads over the full prompt total:
156
+ * input + cache read + cache write), and is null when the agent reports no cache buckets at all —
157
+ * a Qoder session must read as unavailable, not as a 0% hit rate. `tps` is the turn AVERAGE
158
+ * (output tokens over wall time, tool calls included); it is deliberately labelled as an average
159
+ * rather than sold as instantaneous speed. */
160
+ export interface TurnUsage {
161
+ input: number;
162
+ output: number;
163
+ thought: number;
164
+ cacheRead: number;
165
+ cacheWrite: number;
166
+ total: number;
167
+ durationMs?: number;
168
+ cacheHitPercent?: number | null;
169
+ tps?: number | null;
135
170
  }
136
171
 
137
172
  export interface SessionModeState {
@@ -228,7 +263,10 @@ export type ServerEvent =
228
263
  /** Wall time from the moment the turn was announced to the moment the agent answered (ms).
229
264
  * Exists so a notification can say "用时 1m12s" instead of a protocol token like `end_turn`,
230
265
  * which is a fact about the wire, not about the operator's wait (absent when unknowable). */
231
- durationMs?: number }
266
+ durationMs?: number;
267
+ /** What this turn cost in tokens (issue #70), subtracted from the agent's cumulative
268
+ * counters. Absent when the agent reports none — the page then says 「未上报」, never 0. */
269
+ usage?: TurnUsage | null }
232
270
  /** per-turn trace: what model/effort/mode this turn is actually running with
233
271
  * (AionUi F-DISPLAY-11 lineage — "why did it behave differently?" ) */
234
272
  /** `pid` is the id of the prompt that started this turn, copied from the client's command (issue
@@ -491,8 +529,11 @@ export interface StoredMessage {
491
529
  * `approval` = an approval request as a transcript row (it asks a question, then carries the
492
530
  * decision), and `project-skills` = the agent reporting that this repo ships skills it is NOT
493
531
  * loading. Both are DUrable on purpose: a modal that vanishes leaves no record of what was
494
- * asked or what was answered. */
495
- kind: "user" | "agent" | "thought" | "tool" | "plan" | "meta" | "note" | "approval" | "project-skills";
532
+ * asked or what was answered. `turn-usage` = one turn's token bill (issue #70): it is written at
533
+ * turn-end, so it sits at the TAIL of the reply it bills, and it is persisted because a reload
534
+ * must show the same numbers instead of losing the one part of the turn the agent will never
535
+ * re-send. */
536
+ kind: "user" | "agent" | "thought" | "tool" | "plan" | "meta" | "note" | "approval" | "project-skills" | "turn-usage";
496
537
  payload: unknown; // raw ACP content/update fragment (kept lossless)
497
538
  toolCallId?: string; // set for kind=tool; upsert key
498
539
  createdAt: number;