@oh-my-pi/pi-agent-core 18.7.0 → 18.8.1

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
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.1] - 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - Added the public `validateAgentToolArguments()` API for consistent, lenient-aware validation of agent tool arguments across agent and coding-agent workflows.
10
+
11
+ ## [18.8.0] - 2026-10-07
12
+
13
+ ### Changed
14
+
15
+ - Improved performance when pruning tool output and capturing telemetry messages during long-running sessions.
16
+
5
17
  ## [18.7.0] - 2026-10-06
6
18
 
7
19
  ### Fixed
@@ -127,10 +127,11 @@ export declare const MIN_PRUNE_TOKENS = 50;
127
127
  * candidate is pruned now only when the suffix after it is small (tail case —
128
128
  * the read→edit→read loop) or when the context has been idle long enough that
129
129
  * the provider cache is cold anyway (then all still-sent candidates flush).
130
- * Never mutates entries before `keepBoundaryId` (summarized away — not sent).
130
+ * Never mutates entries before `keepBoundaryId` (summarized away — not sent),
131
+ * and never walks them unless a sent result's call lives there.
131
132
  */
132
- export declare function pruneSupersededToolResults(entries: SessionEntry[], tokenizer: Tokenizer, config: SupersedePruneConfig): PruneResult;
133
- export declare function pruneToolOutputs(entries: SessionEntry[], tokenizer: Tokenizer, config?: PruneConfig): PruneResult;
133
+ export declare function pruneSupersededToolResults(entries: readonly SessionEntry[], tokenizer: Tokenizer, config: SupersedePruneConfig): PruneResult;
134
+ export declare function pruneToolOutputs(entries: readonly SessionEntry[], tokenizer: Tokenizer, config?: PruneConfig): PruneResult;
134
135
  /**
135
136
  * Supersede key for the `read` tool: the file path with the trailing line/raw
136
137
  * selector stripped (the read tool's own splitter grammar via
@@ -6,7 +6,8 @@ export interface ProtectedToolContext {
6
6
  readonly toolCall: AgentToolCall | undefined;
7
7
  }
8
8
  export type ProtectedToolMatcher = string | ((context: ProtectedToolContext) => boolean);
9
- export declare function collectToolCallsById(entries: readonly SessionEntry[]): Map<string, AgentToolCall>;
9
+ /** Map tool-call ids to their calls across `entries[start, end)` (later calls win). */
10
+ export declare function collectToolCallsById(entries: readonly SessionEntry[], start?: number, end?: number): Map<string, AgentToolCall>;
10
11
  /** Return the tool-result message carried by a session entry, if any. */
11
12
  export declare function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined;
12
13
  /**
@@ -13,5 +13,6 @@ export * from "./telemetry.js";
13
13
  export * from "./thinking.js";
14
14
  export * from "./tool-context.js";
15
15
  export * from "./tokenizer.js";
16
+ export * from "./tool-arguments.js";
16
17
  export * from "./types.js";
17
18
  export * from "./utils/yield.js";
@@ -1,4 +1,4 @@
1
- import { type AssistantMessage } from "@oh-my-pi/pi-ai";
1
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
2
  import type { AgentContext, AgentLoopConfig, AgentTool, AgentToolCall, AgentToolResult, SpeculativeAuthorization, SpeculativeChildDefinition, SpeculativeChildHandle, SpeculativeLaunchContext, SpeculativeToolExecutionConfig, ToolSpeculationEffect, ToolSpeculationStreamSession } from "./types.js";
3
3
  export type SpeculativeRawOutcome = {
4
4
  result: AgentToolResult<unknown>;
@@ -0,0 +1,19 @@
1
+ import { type ToolCall } from "@oh-my-pi/pi-ai";
2
+ import type { AgentTool } from "./types.js";
3
+ /**
4
+ * Where a call's `__parseError`/`__rawJson` keys came from.
5
+ * - `"parsed"`: a provider stream parsed the arguments, so `__parseError` is a
6
+ * genuine parse-failure sentinel and is never handed to a tool.
7
+ * - `"payload"`: the arguments are a payload the model wrote (eval tool bridge,
8
+ * `xd://` device content), so those keys are forged and only stripped.
9
+ */
10
+ export type ToolArgumentSource = "parsed" | "payload";
11
+ /**
12
+ * Validate a call's arguments against `tool`'s schema, honoring
13
+ * {@link AgentTool.lenientArgValidation}: on a schema mismatch a lenient tool
14
+ * receives its raw arguments minus the internal `__parseError`/`__rawJson`
15
+ * sentinels. Malformed JSON from a provider stream is never lenient.
16
+ *
17
+ * @throws the validation error when the tool is strict or the arguments failed to parse.
18
+ */
19
+ export declare function validateAgentToolArguments(tool: AgentTool, toolCall: ToolCall, source?: ToolArgumentSource): ToolCall["arguments"];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "18.7.0",
4
+ "version": "18.8.1",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,16 +38,16 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.7.0",
42
- "@oh-my-pi/pi-catalog": "18.7.0",
43
- "@oh-my-pi/pi-natives": "18.7.0",
44
- "@oh-my-pi/pi-utils": "18.7.0",
45
- "@oh-my-pi/pi-wire": "18.7.0",
46
- "@oh-my-pi/snapcompact": "18.7.0",
41
+ "@oh-my-pi/pi-ai": "18.8.1",
42
+ "@oh-my-pi/pi-catalog": "18.8.1",
43
+ "@oh-my-pi/pi-natives": "18.8.1",
44
+ "@oh-my-pi/pi-utils": "18.8.1",
45
+ "@oh-my-pi/pi-wire": "18.8.1",
46
+ "@oh-my-pi/snapcompact": "18.8.1",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.7.0",
50
+ "@oh-my-pi/omptype": "18.8.1",
51
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -23,7 +23,6 @@ import {
23
23
  type TSchema,
24
24
  toolWireSchema,
25
25
  type UserMessage,
26
- validateToolArguments,
27
26
  } from "@oh-my-pi/pi-ai";
