@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 +12 -0
- package/dist/types/compaction/pruning.d.ts +4 -3
- package/dist/types/compaction/tool-protection.d.ts +2 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speculative-execution.d.ts +1 -1
- package/dist/types/tool-arguments.d.ts +19 -0
- package/package.json +8 -8
- package/src/agent-loop.ts +2 -8
- package/src/compaction/pruning.ts +110 -73
- package/src/compaction/tool-protection.ts +8 -2
- package/src/index.ts +2 -0
- package/src/speculative-execution.ts +5 -9
- package/src/telemetry.ts +18 -17
- package/src/tool-arguments.ts +38 -0
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
|
-
|
|
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
|
/**
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.
|
|
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.
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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 >=
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
361
|
-
//
|
|
362
|
-
toPrune = candidates
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
423
|
-
|
|
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 >=
|
|
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)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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).
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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 =
|
|
716
|
+
validatedArgs = validateAgentToolArguments(tool, toolCall);
|
|
716
717
|
} catch {
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|