pi-nebius 0.3.2 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  Versions use Semantic Versioning. Entries describe changes included in the named version;
4
4
  a version is released only when its matching Git tag and GitHub release are published.
5
5
 
6
+ ## [0.4.0](https://github.com/PeterHdd/pi-nebius/compare/v0.3.2...v0.4.0) (2026-09-23)
7
+
8
+
9
+ ### Features
10
+
11
+ * add request-level benchmark token observability ([33385c2](https://github.com/PeterHdd/pi-nebius/commit/33385c2364bf958607d0c8d0a2ac8d6bf6899663))
12
+ * add request-level benchmark token observability ([5ecdaea](https://github.com/PeterHdd/pi-nebius/commit/5ecdaea0bda25b4df3fcfe50d5b9e0e15883350c))
13
+
6
14
  ## [0.3.2](https://github.com/PeterHdd/pi-nebius/compare/v0.3.1...v0.3.2) (2026-09-17)
7
15
 
8
16
 
package/README.md CHANGED
@@ -109,6 +109,10 @@ middle values for an even number of runs). With one run, both show that run's me
109
109
  Failed runs contribute to the performance metrics; missing measurements appear as `n/a`.
110
110
  Custom prompts have no automatic correctness check, so a completed run does not establish success.
111
111
 
112
+ After the summary, a per-request trace shows token usage, context sizes in bytes, tool-output sizes,
113
+ and repeated operations. These observations help identify where context grows without changing
114
+ the agent. Byte counts are not token estimates, and repeated calls are not necessarily wasteful.
115
+
112
116
  Detailed results, request settings, and resulting files are saved under `benchmark-results/`.
113
117
  Benchmarks use your saved model settings at the start; later changes do not affect an active comparison.
114
118
 
@@ -0,0 +1,59 @@
1
+ import { isRecord } from "../models.js";
2
+ export function textSize(content) {
3
+ const text = typeof content === "string"
4
+ ? content
5
+ : Array.isArray(content)
6
+ ? content
7
+ .filter((block) => isRecord(block) && block.type === "text" && typeof block.text === "string")
8
+ .map((block) => block.text)
9
+ .join("\n")
10
+ : "";
11
+ return {
12
+ bytes: Buffer.byteLength(text, "utf8"),
13
+ lines: text ? text.split("\n").length - Number(text.endsWith("\n")) : 0,
14
+ };
15
+ }
16
+ /** Counts serialized JSON bytes, not model tokens. Never retains request content. */
17
+ export function observeContext(body) {
18
+ if (typeof body !== "string")
19
+ return null; // Do not consume Request/stream bodies for observation.
20
+ try {
21
+ const payload = JSON.parse(body);
22
+ if (!isRecord(payload) || !Array.isArray(payload.messages))
23
+ return null;
24
+ const bytes = { system: 0, tools: 0, user: 0, assistant: 0, toolResults: 0, other: 0 };
25
+ const toolResults = [];
26
+ for (const message of payload.messages) {
27
+ const role = isRecord(message) ? message.role : undefined;
28
+ const category = role === "system" || role === "developer"
29
+ ? "system"
30
+ : role === "user"
31
+ ? "user"
32
+ : role === "assistant"
33
+ ? "assistant"
34
+ : role === "tool"
35
+ ? "toolResults"
36
+ : "other";
37
+ bytes[category] += Buffer.byteLength(JSON.stringify(message), "utf8");
38
+ if (isRecord(message) && role === "tool" && typeof message.tool_call_id === "string")
39
+ toolResults.push({ id: message.tool_call_id, ...textSize(message.content) });
40
+ }
41
+ if (payload.tools !== undefined)
42
+ bytes.tools = Buffer.byteLength(JSON.stringify(payload.tools), "utf8");
43
+ return {
44
+ measurement: "serialized-json-bytes",
45
+ bytes,
46
+ messageCount: payload.messages.length,
47
+ toolResults,
48
+ };
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** Stable object-key order lets equivalent argument objects compare within a run. */
55
+ export function canonical(value) {
56
+ return (JSON.stringify(value, (_key, item) => isRecord(item)
57
+ ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)))
58
+ : item) ?? "null");
59
+ }
@@ -1,5 +1,6 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac, randomBytes } from "node:crypto";
2
2
  import { isRecord } from "../models.js";
3
+ import { canonical, observeContext, textSize } from "./context-observation.js";
3
4
  export const hash = (text) => createHash("sha256").update(text).digest("hex");
4
5
  const count = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
5
6
  export function reportedUsage(value) {
@@ -46,6 +47,11 @@ export function redactor(secrets) {
46
47
  export class Instrumentation {
47
48
  state = emptyObservation();
48
49
  compacting = false;
50
+ // Per-run HMAC keys are never persisted: compare values without retaining commands or file text.
51
+ fingerprintKey = randomBytes(32);
52
+ fingerprint(value) {
53
+ return createHmac("sha256", this.fingerprintKey).update(canonical(value)).digest("hex");
54
+ }
49
55
  update;
50
56
  now;
51
57
  redact;
@@ -83,8 +89,14 @@ export class Instrumentation {
83
89
  case "auto_retry_start":
84
90
  this.state.retries++;
85
91
  break;
86
- case "tool_execution_start":
92
+ case "tool_execution_start": {
93
+ const fingerprint = this.fingerprint(event.args);
94
+ const previous = this.state.tools.find((tool) => tool.name === event.toolName && tool.argumentsFingerprint === fingerprint);
95
+ const origin = this.state.requests.find((request) => request.generatedToolCalls?.some((tool) => tool.id === event.toolCallId));
87
96
  this.state.tools.push({
97
+ request: origin?.request ?? null,
98
+ argumentsFingerprint: fingerprint,
99
+ repeatedArgumentsOf: previous?.id ?? null,
88
100
  id: event.toolCallId,
89
101
  name: event.toolName,
90
102
  startedAtMs: at,
@@ -93,6 +105,7 @@ export class Instrumentation {
93
105
  error: null,
94
106
  });
95
107
  break;
108
+ }
96
109
  case "tool_execution_end": {
97
110
  const tool = this.state.tools.find((item) => item.id === event.toolCallId);
98
111
  if (tool) {
@@ -104,6 +117,11 @@ export class Instrumentation {
104
117
  case "message_end": {
105
118
  const message = event.message;
106
119
  if (message.role === "assistant") {
120
+ const request = this.state.requests.filter((item) => item.purpose === "agent").at(-1);
121
+ if (request)
122
+ request.generatedToolCalls = message.content
123
+ .filter((block) => block.type === "toolCall")
124
+ .map((block) => ({ id: block.id, name: block.name }));
107
125
  this.state.assistantMessages++;
108
126
  this.state.lastAssistantStopReason = message.stopReason;
109
127
  if (message.errorMessage)
@@ -129,6 +147,14 @@ export class Instrumentation {
129
147
  this.state.tools.push(tool);
130
148
  }
131
149
  tool.isError = message.isError;
150
+ const fingerprint = this.fingerprint(message.content);
151
+ tool.output = { ...textSize(message.content), fingerprint };
152
+ const previous = this.state.tools.find((item) => item !== tool &&
153
+ item.name === tool.name &&
154
+ item.argumentsFingerprint !== undefined &&
155
+ item.argumentsFingerprint === tool.argumentsFingerprint &&
156
+ item.output?.fingerprint === fingerprint);
157
+ tool.repeatedOutputOf = previous?.id ?? null;
132
158
  if (message.isError)
133
159
  tool.error = this.redact(message.content
134
160
  .filter((item) => item.type === "text")
@@ -149,6 +175,8 @@ export class Instrumentation {
149
175
  return fetcher(input, init);
150
176
  const trace = {
151
177
  request: this.state.requests.length + 1,
178
+ context: observeContext(init?.body),
179
+ generatedToolCalls: [],
152
180
  purpose: this.compacting ? "compaction" : "agent",
153
181
  startedAtMs: this.now(),
154
182
  endedAtMs: null,
@@ -1,4 +1,5 @@
1
1
  import { distribution } from "./metrics.js";
2
+ import { requestReport } from "./request-report.js";
2
3
  /** Client-observed first generated content on the first agent request, not worker startup. */
3
4
  export function observedTtftMs(run) {
4
5
  const first = run.observation.requests.find((request) => request.purpose === "agent");
@@ -89,6 +90,13 @@ export function terminalReport(results) {
89
90
  ]
90
91
  : []),
91
92
  "",
93
+ ...results.runs.flatMap(requestReport),
94
+ "",
95
+ "Request tokens are provider-reported per HTTP attempt, including retries and compaction when observed.",
96
+ "Δ input is the change from the preceding request, not tokens attributable to its tools.",
97
+ "Context sizes are measured UTF-8 JSON bytes including message wrappers; category token counts are unavailable.",
98
+ "Tool text sizes exclude images and may already be truncated by Pi. Commands, arguments, and successful output text are not stored.",
99
+ "Input amplification = cumulative input / last agent request input; not a waste score or final context size.",
92
100
  "Detailed per-run measurements and validator output: results.json.",
93
101
  ].join("\n");
94
102
  }
@@ -0,0 +1,118 @@
1
+ const number = (value) => value == null ? "n/a" : value.toLocaleString("en-US");
2
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: remove terminal control characters from labels
3
+ const label = (value) => value.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 80);
4
+ const table = (rows) => {
5
+ const widths = rows[0]?.map((_, index) => Math.max(...rows.map((row) => row[index]?.length ?? 0))) ?? [];
6
+ return rows.map((row) => row
7
+ .map((cell, index) => cell.padEnd(widths[index] ?? 0))
8
+ .join(" │ ")
9
+ .trimEnd());
10
+ };
11
+ export function requestReport(run) {
12
+ const requests = run.observation.requests;
13
+ const tools = run.observation.tools ?? [];
14
+ const rows = [["Request", "Kind", "Input", "Δ input", "Output", "Cached", "Tools produced"]];
15
+ const sizes = [
16
+ ["Request", "System B", "Schemas B", "User B", "Assistant B", "Tool results B", "Other B"],
17
+ ];
18
+ for (const [index, request] of requests.entries()) {
19
+ const input = request.usage?.inputTokens;
20
+ const previous = requests[index - 1]?.usage?.inputTokens;
21
+ const delta = input != null && previous != null ? input - previous : null;
22
+ rows.push([
23
+ String(request.request ?? index + 1),
24
+ request.purpose,
25
+ number(input),
26
+ delta !== null && delta > 0 ? `+${number(delta)}` : number(delta),
27
+ number(request.usage?.outputTokens),
28
+ number(request.usage?.cachedInputTokens),
29
+ request.generatedToolCalls?.length
30
+ ? request.generatedToolCalls.map((tool) => label(tool.name)).join(", ")
31
+ : "—",
32
+ ]);
33
+ const bytes = request.context?.bytes;
34
+ sizes.push([
35
+ String(request.request ?? index + 1),
36
+ ...[
37
+ bytes?.system,
38
+ bytes?.tools,
39
+ bytes?.user,
40
+ bytes?.assistant,
41
+ bytes?.toolResults,
42
+ bytes?.other,
43
+ ].map(number),
44
+ ]);
45
+ }
46
+ const input = run.tokens.cumulativeInputTokens;
47
+ const output = run.tokens.cumulativeOutputTokens;
48
+ rows.push([
49
+ "TOTAL",
50
+ "all attempts",
51
+ number(input),
52
+ "—",
53
+ number(output),
54
+ number(run.tokens.cachedInputTokens),
55
+ "",
56
+ ]);
57
+ const last = run.tokens.lastRequestInputTokens;
58
+ const amplification = run.tokens.inputAmplificationVsLastRequest;
59
+ const total = input != null && output != null ? input + output : null;
60
+ const consumption = run.success && run.validation.checked
61
+ ? "Tokens to validated solution"
62
+ : run.failure !== null
63
+ ? "Tokens consumed before failure"
64
+ : "Tokens consumed (correctness not checked)";
65
+ const lines = [
66
+ "",
67
+ `Request trace: ${label(run.model)} #${run.run}`,
68
+ ...table(rows),
69
+ `${consumption}: ${number(total)} (input ${number(input)} + output ${number(output)}).`,
70
+ `Last agent request input: ${number(last)}; input amplification: ${amplification == null ? "n/a" : `${amplification.toFixed(2)}x`}.`,
71
+ "",
72
+ "Context composition — measured JSON bytes (B), NOT token attribution:",
73
+ ...table(sizes),
74
+ ];
75
+ if (tools.length) {
76
+ lines.push("", "Tool results — model-facing text after Pi processing:");
77
+ const toolRows = [
78
+ [
79
+ "Tool",
80
+ "From request",
81
+ "Text bytes",
82
+ "Lines",
83
+ "Included in requests",
84
+ "Repeated arguments",
85
+ "Same result",
86
+ ],
87
+ ];
88
+ for (const [index, tool] of tools.entries()) {
89
+ const included = requests
90
+ .filter((request) => request.context?.toolResults.some((result) => result.id === tool.id))
91
+ .map((request) => request.request);
92
+ const reference = (id) => {
93
+ const at = tools.findIndex((item) => item.id === id);
94
+ return at < 0 ? "—" : `T${at + 1}`;
95
+ };
96
+ toolRows.push([
97
+ `T${index + 1} ${label(tool.name)}`,
98
+ number(tool.request),
99
+ number(tool.output?.bytes),
100
+ number(tool.output?.lines),
101
+ requests.every((request) => request.context != null)
102
+ ? included.join(", ") || "none"
103
+ : `${included.join(", ") || "none observed"} (partial)`,
104
+ reference(tool.repeatedArgumentsOf),
105
+ reference(tool.repeatedOutputOf),
106
+ ]);
107
+ }
108
+ lines.push(...table(toolRows));
109
+ const large = tools
110
+ .map((tool, index) => ({ tool, index }))
111
+ .filter(({ tool }) => (tool.output?.bytes ?? 0) >= 16384);
112
+ for (const { tool, index } of large)
113
+ lines.push(`Observation: T${index + 1} returned ${number(tool.output?.bytes)} text bytes (large-output threshold: 16 KiB).`);
114
+ if (tools.some((tool) => tool.repeatedArgumentsOf))
115
+ lines.push("Repeated arguments/results are candidates to inspect, not proof of unnecessary work or unchanged files.");
116
+ }
117
+ return lines;
118
+ }
@@ -251,3 +251,44 @@ No live benchmark has been run in this environment: `NEBIUS_API_KEY` was unavail
251
251
  See [benchmark-research.md](benchmark-research.md) for inspected source APIs and Nebius methodology.
252
252
 
253
253
  Result schema version 2 removes monetary fields from version 1 (per-run estimates, pricing snapshots, aggregate costs, and the pricing hash). Existing saved results are not rewritten. Task definitions still use schema version 1.
254
+
255
+
256
+ ## Request-level observability
257
+
258
+ After the comparison summary, each run shows its individual HTTP requests: provider-reported
259
+ input/output tokens, cached input tokens when available, change in input from the preceding request,
260
+ and tool calls produced by the response. A request can produce zero, one, or multiple tool calls.
261
+ Retries and compaction requests remain separate rows; a request is not necessarily an agent turn.
262
+ Missing usage stays `n/a`, including totals when any required usage is missing.
263
+
264
+ The context-composition table measures **UTF-8 serialized JSON bytes**, not tokens. Categories are
265
+ system/developer messages, tool schemas, user messages, assistant messages (including tool arguments),
266
+ tool-result messages, and other messages. Message wrapper fields are included; array separators and
267
+ other HTTP payload fields are not. File reads and shell output are subtypes of tool results, not
268
+ additional categories. Category token counts are unavailable; no tokenizer estimates are substituted.
269
+ Input-token differences are net changes, not causal attribution to the most recent tool.
270
+
271
+ Tool rows use local labels such as `T1 read` and `T2 bash`. They show model-facing text bytes/lines,
272
+ the originating request, and the subsequent request numbers containing that tool-call ID. Repeated
273
+ HTTP attempts count as repeated inclusions. These are observations after Pi's tool processing;
274
+ original shell stdout/stderr may already have been truncated. Text sizes exclude images. If a request
275
+ body cannot be inspected without consuming it, context measurements are unavailable and inclusion
276
+ lists are labelled partial.
277
+
278
+ Outputs of at least 16 KiB receive a large-output observation. Matching tool names and canonicalized
279
+ arguments flag repeated operations; matching result content also flags repeated results. This does
280
+ not establish that files or external state were unchanged, or that a call was avoidable. No tool calls
281
+ are blocked and no output or context is reduced. Only sizes, IDs, names, and per-run keyed fingerprints
282
+ are added to traces; successful output, commands, paths in arguments, and prompts are not retained.
283
+ Fingerprint keys are ephemeral and are not saved; fingerprints are not comparable across runs.
284
+
285
+ Input amplification is cumulative input over **all observed HTTP attempts** divided by the final
286
+ agent request's input tokens. It is unavailable with incomplete usage or a zero/missing denominator.
287
+ Compaction can decrease the denominator, so the ratio is not a waste score. Final-request input also
288
+ is not the context size after the response. Successful validated runs show input + output tokens as
289
+ **tokens to validated solution**; failures and unvalidated runs are labelled separately. Cached and
290
+ reasoning token counters are details of usage, not additional tokens to add to that sum.
291
+
292
+ These are additive optional fields in result schema 2 (`RequestTrace.context`,
293
+ `RequestTrace.generatedToolCalls`, and tool fingerprints/output sizes). Old reports remain readable;
294
+ absent observations are unavailable. Individual data remains in `results.json` and the live journals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-nebius",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Nebius Token Factory provider and agentic coding benchmarks for Pi",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,68 @@
1
+ import { isRecord } from "../models.ts";
2
+ import type { RequestContext } from "./types.ts";
3
+
4
+ export function textSize(content: unknown): { bytes: number; lines: number } {
5
+ const text =
6
+ typeof content === "string"
7
+ ? content
8
+ : Array.isArray(content)
9
+ ? content
10
+ .filter(
11
+ (block) => isRecord(block) && block.type === "text" && typeof block.text === "string",
12
+ )
13
+ .map((block) => block.text)
14
+ .join("\n")
15
+ : "";
16
+ return {
17
+ bytes: Buffer.byteLength(text, "utf8"),
18
+ lines: text ? text.split("\n").length - Number(text.endsWith("\n")) : 0,
19
+ };
20
+ }
21
+
22
+ /** Counts serialized JSON bytes, not model tokens. Never retains request content. */
23
+ export function observeContext(body: unknown): RequestContext | null {
24
+ if (typeof body !== "string") return null; // Do not consume Request/stream bodies for observation.
25
+ try {
26
+ const payload: unknown = JSON.parse(body);
27
+ if (!isRecord(payload) || !Array.isArray(payload.messages)) return null;
28
+ const bytes = { system: 0, tools: 0, user: 0, assistant: 0, toolResults: 0, other: 0 };
29
+ const toolResults: RequestContext["toolResults"] = [];
30
+ for (const message of payload.messages) {
31
+ const role = isRecord(message) ? message.role : undefined;
32
+ const category =
33
+ role === "system" || role === "developer"
34
+ ? "system"
35
+ : role === "user"
36
+ ? "user"
37
+ : role === "assistant"
38
+ ? "assistant"
39
+ : role === "tool"
40
+ ? "toolResults"
41
+ : "other";
42
+ bytes[category] += Buffer.byteLength(JSON.stringify(message), "utf8");
43
+ if (isRecord(message) && role === "tool" && typeof message.tool_call_id === "string")
44
+ toolResults.push({ id: message.tool_call_id, ...textSize(message.content) });
45
+ }
46
+ if (payload.tools !== undefined)
47
+ bytes.tools = Buffer.byteLength(JSON.stringify(payload.tools), "utf8");
48
+ return {
49
+ measurement: "serialized-json-bytes",
50
+ bytes,
51
+ messageCount: payload.messages.length,
52
+ toolResults,
53
+ };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** Stable object-key order lets equivalent argument objects compare within a run. */
60
+ export function canonical(value: unknown): string {
61
+ return (
62
+ JSON.stringify(value, (_key, item) =>
63
+ isRecord(item)
64
+ ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)))
65
+ : item,
66
+ ) ?? "null"
67
+ );
68
+ }
@@ -1,6 +1,7 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac, randomBytes } from "node:crypto";
2
2
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
3
3
  import { isRecord } from "../models.ts";
4
+ import { canonical, observeContext, textSize } from "./context-observation.ts";
4
5
  import type { Observation, ReportedUsage, RequestTrace } from "./types.ts";
5
6
 
6
7
  export const hash = (text: string) => createHash("sha256").update(text).digest("hex");
@@ -51,6 +52,11 @@ export function redactor(secrets: string[]) {
51
52
  export class Instrumentation {
52
53
  readonly state = emptyObservation();
53
54
  private compacting = false;
55
+ // Per-run HMAC keys are never persisted: compare values without retaining commands or file text.
56
+ private readonly fingerprintKey = randomBytes(32);
57
+ private fingerprint(value: unknown) {
58
+ return createHmac("sha256", this.fingerprintKey).update(canonical(value)).digest("hex");
59
+ }
54
60
  private readonly update: (state: Observation) => void;
55
61
  private readonly now: () => number;
56
62
  private readonly redact: (text: string) => string;
@@ -93,8 +99,18 @@ export class Instrumentation {
93
99
  case "auto_retry_start":
94
100
  this.state.retries++;
95
101
  break;
96
- case "tool_execution_start":
102
+ case "tool_execution_start": {
103
+ const fingerprint = this.fingerprint(event.args);
104
+ const previous = this.state.tools.find(
105
+ (tool) => tool.name === event.toolName && tool.argumentsFingerprint === fingerprint,
106
+ );
107
+ const origin = this.state.requests.find((request) =>
108
+ request.generatedToolCalls?.some((tool) => tool.id === event.toolCallId),
109
+ );
97
110
  this.state.tools.push({
111
+ request: origin?.request ?? null,
112
+ argumentsFingerprint: fingerprint,
113
+ repeatedArgumentsOf: previous?.id ?? null,
98
114
  id: event.toolCallId,
99
115
  name: event.toolName,
100
116
  startedAtMs: at,
@@ -103,6 +119,7 @@ export class Instrumentation {
103
119
  error: null,
104
120
  });
105
121
  break;
122
+ }
106
123
  case "tool_execution_end": {
107
124
  const tool = this.state.tools.find((item) => item.id === event.toolCallId);
108
125
  if (tool) {
@@ -114,6 +131,11 @@ export class Instrumentation {
114
131
  case "message_end": {
115
132
  const message = event.message;
116
133
  if (message.role === "assistant") {
134
+ const request = this.state.requests.filter((item) => item.purpose === "agent").at(-1);
135
+ if (request)
136
+ request.generatedToolCalls = message.content
137
+ .filter((block) => block.type === "toolCall")
138
+ .map((block) => ({ id: block.id, name: block.name }));
117
139
  this.state.assistantMessages++;
118
140
  this.state.lastAssistantStopReason = message.stopReason;
119
141
  if (message.errorMessage)
@@ -138,6 +160,17 @@ export class Instrumentation {
138
160
  this.state.tools.push(tool);
139
161
  }
140
162
  tool.isError = message.isError;
163
+ const fingerprint = this.fingerprint(message.content);
164
+ tool.output = { ...textSize(message.content), fingerprint };
165
+ const previous = this.state.tools.find(
166
+ (item) =>
167
+ item !== tool &&
168
+ item.name === tool.name &&
169
+ item.argumentsFingerprint !== undefined &&
170
+ item.argumentsFingerprint === tool.argumentsFingerprint &&
171
+ item.output?.fingerprint === fingerprint,
172
+ );
173
+ tool.repeatedOutputOf = previous?.id ?? null;
141
174
  if (message.isError)
142
175
  tool.error = this.redact(
143
176
  message.content
@@ -160,6 +193,8 @@ export class Instrumentation {
160
193
  if (!url.pathname.endsWith("/chat/completions")) return fetcher(input, init);
161
194
  const trace: RequestTrace = {
162
195
  request: this.state.requests.length + 1,
196
+ context: observeContext(init?.body),
197
+ generatedToolCalls: [],
163
198
  purpose: this.compacting ? "compaction" : "agent",
164
199
  startedAtMs: this.now(),
165
200
  endedAtMs: null,
@@ -1,4 +1,5 @@
1
1
  import { distribution } from "./metrics.ts";
2
+ import { requestReport } from "./request-report.ts";
2
3
  import type { Results, RunResult } from "./types.ts";
3
4
 
4
5
  /** Client-observed first generated content on the first agent request, not worker startup. */
@@ -106,6 +107,13 @@ export function terminalReport(results: Results): string {
106
107
  ]
107
108
  : []),
108
109
  "",
110
+ ...results.runs.flatMap(requestReport),
111
+ "",
112
+ "Request tokens are provider-reported per HTTP attempt, including retries and compaction when observed.",
113
+ "Δ input is the change from the preceding request, not tokens attributable to its tools.",
114
+ "Context sizes are measured UTF-8 JSON bytes including message wrappers; category token counts are unavailable.",
115
+ "Tool text sizes exclude images and may already be truncated by Pi. Commands, arguments, and successful output text are not stored.",
116
+ "Input amplification = cumulative input / last agent request input; not a waste score or final context size.",
109
117
  "Detailed per-run measurements and validator output: results.json.",
110
118
  ].join("\n");
111
119
  }
@@ -0,0 +1,130 @@
1
+ import type { RunResult } from "./types.ts";
2
+
3
+ const number = (value: number | null | undefined) =>
4
+ value == null ? "n/a" : value.toLocaleString("en-US");
5
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: remove terminal control characters from labels
6
+ const label = (value: string) => value.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 80);
7
+ const table = (rows: string[][]) => {
8
+ const widths =
9
+ rows[0]?.map((_, index) => Math.max(...rows.map((row) => row[index]?.length ?? 0))) ?? [];
10
+ return rows.map((row) =>
11
+ row
12
+ .map((cell, index) => cell.padEnd(widths[index] ?? 0))
13
+ .join(" │ ")
14
+ .trimEnd(),
15
+ );
16
+ };
17
+
18
+ export function requestReport(run: RunResult): string[] {
19
+ const requests = run.observation.requests;
20
+ const tools = run.observation.tools ?? [];
21
+ const rows = [["Request", "Kind", "Input", "Δ input", "Output", "Cached", "Tools produced"]];
22
+ const sizes = [
23
+ ["Request", "System B", "Schemas B", "User B", "Assistant B", "Tool results B", "Other B"],
24
+ ];
25
+ for (const [index, request] of requests.entries()) {
26
+ const input = request.usage?.inputTokens;
27
+ const previous = requests[index - 1]?.usage?.inputTokens;
28
+ const delta = input != null && previous != null ? input - previous : null;
29
+ rows.push([
30
+ String(request.request ?? index + 1),
31
+ request.purpose,
32
+ number(input),
33
+ delta !== null && delta > 0 ? `+${number(delta)}` : number(delta),
34
+ number(request.usage?.outputTokens),
35
+ number(request.usage?.cachedInputTokens),
36
+ request.generatedToolCalls?.length
37
+ ? request.generatedToolCalls.map((tool) => label(tool.name)).join(", ")
38
+ : "—",
39
+ ]);
40
+ const bytes = request.context?.bytes;
41
+ sizes.push([
42
+ String(request.request ?? index + 1),
43
+ ...[
44
+ bytes?.system,
45
+ bytes?.tools,
46
+ bytes?.user,
47
+ bytes?.assistant,
48
+ bytes?.toolResults,
49
+ bytes?.other,
50
+ ].map(number),
51
+ ]);
52
+ }
53
+ const input = run.tokens.cumulativeInputTokens;
54
+ const output = run.tokens.cumulativeOutputTokens;
55
+ rows.push([
56
+ "TOTAL",
57
+ "all attempts",
58
+ number(input),
59
+ "—",
60
+ number(output),
61
+ number(run.tokens.cachedInputTokens),
62
+ "",
63
+ ]);
64
+ const last = run.tokens.lastRequestInputTokens;
65
+ const amplification = run.tokens.inputAmplificationVsLastRequest;
66
+ const total = input != null && output != null ? input + output : null;
67
+ const consumption =
68
+ run.success && run.validation.checked
69
+ ? "Tokens to validated solution"
70
+ : run.failure !== null
71
+ ? "Tokens consumed before failure"
72
+ : "Tokens consumed (correctness not checked)";
73
+ const lines = [
74
+ "",
75
+ `Request trace: ${label(run.model)} #${run.run}`,
76
+ ...table(rows),
77
+ `${consumption}: ${number(total)} (input ${number(input)} + output ${number(output)}).`,
78
+ `Last agent request input: ${number(last)}; input amplification: ${amplification == null ? "n/a" : `${amplification.toFixed(2)}x`}.`,
79
+ "",
80
+ "Context composition — measured JSON bytes (B), NOT token attribution:",
81
+ ...table(sizes),
82
+ ];
83
+ if (tools.length) {
84
+ lines.push("", "Tool results — model-facing text after Pi processing:");
85
+ const toolRows = [
86
+ [
87
+ "Tool",
88
+ "From request",
89
+ "Text bytes",
90
+ "Lines",
91
+ "Included in requests",
92
+ "Repeated arguments",
93
+ "Same result",
94
+ ],
95
+ ];
96
+ for (const [index, tool] of tools.entries()) {
97
+ const included = requests
98
+ .filter((request) => request.context?.toolResults.some((result) => result.id === tool.id))
99
+ .map((request) => request.request);
100
+ const reference = (id: string | null | undefined) => {
101
+ const at = tools.findIndex((item) => item.id === id);
102
+ return at < 0 ? "—" : `T${at + 1}`;
103
+ };
104
+ toolRows.push([
105
+ `T${index + 1} ${label(tool.name)}`,
106
+ number(tool.request),
107
+ number(tool.output?.bytes),
108
+ number(tool.output?.lines),
109
+ requests.every((request) => request.context != null)
110
+ ? included.join(", ") || "none"
111
+ : `${included.join(", ") || "none observed"} (partial)`,
112
+ reference(tool.repeatedArgumentsOf),
113
+ reference(tool.repeatedOutputOf),
114
+ ]);
115
+ }
116
+ lines.push(...table(toolRows));
117
+ const large = tools
118
+ .map((tool, index) => ({ tool, index }))
119
+ .filter(({ tool }) => (tool.output?.bytes ?? 0) >= 16384);
120
+ for (const { tool, index } of large)
121
+ lines.push(
122
+ `Observation: T${index + 1} returned ${number(tool.output?.bytes)} text bytes (large-output threshold: 16 KiB).`,
123
+ );
124
+ if (tools.some((tool) => tool.repeatedArgumentsOf))
125
+ lines.push(
126
+ "Repeated arguments/results are candidates to inspect, not proof of unnecessary work or unchanged files.",
127
+ );
128
+ }
129
+ return lines;
130
+ }
@@ -26,7 +26,22 @@ export interface ReportedUsage {
26
26
  reasoningTokens: number | null;
27
27
  totalTokens: number | null;
28
28
  }
29
+ export interface RequestContext {
30
+ measurement: "serialized-json-bytes";
31
+ bytes: {
32
+ system: number;
33
+ tools: number;
34
+ user: number;
35
+ assistant: number;
36
+ toolResults: number;
37
+ other: number;
38
+ };
39
+ messageCount: number;
40
+ toolResults: Array<{ id: string; bytes: number; lines: number }>;
41
+ }
29
42
  export interface RequestTrace {
43
+ context?: RequestContext | null;
44
+ generatedToolCalls?: Array<{ id: string; name: string }>;
30
45
  request: number;
31
46
  purpose: "agent" | "compaction";
32
47
  startedAtMs: number;
@@ -42,6 +57,11 @@ export interface RequestTrace {
42
57
  streamComplete: boolean;
43
58
  }
44
59
  export interface ToolTrace {
60
+ request?: number | null;
61
+ argumentsFingerprint?: string;
62
+ repeatedArgumentsOf?: string | null;
63
+ output?: { bytes: number; lines: number; fingerprint: string };
64
+ repeatedOutputOf?: string | null;
45
65
  id: string;
46
66
  name: string;
47
67
  startedAtMs: number | null;