28
27
  import {
29
28
  type Dialect,
@@ -76,6 +75,7 @@ import {
76
75
  startExecuteToolSpan,
77
76
  startInvokeAgentSpan,
78
77
  } from "./telemetry";
78
+ import { validateAgentToolArguments } from "./tool-arguments";
79
79
  import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
80
80
  import dsmlToolCallLeakPrompt from "./prompts/dsml-tool-call-leak.md" with { type: "text" };
81
81
  import type {
@@ -3062,15 +3062,9 @@ async function prepareToolCallDispatch(
3062
3062
  if (!tool) {
3063
3063
  throw new Error(formatToolNotFoundMessage(toolCall.name, context.tools, suggestFallbackToolNames?.()));
3064
3064
  }
3065
- return validateToolArguments(tool, { ...toolCall, arguments: args });
3065
+ return validateAgentToolArguments(tool, { ...toolCall, arguments: args });
3066
3066
  } catch (validationError) {
3067
- // Lenience covers schema mismatches; a parse failure has no args to hand over.
3068
3067
  const parseFailed = "__parseError" in args;
3069
- if (tool?.lenientArgValidation && !parseFailed) {
3070
- const fallback = { ...args };
3071
- delete fallback.__rawJson;
3072
- return fallback;
3073
- }
3074
3068
  entry.args = parseFailed ? { __parseError: args.__parseError } : args;
3075
3069
  entry.validationErrorMessage =
3076
3070
  validationError instanceof Error ? validationError.message : String(validationError);
@@ -179,24 +179,6 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
179
179
  return Math.max(0, tokens - noticeTokens);
180
180
  }
181
181
 
182
- /**
183
- * For each entry index, the estimated token total of all *message* entries
184
- * strictly after it — how much prompt-cache content the provider must re-write
185
- * (cacheWrite premium) if that entry is mutated in place. Used to keep prune
186
- * mutations inside the cheap-to-recache tail.
187
- */
188
- function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer: Tokenizer): number[] {
189
- // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
190
- const suffix = new Array<number>(entries.length);
191
- let accumulated = 0;
192
- for (let i = entries.length - 1; i >= 0; i--) {
193
- suffix[i] = accumulated;
194
- const entry = entries[i];
195
- if (entry.type === "message") accumulated += tokenizer.countMessage(entry.message as AgentMessage);
196
- }
197
- return suffix;
198
- }
199
-
200
182
  /**
201
183
  * Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries
202
184
  * before this index are summarized away by the latest compaction and never sent,
@@ -205,8 +187,36 @@ function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer:
205
187
  */
206
188
  function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number {
207
189
  if (keepBoundaryId === undefined) return 0;
208
- const index = entries.findIndex(entry => entry.id === keepBoundaryId);
209
- return index < 0 ? 0 : index;
190
+ for (let i = entries.length - 1; i >= 0; i--) {
191
+ if (entries[i].id === keepBoundaryId) return i;
192
+ }
193
+ return 0;
194
+ }
195
+
196
+ /**
197
+ * Tool-call lookup for results in the sent region `entries[start, end)`.
198
+ * Calls there are indexed eagerly; the summarized-away prefix is indexed only
199
+ * on the first miss, so lookups match a whole-branch scan while the common
200
+ * case never walks history the boundary already excludes.
201
+ */
202
+ class SentToolCalls {
203
+ readonly #entries: readonly SessionEntry[];
204
+ readonly #start: number;
205
+ readonly #sent: Map<string, AgentToolCall>;
206
+ #prefix: Map<string, AgentToolCall> | undefined;
207
+
208
+ constructor(entries: readonly SessionEntry[], start: number) {
209
+ this.#entries = entries;
210
+ this.#start = start;
211
+ this.#sent = collectToolCallsById(entries, start);
212
+ }
213
+
214
+ get(id: string): AgentToolCall | undefined {
215
+ const call = this.#sent.get(id);
216
+ if (call !== undefined || this.#start === 0) return call;
217
+ this.#prefix ??= collectToolCallsById(this.#entries, 0, this.#start);
218
+ return this.#prefix.get(id);
219
+ }
210
220
  }
211
221
 
212
222
  interface SupersedeCandidate {
@@ -240,19 +250,22 @@ interface NewerResults {
240
250
  */
241
251
  function collectSupersededResults(
242
252
  entries: readonly SessionEntry[],
253
+ start: number,
243
254
  tokenizer: Tokenizer,
244
- toolCallsById: ReadonlyMap<string, AgentToolCall>,
255
+ toolCalls: SentToolCalls,
245
256
  supersedeKey: SupersedeKeyFn,
246
257
  supersedeComplete: SupersedeCompleteFn | undefined,
247
258
  protectedTools: readonly ProtectedToolMatcher[],
248
259
  ): SupersedeCandidate[] {
260
+ // Walk newest → oldest: supersession only depends on NEWER results, so
261
+ // stopping at `start` yields exactly the full scan's candidates at/after it.
249
262
  const candidates: SupersedeCandidate[] = [];
250
263
  const newerByKey = new Map<string, NewerResults>();
251
- for (let i = entries.length - 1; i >= 0; i--) {
264
+ for (let i = entries.length - 1; i >= start; i--) {
252
265
  const entry = entries[i];
253
266
  const message = getToolResultMessage(entry);
254
267
  if (!message || message.prunedAt !== undefined) continue;
255
- const toolCall = toolCallsById.get(message.toolCallId);
268
+ const toolCall = toolCalls.get(message.toolCallId);
256
269
  if (!toolCall) continue;
257
270
  if (isProtectedToolResult(message, toolCall, protectedTools)) continue;
258
271
  const key = supersedeKey(toolCall.name, toolCall.arguments as Record<string, unknown>);
@@ -289,18 +302,19 @@ function collectSupersededResults(
289
302
  */
290
303
  function collectUselessResults(
291
304
  entries: readonly SessionEntry[],
305
+ start: number,
292
306
  tokenizer: Tokenizer,
293
- toolCallsById: ReadonlyMap<string, AgentToolCall>,
307
+ toolCalls: SentToolCalls,
294
308
  protectedTools: readonly ProtectedToolMatcher[],
295
309
  exclude: ReadonlySet<ToolResultMessage>,
296
310
  ): SupersedeCandidate[] {
297
311
  const candidates: SupersedeCandidate[] = [];
298
- for (let i = 0; i < entries.length; i++) {
312
+ for (let i = start; i < entries.length; i++) {
299
313
  const entry = entries[i];
300
314
  const message = getToolResultMessage(entry);
301
315
  if (message?.useless !== true || message.prunedAt !== undefined || message.isError === true) continue;
302
316
  if (exclude.has(message)) continue;
303
- if (isProtectedToolResult(message, toolCallsById.get(message.toolCallId), protectedTools)) continue;
317
+ if (isProtectedToolResult(message, toolCalls.get(message.toolCallId), protectedTools)) continue;
304
318
  const tokens = tokenizer.countMessage(message as AgentMessage);
305
319
  if (estimatePrunedSavings(tokens, USELESS_NOTICE) <= 0) continue;
306
320
  candidates.push({ entry: entry as SessionMessageEntry, message, index: i, tokens, notice: USELESS_NOTICE });
@@ -315,19 +329,22 @@ function collectUselessResults(
315
329
  * candidate is pruned now only when the suffix after it is small (tail case —
316
330
  * the read→edit→read loop) or when the context has been idle long enough that
317
331
  * the provider cache is cold anyway (then all still-sent candidates flush).
318
- * Never mutates entries before `keepBoundaryId` (summarized away — not sent).
332
+ * Never mutates entries before `keepBoundaryId` (summarized away — not sent),
333
+ * and never walks them unless a sent result's call lives there.
319
334
  */
320
335
  export function pruneSupersededToolResults(
321
- entries: SessionEntry[],
336
+ entries: readonly SessionEntry[],
322
337
  tokenizer: Tokenizer,
323
338
  config: SupersedePruneConfig,
324
339
  ): PruneResult {
325
- const toolCallsById = collectToolCallsById(entries);
340
+ const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
341
+ const toolCalls = new SentToolCalls(entries, boundaryIndex);
326
342
  const candidates = config.supersedeKey
327
343
  ? collectSupersededResults(
328
344
  entries,
345
+ boundaryIndex,
329
346
  tokenizer,
330
- toolCallsById,
347
+ toolCalls,
331
348
  config.supersedeKey,
332
349
  config.supersedeComplete,
333
350
  config.protectedTools,
@@ -335,7 +352,16 @@ export function pruneSupersededToolResults(
335
352
  : [];
336
353
  if (config.pruneUseless) {
337
354
  const exclude = new Set(candidates.map(candidate => candidate.message));
338
- candidates.push(...collectUselessResults(entries, tokenizer, toolCallsById, config.protectedTools, exclude));
355
+ for (const candidate of collectUselessResults(
356
+ entries,
357
+ boundaryIndex,
358
+ tokenizer,
359
+ toolCalls,
360
+ config.protectedTools,
361
+ exclude,
362
+ )) {
363
+ candidates.push(candidate);
364
+ }
339
365
  candidates.sort((a, b) => a.index - b.index);
340
366
  }
341
367
  if (candidates.length === 0) return NOTHING_PRUNED;
@@ -352,38 +378,40 @@ export function pruneSupersededToolResults(
352
378
  const idle =
353
379
  lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS);
354
380
 
355
- const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
356
-
357
381
  let toPrune: SupersedeCandidate[];
358
382
  if (idle) {
359
383
  // Provider cache is cold (idle exceeds the retention TTL), so re-writing
360
- // the sent region costs nothing. Entries before the compaction boundary
361
- // are summarized away and never sent — skip them to avoid pointless churn.
362
- toPrune = candidates.filter(candidate => candidate.index >= boundaryIndex);
384
+ // the sent region costs nothing. Candidates already start at the
385
+ // compaction boundary (summarized-away entries are never sent).
386
+ toPrune = candidates;
363
387
  } else {
388
+ // Mutating a candidate re-writes its suffix (tokens of every message
389
+ // strictly after it) in the warm cache, so prune only when that suffix is
390
+ // small. The suffix only grows walking back, so stop at the first index
391
+ // past the limit instead of measuring the whole branch.
364
392
  const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
365
- // suffixTokens[i] = estimated tokens of all messages strictly after entry i.
366
- // Mutating a candidate re-writes its suffix in the warm cache, so prune only
367
- // when that suffix is small (cheap-to-recache tail) and the candidate sits
368
- // at/after the compaction boundary.
369
- const suffixTokens = computeMessageSuffixTokens(entries, tokenizer);
370
- toPrune = candidates.filter(
371
- candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
372
- );
393
+ toPrune = [];
394
+ let suffixTokens = 0;
395
+ let next = candidates.length - 1;
396
+ for (let i = entries.length - 1; next >= 0 && suffixTokens <= suffixTokenLimit; i--) {
397
+ while (next >= 0 && candidates[next].index === i) toPrune.push(candidates[next--]);
398
+ const entry = entries[i];
399
+ if (entry.type === "message") suffixTokens += tokenizer.countMessage(entry.message as AgentMessage);
400
+ }
401
+ toPrune.reverse();
373
402
  }
374
403
  if (toPrune.length === 0) return NOTHING_PRUNED;
375
404
 
376
- const prunedAt = Date.now();
377
405
  let tokensSaved = 0;
378
- const steps = toPrune.map(candidate => {
379
- tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
380
- return blankToolResult(candidate.message, candidate.notice, prunedAt);
381
- });
406
+ for (const candidate of toPrune) tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
407
+
408
+ const prunedAt = Date.now();
409
+ const steps = toPrune.map(candidate => blankToolResult(candidate.message, candidate.notice, prunedAt));
382
410
  return { prunedCount: toPrune.length, tokensSaved, undo: undoAll(steps) };
383
411
  }
384
412
 
385
413
  export function pruneToolOutputs(
386
- entries: SessionEntry[],
414
+ entries: readonly SessionEntry[],
387
415
  tokenizer: Tokenizer,
388
416
  config: PruneConfig = DEFAULT_PRUNE_CONFIG,
389
417
  ): PruneResult {
@@ -391,13 +419,17 @@ export function pruneToolOutputs(
391
419
  let tokensSaved = 0;
392
420
 
393
421
  const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean; useless: boolean }> = [];
394
- const toolCallsById = collectToolCallsById(entries);
422
+ // Entries before the compaction boundary are summarized away (never sent)
423
+ // and are never prune candidates, so no pass below walks them.
424
+ const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
425
+ const toolCalls = new SentToolCalls(entries, boundaryIndex);
395
426
  const supersededMessages = config.supersedeKey
396
427
  ? new Set(
397
428
  collectSupersededResults(
398
429
  entries,
430
+ boundaryIndex,
399
431
  tokenizer,
400
- toolCallsById,
432
+ toolCalls,
401
433
  config.supersedeKey,
402
434
  config.supersedeComplete,
403
435
  config.protectedTools,
@@ -409,42 +441,41 @@ export function pruneToolOutputs(
409
441
  ? new Set(
410
442
  collectUselessResults(
411
443
  entries,
444
+ boundaryIndex,
412
445
  tokenizer,
413
- toolCallsById,
446
+ toolCalls,
414
447
  config.protectedTools,
415
448
  supersededMessages ?? new Set(),
416
449
  ).map(candidate => candidate.message),
417
450
  )
418
451
  : undefined;
419
452
 
420
- const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
421
453
  const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
422
- // All-message suffix per index, only when the cache guard is armed.
423
- const messageSuffix =
424
- cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries, tokenizer);
454
+ // Tokens of every message strictly after entry `i` (cache guard only).
455
+ let messageSuffix = 0;
425
456
 
426
- for (let i = entries.length - 1; i >= 0; i--) {
457
+ for (let i = entries.length - 1; i >= boundaryIndex; i--) {
427
458
  const entry = entries[i];
459
+ const suffixAfter = messageSuffix;
428
460
  const message = getToolResultMessage(entry);
429
- if (!message) continue;
430
-
431
- const tokens = tokenizer.countMessage(message as AgentMessage);
432
- const isProtected = isProtectedToolResult(message, toolCallsById.get(message.toolCallId), config.protectedTools);
433
-
434
- if (message.prunedAt !== undefined) {
435
- accumulatedTokens += tokens;
461
+ if (!message) {
462
+ if (cacheWarmSuffixTokens !== undefined && entry.type === "message") {
463
+ messageSuffix += tokenizer.countMessage(entry.message as AgentMessage);
464
+ }
436
465
  continue;
437
466
  }
438
467
 
468
+ const tokens = tokenizer.countMessage(message as AgentMessage);
469
+ messageSuffix += tokens;
470
+
439
471
  // Prompt-cache guard: a result whose all-message suffix exceeds the
440
472
  // warm-cache window sits in the already-sent cached prefix — mutating it
441
- // re-writes the whole suffix (cacheWrite premium). Entries before the
442
- // compaction boundary are summarized away (never sent). Both are skipped
443
- // before any prune decision, so superseded/useless cannot reach a deep,
444
- // still-cached copy; compaction/shake reclaim those when they rebuild.
445
- const inWarmPrefix =
446
- messageSuffix !== undefined && cacheWarmSuffixTokens !== undefined && messageSuffix[i] > cacheWarmSuffixTokens;
447
- if (inWarmPrefix || i < boundaryIndex) {
473
+ // re-writes the whole suffix (cacheWrite premium). The suffix only grows
474
+ // walking back, so every older result is in the warm prefix too. Deeper,
475
+ // still-cached superseded/useless copies are left for compaction/shake.
476
+ if (cacheWarmSuffixTokens !== undefined && suffixAfter > cacheWarmSuffixTokens) break;
477
+
478
+ if (message.prunedAt !== undefined) {
448
479
  accumulatedTokens += tokens;
449
480
  continue;
450
481
  }
@@ -456,7 +487,13 @@ export function pruneToolOutputs(
456
487
  const superseded = supersededMessages?.has(message) ?? false;
457
488
  const useless = uselessMessages?.has(message) ?? false;
458
489
  const tooSmall = tokens < MIN_PRUNE_TOKENS;
459
- if (!superseded && !useless && (accumulatedTokens < config.protectTokens || isProtected || tooSmall)) {
490
+ if (
491
+ !superseded &&
492
+ !useless &&
493
+ (accumulatedTokens < config.protectTokens ||
494
+ tooSmall ||
495
+ isProtectedToolResult(message, toolCalls.get(message.toolCallId), config.protectedTools))
496
+ ) {
460
497
  accumulatedTokens += tokens;
461
498
  continue;
462
499
  }
@@ -11,9 +11,15 @@ export type ProtectedToolMatcher = string | ((context: ProtectedToolContext) =>
11
11
 
12
12
  const SKILL_INTERNAL_URL_PREFIX = "skill://";
13
13
 
14
- export function collectToolCallsById(entries: readonly SessionEntry[]): Map<string, AgentToolCall> {
14
+ /** Map tool-call ids to their calls across `entries[start, end)` (later calls win). */
15
+ export function collectToolCallsById(
16
+ entries: readonly SessionEntry[],
17
+ start = 0,
18
+ end = entries.length,
19
+ ): Map<string, AgentToolCall> {
15
20
  const toolCalls = new Map<string, AgentToolCall>();
16
- for (const entry of entries) {
21
+ for (let i = start; i < end; i++) {
22
+ const entry = entries[i];
17
23
  if (entry.type !== "message") continue;
18
24
  const message = entry.message;
19
25
  if (message.role !== "assistant") continue;
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@ export * from "./thinking";
28
28
  export * from "./tool-context";
29
29
  // Tokenizer choice
30
30
  export * from "./tokenizer";
31
+ // Lenient-aware tool argument validation
32
+ export * from "./tool-arguments";
31
33
  // Types
32
34
  export * from "./types";
33
35
  // Yield utilities for Bun event-loop busy-wait prevention
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
- import { type AssistantMessage, validateToolArguments } from "@oh-my-pi/pi-ai";
2
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
+ import { validateAgentToolArguments } from "./tool-arguments";
3
4
  import type {
4
5
  AgentContext,
5
6
  AgentLoopConfig,
@@ -712,15 +713,10 @@ export class SpeculativeOperationCoordinator {
712
713
  }
713
714
  let validatedArgs: Record<string, unknown>;
714
715
  try {
715
- validatedArgs = validateToolArguments(tool, toolCall);
716
+ validatedArgs = validateAgentToolArguments(tool, toolCall);
716
717
  } catch {
717
- // Lenience covers schema mismatches; a parse failure has no args to hand over.
718
- if (!tool.lenientArgValidation || "__parseError" in toolCall.arguments) {
719
- this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
720
- return undefined;
721
- }
722
- validatedArgs = { ...toolCall.arguments };
723
- delete validatedArgs.__rawJson;
718
+ this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
719
+ return undefined;
724
720
  }
725
721
  let executionArgs: Record<string, unknown>;
726
722
  try {
package/src/telemetry.ts CHANGED
@@ -836,15 +836,27 @@ function serializeRequestMessagesForTelemetry(
836
836
  ): string | undefined {
837
837
  const serializer = telemetry.config.contentSerializer?.requestMessages;
838
838
  if (serializer) return callContentSerializer(telemetry, "requestMessages", () => serializer(request));
839
+ const systemParts = normalizeSystemPromptParts(request.systemPrompt);
840
+ const requestMessages = request.messages ?? [];
841
+ const total = systemParts.length + requestMessages.length;
842
+ if (total === 0) return undefined;
843
+ // Summarize only what survives the message cap; the rest is counted.
839
844
  const messages: TelemetryMessageSummary[] = [];
840
- for (const text of normalizeSystemPromptParts(request.systemPrompt))
845
+ for (const text of systemParts) {
846
+ if (messages.length === MAX_TELEMETRY_MESSAGE_COUNT) break;
841
847
  messages.push({ role: "system", content: summarizeTelemetryValue(text) });
842
- if (request.messages) {
843
- for (const message of request.messages) {
844
- messages.push({ role: message.role, content: summarizeTelemetryValue(message.content) });
845
- }
846
848
  }
847
- return messages.length === 0 ? undefined : stringifyJsonAttribute(limitTelemetryMessages(messages));
849
+ for (const message of requestMessages) {
850
+ if (messages.length === MAX_TELEMETRY_MESSAGE_COUNT) break;
851
+ messages.push({ role: message.role, content: summarizeTelemetryValue(message.content) });
852
+ }
853
+ if (total > MAX_TELEMETRY_MESSAGE_COUNT) {
854
+ messages.push({
855
+ role: "system",
856
+ content: { kind: "truncated", omittedMessages: total - MAX_TELEMETRY_MESSAGE_COUNT },
857
+ });
858
+ }
859
+ return stringifyJsonAttribute(messages);
848
860
  }
849
861
 
850
862
  function serializeResponseTextForTelemetry(telemetry: AgentTelemetry, message: AssistantMessage): string | undefined {
@@ -1022,17 +1034,6 @@ function callContentSerializer(
1022
1034
  }
1023
1035
  }
1024
1036
 
1025
- function limitTelemetryMessages(messages: readonly TelemetryMessageSummary[]): TelemetryMessageSummary[] {
1026
- const limited = messages.slice(0, MAX_TELEMETRY_MESSAGE_COUNT);
1027
- if (messages.length > MAX_TELEMETRY_MESSAGE_COUNT) {
1028
- limited.push({
1029
- role: "system",
1030
- content: { kind: "truncated", omittedMessages: messages.length - MAX_TELEMETRY_MESSAGE_COUNT },
1031
- });
1032
- }
1033
- return limited;
1034
- }
1035
-
1036
1037
  function limitTelemetryToolCalls(toolCalls: readonly TelemetryToolCallSummary[]): TelemetryToolCallSummary[] {
1037
1038
  const limited = toolCalls.slice(0, MAX_TELEMETRY_ARRAY_ITEMS);
1038
1039
  if (toolCalls.length > MAX_TELEMETRY_ARRAY_ITEMS) {
@@ -0,0 +1,38 @@
1
+ import { type ToolCall, validateToolArguments } from "@oh-my-pi/pi-ai";
2
+ import type { AgentTool } from "./types";
3
+
4
+ /**
5
+ * Where a call's `__parseError`/`__rawJson` keys came from.
6
+ * - `"parsed"`: a provider stream parsed the arguments, so `__parseError` is a
7
+ * genuine parse-failure sentinel and is never handed to a tool.
8
+ * - `"payload"`: the arguments are a payload the model wrote (eval tool bridge,
9
+ * `xd://` device content), so those keys are forged and only stripped.
10
+ */
11
+ export type ToolArgumentSource = "parsed" | "payload";
12
+
13
+ /**
14
+ * Validate a call's arguments against `tool`'s schema, honoring
15
+ * {@link AgentTool.lenientArgValidation}: on a schema mismatch a lenient tool
16
+ * receives its raw arguments minus the internal `__parseError`/`__rawJson`
17
+ * sentinels. Malformed JSON from a provider stream is never lenient.
18
+ *
19
+ * @throws the validation error when the tool is strict or the arguments failed to parse.
20
+ */
21
+ export function validateAgentToolArguments(
22
+ tool: AgentTool,
23
+ toolCall: ToolCall,
24
+ source: ToolArgumentSource = "parsed",
25
+ ): ToolCall["arguments"] {
26
+ try {
27
+ return validateToolArguments(tool, toolCall);
28
+ } catch (error) {
29
+ const args: unknown = toolCall.arguments;
30
+ if (!tool.lenientArgValidation) throw error;
31
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return args as ToolCall["arguments"];
32
+ if (source === "parsed" && "__parseError" in args) throw error;
33
+ const fallback = { ...(args as Record<string, unknown>) };
34
+ delete fallback.__parseError;
35
+ delete fallback.__rawJson;
36
+ return fallback;
37
+ }
38
+ }