@oh-my-pi/pi-agent-core 18.5.0 → 18.5.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 +17 -0
- package/dist/types/compaction/pruning.d.ts +33 -9
- package/dist/types/compaction/utils.d.ts +5 -0
- package/dist/types/telemetry.d.ts +10 -0
- package/package.json +8 -8
- package/src/agent-loop.ts +129 -19
- package/src/compaction/pruning.ts +81 -21
- package/src/compaction/utils.ts +9 -0
- package/src/telemetry.ts +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.5.1] - 2026-10-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added provider-reported usage cost, the unnormalized provider ID, and the requested model ID to CostEstimatorContext, allowing cost estimators to use the cost already recorded for a request instead of recomputing it from token counts.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Improved tool-result supersession so path reads with selectors are tracked independently from bare-path reads, while failed results supersede only earlier failed results for the same key.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed parallel tool calls with reused or empty IDs executing with another call’s arguments or merging results; each call now executes and reports its own payload.
|
|
18
|
+
- Fixed completed tool calls being retried after a transient provider-stream read error; incomplete calls from the affected turn are now discarded.
|
|
19
|
+
- Fixed previously read code being removed from context when a subsequent read returned a summary, truncated content, or an error.
|
|
20
|
+
- Fixed intent tracing for tools that define i as a real argument, preserving the argument and its declared schema order.
|
|
21
|
+
|
|
5
22
|
## [18.4.11] - 2026-10-02
|
|
6
23
|
|
|
7
24
|
### Fixed
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool output pruning utilities for compaction.
|
|
3
3
|
*/
|
|
4
|
+
import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
4
5
|
import type { Tokenizer } from "../tokenizer.js";
|
|
5
6
|
import type { SessionEntry } from "./entries.js";
|
|
6
7
|
import { type ProtectedToolMatcher } from "./tool-protection.js";
|
|
@@ -18,6 +19,8 @@ export interface PruneConfig {
|
|
|
18
19
|
* unchanged.
|
|
19
20
|
*/
|
|
20
21
|
supersedeKey?: SupersedeKeyFn;
|
|
22
|
+
/** Whether a keyed result shows its whole target (see {@link SupersedePruneConfig.supersedeComplete}). */
|
|
23
|
+
supersedeComplete?: SupersedeCompleteFn;
|
|
21
24
|
/** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
|
|
22
25
|
pruneUseless?: boolean;
|
|
23
26
|
/**
|
|
@@ -56,15 +59,35 @@ export declare const SUPERSEDED_NOTICE = "[Superseded by a newer read of this fi
|
|
|
56
59
|
export declare const USELESS_NOTICE = "[Uneventful result elided]";
|
|
57
60
|
/**
|
|
58
61
|
* Maps a tool call to a supersede key. Results sharing a key form a group in
|
|
59
|
-
* which
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* `undefined` to exempt a call from
|
|
62
|
+
* which a newer successful result supersedes every older one. A key `K` also
|
|
63
|
+
* covers keys with prefix `K + "\u0000"`: they are superseded only when the
|
|
64
|
+
* newest successful result for `K` is complete (see
|
|
65
|
+
* {@link SupersedeCompleteFn}). Return `undefined` to exempt a call from
|
|
66
|
+
* supersede grouping.
|
|
63
67
|
*/
|
|
64
68
|
export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) => string | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Whether a successful keyed result shows its whole target, so a result for a
|
|
71
|
+
* parent key (e.g. a selector-free read) may supersede child-key results (e.g.
|
|
72
|
+
* range reads). A summary, truncated page, or notice is not complete.
|
|
73
|
+
*/
|
|
74
|
+
export type SupersedeCompleteFn = (message: ToolResultMessage) => boolean;
|
|
65
75
|
export interface SupersedePruneConfig {
|
|
66
|
-
/** Supersede key function;
|
|
76
|
+
/** Supersede key function; a newer successful result with the same key supersedes older ones (see {@link SupersedeKeyFn}). */
|
|
67
77
|
supersedeKey?: SupersedeKeyFn;
|
|
78
|
+
/**
|
|
79
|
+
* Whether a successful keyed result is complete. Absent, every successful
|
|
80
|
+
* result is complete. Child-key results are superseded only when the parent
|
|
81
|
+
* key's newest successful result is complete; same-key results follow
|
|
82
|
+
* newest-wins, since a same-key re-read that shows a different view means
|
|
83
|
+
* the target changed. A failed result supersedes only older failed results.
|
|
84
|
+
*
|
|
85
|
+
* Trade-off, by design: after `read foo.ts:50-200` → edit → a bare
|
|
86
|
+
* `read foo.ts` that returns a summary, the pre-edit range stays in context
|
|
87
|
+
* until a complete bare read or a same-range re-read. Keeping possibly stale
|
|
88
|
+
* detail is preferred over losing detail the summary does not show.
|
|
89
|
+
*/
|
|
90
|
+
supersedeComplete?: SupersedeCompleteFn;
|
|
68
91
|
/** Also prune results flagged useless by their tool. Default false. */
|
|
69
92
|
pruneUseless?: boolean;
|
|
70
93
|
/** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
|
|
@@ -113,9 +136,10 @@ export declare function pruneToolOutputs(entries: SessionEntry[], tokenizer: Tok
|
|
|
113
136
|
* selector stripped (the read tool's own splitter grammar via
|
|
114
137
|
* {@link splitReadSelector}, e.g. `src/foo.ts:50-200`, `:2-4:raw`).
|
|
115
138
|
* Internal/URL-scheme paths (`skill://…`, `https://…`) are exempt.
|
|
116
|
-
* Selector-free reads key on the bare path;
|
|
117
|
-
* `path + "\u0000" + selector`, so
|
|
118
|
-
*
|
|
119
|
-
*
|
|
139
|
+
* Selector-free reads key on the bare path; line-range reads key on
|
|
140
|
+
* `path + "\u0000" + selector`, so a complete bare-path read can supersede them.
|
|
141
|
+
* `raw` and `conflicts` reads show a different form than a plain read (e.g. raw
|
|
142
|
+
* notebook JSON vs converted notebook text), so they key on
|
|
143
|
+
* `path + "\u0001" + selector` and are superseded only by the same selector.
|
|
120
144
|
*/
|
|
121
145
|
export declare function readToolSupersedeKey(toolName: string, args: Record<string, unknown>): string | undefined;
|
|
@@ -26,6 +26,11 @@ export declare function splitReadSelector(path: string): {
|
|
|
26
26
|
* and matches its write/edit path when computing Read/Write/RW markers.
|
|
27
27
|
*/
|
|
28
28
|
export declare function stripReadSelector(path: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a selector from {@link splitReadSelector} shows the file in a form other
|
|
31
|
+
* than its plain lines: verbatim `raw` bytes or the merge `conflicts` view.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isAlternateFormReadSelector(sel: string): boolean;
|
|
29
34
|
/**
|
|
30
35
|
* Whether `path` references a `scheme://` URL (internal URI or web URL) rather
|
|
31
36
|
* than a filesystem path that belongs in the compaction `<files>` summary.
|
|
@@ -145,10 +145,18 @@ export interface ChatUsageSnapshot {
|
|
|
145
145
|
}
|
|
146
146
|
/** Context passed to the cost estimator. */
|
|
147
147
|
export interface CostEstimatorContext {
|
|
148
|
+
/** OTel provider label (`gen_ai.provider.name`). */
|
|
148
149
|
readonly provider: string;
|
|
150
|
+
/** Provider id before OTel normalization (e.g. `openai-codex` for the `openai` label). */
|
|
151
|
+
readonly providerId: string | undefined;
|
|
152
|
+
/** Model id reported on the response; a provider may report a served model instead of the requested one. */
|
|
149
153
|
readonly model: string;
|
|
154
|
+
/** Requested model id, when the caller knows it. */
|
|
155
|
+
readonly modelId: string | undefined;
|
|
150
156
|
readonly serviceTier: ServiceTier | undefined;
|
|
151
157
|
readonly usage: ChatUsageSnapshot;
|
|
158
|
+
/** Cost the provider already computed for this request; the value persisted with the message. */
|
|
159
|
+
readonly usageCost: Usage["cost"];
|
|
152
160
|
}
|
|
153
161
|
/**
|
|
154
162
|
* Cost estimator result.
|
|
@@ -390,6 +398,8 @@ export interface ChatRequestSnapshot {
|
|
|
390
398
|
*/
|
|
391
399
|
export declare function finishChatSpan(telemetry: AgentTelemetry | undefined, span: Span | undefined, message: AssistantMessage, options: {
|
|
392
400
|
readonly stepNumber: number;
|
|
401
|
+
/** Requested model id; `message.model` may name a served fallback model instead. */
|
|
402
|
+
readonly modelId?: string;
|
|
393
403
|
readonly serviceTier?: ServiceTier;
|
|
394
404
|
readonly responseHeaders?: Readonly<Record<string, string>>;
|
|
395
405
|
readonly baseUrl?: string;
|
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.5.
|
|
4
|
+
"version": "18.5.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.5.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.5.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.5.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.5.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.5.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.5.
|
|
41
|
+
"@oh-my-pi/pi-ai": "18.5.1",
|
|
42
|
+
"@oh-my-pi/pi-catalog": "18.5.1",
|
|
43
|
+
"@oh-my-pi/pi-natives": "18.5.1",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.5.1",
|
|
45
|
+
"@oh-my-pi/pi-wire": "18.5.1",
|
|
46
|
+
"@oh-my-pi/snapcompact": "18.5.1",
|
|
47
47
|
"@opentelemetry/api": "^1.9.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@oh-my-pi/omptype": "18.5.
|
|
50
|
+
"@oh-my-pi/omptype": "18.5.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
|
@@ -28,11 +28,13 @@ import {
|
|
|
28
28
|
import {
|
|
29
29
|
type Dialect,
|
|
30
30
|
encodeInbandToolHistory,
|
|
31
|
+
mintToolCallId,
|
|
31
32
|
renderInbandToolPrompt,
|
|
32
33
|
renderToolExamples,
|
|
33
34
|
wrapInbandToolStream,
|
|
34
35
|
} from "@oh-my-pi/pi-ai/dialect";
|
|
35
36
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
37
|
+
import { appendDuplicateSuffix, MAX_TOOL_CALL_ID_LENGTH } from "@oh-my-pi/pi-ai/providers/transform-messages";
|
|
36
38
|
import {
|
|
37
39
|
type CursorExecResolvedCarrier,
|
|
38
40
|
copyCursorExecResolved,
|
|
@@ -998,7 +1000,8 @@ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeT
|
|
|
998
1000
|
const injectIntent = options.injectIntent && Bun.env.PI_NO_INTENT !== "1";
|
|
999
1001
|
return tools?.map(t => {
|
|
1000
1002
|
const intentMode = resolveIntentMode(t.intent);
|
|
1001
|
-
const doInjectIntent =
|
|
1003
|
+
const doInjectIntent =
|
|
1004
|
+
injectIntent && intentMode !== "omit" && !schemaDefinesProperty(toolWireSchema(t), INTENT_FIELD);
|
|
1002
1005
|
// When the full catalog is rendered into the system prompt, ship the tool
|
|
1003
1006
|
// specs without their descriptions (top-level + nested schema annotations)
|
|
1004
1007
|
// so they are not duplicated on the wire. Strip the STABLE wire schema (the
|
|
@@ -1479,6 +1482,15 @@ async function runLoopBody(
|
|
|
1479
1482
|
}
|
|
1480
1483
|
}
|
|
1481
1484
|
if (recovered) {
|
|
1485
|
+
// Mint/repair ids before any snapshot: the recovered message skipped
|
|
1486
|
+
// the streamed prepare (the leak interruption landed first), and the
|
|
1487
|
+
// `message_start`/`message_end` copies below are what persistence and
|
|
1488
|
+
// every consumer retain. Repairing only at executeToolCalls would
|
|
1489
|
+
// leave those copies under a never-materialized id while the result
|
|
1490
|
+
// carries the minted one. The later prepare stays idempotent — the
|
|
1491
|
+
// repaired mark rides the snapshot (object spread), and the minted
|
|
1492
|
+
// ids are already claimed at run scope.
|
|
1493
|
+
ensureUniqueToolCallIds(message, toolCallIdsDispatchedUnder(currentContext));
|
|
1482
1494
|
message = snapshotAssistantMessage(message);
|
|
1483
1495
|
currentContext.messages.push(message);
|
|
1484
1496
|
stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
|
|
@@ -2031,6 +2043,7 @@ async function streamAssistantResponse(
|
|
|
2031
2043
|
const finishChat = async (message: AssistantMessage): Promise<void> => {
|
|
2032
2044
|
await finishChatSpan(telemetry, chatSpan, message, {
|
|
2033
2045
|
stepNumber: chatStepNumber,
|
|
2046
|
+
modelId: model.id,
|
|
2034
2047
|
serviceTier: effectiveServiceTier,
|
|
2035
2048
|
responseHeaders: capturedHeaders,
|
|
2036
2049
|
baseUrl: model.baseUrl,
|
|
@@ -2447,7 +2460,10 @@ async function streamAssistantResponse(
|
|
|
2447
2460
|
}
|
|
2448
2461
|
|
|
2449
2462
|
try {
|
|
2450
|
-
let trailing =
|
|
2463
|
+
let trailing = recoverTransientErrorToolTurn(
|
|
2464
|
+
retainCompletedToolCalls(await response.result(), completedToolCallIds),
|
|
2465
|
+
context.tools ?? [],
|
|
2466
|
+
);
|
|
2451
2467
|
if (harmonyMitigationEnabled) {
|
|
2452
2468
|
const detection = detectHarmonyLeakInAssistantMessage(trailing);
|
|
2453
2469
|
if (detection) {
|
|
@@ -2839,15 +2855,110 @@ function formatToolNotFoundMessage(
|
|
|
2839
2855
|
return `Tool ${name} not found. Closest available: ${suggestions.slice(0, MAX_TOOL_NAME_SUGGESTIONS).join(", ")}`;
|
|
2840
2856
|
}
|
|
2841
2857
|
|
|
2858
|
+
/**
|
|
2859
|
+
* Enforce the invariant every downstream matcher keys on: `toolCall.id` is
|
|
2860
|
+
* unique and non-empty across the whole run — not just within one assistant
|
|
2861
|
+
* message. Providers can deliver a reused id (wire length truncation, Responses
|
|
2862
|
+
* `callId|itemId` composite collapse) or an id that never materialized (`""`) —
|
|
2863
|
+
* the same occurrences the replay pipeline repairs or drops
|
|
2864
|
+
* (`deduplicateToolCallIds` / `sanitizeMalformedToolCalls`). Id-keyed per-call
|
|
2865
|
+
* state — the prepare map below, every UI and persistence matcher — would
|
|
2866
|
+
* otherwise merge sibling calls: the later call's prepared entry overwrites the
|
|
2867
|
+
* earlier's, both calls execute the sibling's payload, and colliding result ids
|
|
2868
|
+
* merge or drop one sibling's output in every consumer. Message-scope
|
|
2869
|
+
* uniqueness is not enough for the session-wide consumers
|
|
2870
|
+
* (`formatSessionHistoryMarkdown`'s last-entry-wins map, the advisor delta
|
|
2871
|
+
* split, `EventController.#toolTimelineComponents`, persistence keys): a second
|
|
2872
|
+
* assistant turn whose ids never materialized used to re-mint `call_1`,
|
|
2873
|
+
* `call_2`, … and re-derive the same `_dup` suffixes, so those consumers merged
|
|
2874
|
+
* the cross-turn collisions. Every id kept or minted here is therefore claimed
|
|
2875
|
+
* in the run-scoped registry (`toolCallIdsDispatchedUnder`) and every candidate
|
|
2876
|
+
* is generated until unused at run scope. Never-materialized is the sanitizer's
|
|
2877
|
+
* trim test (`isMalformedToolCallId`): a whitespace-only id is minted like an
|
|
2878
|
+
* empty one, never kept — it would be dropped from provider replay together
|
|
2879
|
+
* with its result, the live/replay divergence this repair exists to close.
|
|
2880
|
+
* Cursor server-resolved blocks are never re-keyed (the provider correlates
|
|
2881
|
+
* their results out-of-band under its own ids) but their ids still join the
|
|
2882
|
+
* registry so no minted candidate collides with one. Idempotent: the message is
|
|
2883
|
+
* marked repaired and re-entry is a no-op, so the Harmony path can repair
|
|
2884
|
+
* before its snapshots while the dispatch-time prepare sees the already-minted
|
|
2885
|
+
* ids and leaves them alone.
|
|
2886
|
+
*/
|
|
2887
|
+
const kToolCallIdsRepaired = Symbol("agent-loop.toolCallIdsRepaired");
|
|
2888
|
+
|
|
2889
|
+
/** Assistant message optionally carrying the {@link kToolCallIdsRepaired} mark. */
|
|
2890
|
+
type ToolCallIdsRepairedCarrier = AssistantMessage & { [kToolCallIdsRepaired]?: true };
|
|
2891
|
+
|
|
2892
|
+
/**
|
|
2893
|
+
* Run-scoped registry of every tool-call id {@link ensureUniqueToolCallIds} has
|
|
2894
|
+
* kept, minted, or seen on a server-resolved block under one
|
|
2895
|
+
* {@link AgentContext}. Keyed by the dispatch context (the same object every
|
|
2896
|
+
* `prepareToolCallDispatch` receives) so the uniqueness window spans every
|
|
2897
|
+
* assistant turn of the run rather than resetting per message. Each run gets a
|
|
2898
|
+
* fresh loop context, so minted ids use the process-unique `mintToolCallId`.
|
|
2899
|
+
*/
|
|
2900
|
+
const dispatchedToolCallIdsByContext = new WeakMap<AgentContext, Set<string>>();
|
|
2901
|
+
|
|
2902
|
+
function toolCallIdsDispatchedUnder(context: AgentContext): Set<string> {
|
|
2903
|
+
let dispatched = dispatchedToolCallIdsByContext.get(context);
|
|
2904
|
+
if (!dispatched) {
|
|
2905
|
+
dispatched = new Set();
|
|
2906
|
+
dispatchedToolCallIdsByContext.set(context, dispatched);
|
|
2907
|
+
}
|
|
2908
|
+
return dispatched;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
function ensureUniqueToolCallIds(message: ToolCallIdsRepairedCarrier, dispatchedIds: Set<string>): void {
|
|
2912
|
+
if (message[kToolCallIdsRepaired] === true) return;
|
|
2913
|
+
const content = message.content;
|
|
2914
|
+
const reserved = new Set<string>();
|
|
2915
|
+
for (const block of content) {
|
|
2916
|
+
if (block.type === "toolCall" && block.id) reserved.add(block.id);
|
|
2917
|
+
}
|
|
2918
|
+
const seen = new Set<string>();
|
|
2919
|
+
for (const block of content) {
|
|
2920
|
+
if (block.type !== "toolCall") continue;
|
|
2921
|
+
if ((block as CursorExecResolvedCarrier)[kCursorExecResolved] === true) {
|
|
2922
|
+
if (block.id?.trim()) dispatchedIds.add(block.id);
|
|
2923
|
+
continue;
|
|
2924
|
+
}
|
|
2925
|
+
if (block.id?.trim() && !seen.has(block.id) && !dispatchedIds.has(block.id)) {
|
|
2926
|
+
seen.add(block.id);
|
|
2927
|
+
dispatchedIds.add(block.id);
|
|
2928
|
+
continue;
|
|
2929
|
+
}
|
|
2930
|
+
let candidate: string;
|
|
2931
|
+
if (block.id?.trim()) {
|
|
2932
|
+
let suffix = 1;
|
|
2933
|
+
candidate = appendDuplicateSuffix(block.id, `_dup${suffix}`, MAX_TOOL_CALL_ID_LENGTH);
|
|
2934
|
+
while (seen.has(candidate) || reserved.has(candidate) || dispatchedIds.has(candidate)) {
|
|
2935
|
+
suffix += 1;
|
|
2936
|
+
candidate = appendDuplicateSuffix(block.id, `_dup${suffix}`, MAX_TOOL_CALL_ID_LENGTH);
|
|
2937
|
+
}
|
|
2938
|
+
} else {
|
|
2939
|
+
// Process-unique minter: a per-run counter would re-mint `call_1` in
|
|
2940
|
+
// every run and collide with earlier runs' calls in session history.
|
|
2941
|
+
do {
|
|
2942
|
+
candidate = mintToolCallId();
|
|
2943
|
+
} while (seen.has(candidate) || reserved.has(candidate) || dispatchedIds.has(candidate));
|
|
2944
|
+
}
|
|
2945
|
+
block.id = candidate;
|
|
2946
|
+
seen.add(candidate);
|
|
2947
|
+
dispatchedIds.add(candidate);
|
|
2948
|
+
}
|
|
2949
|
+
message[kToolCallIdsRepaired] = true;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2842
2952
|
/**
|
|
2843
2953
|
* Pre-dispatch phase for every pending tool call on `assistantMessage`, run in
|
|
2844
|
-
* call order: intent extraction, argument validation, and
|
|
2845
|
-
* hook. A hook `args` revision is revalidated against the
|
|
2846
|
-
* written back to `toolCall.arguments`; run before
|
|
2847
|
-
* (the streamed path) that makes the revision the
|
|
2848
|
-
* history, execution events, persistence, provider
|
|
2849
|
-
* scheduling, and `tool.execute` all agree.
|
|
2850
|
-
*
|
|
2954
|
+
* call order: tool-call id repair, intent extraction, argument validation, and
|
|
2955
|
+
* the `beforeToolCall` hook. A hook `args` revision is revalidated against the
|
|
2956
|
+
* tool schema and written back to `toolCall.arguments`; run before
|
|
2957
|
+
* `message_start`/`message_end` (the streamed path) that makes the revision the
|
|
2958
|
+
* single source of truth — history, execution events, persistence, provider
|
|
2959
|
+
* replay, concurrency scheduling, and `tool.execute` all agree. Id repair in
|
|
2960
|
+
* {@link ensureUniqueToolCallIds} follows the same rule. Failures are recorded
|
|
2961
|
+
* per call and surfaced by `executeToolCalls` at the record's scheduled slot.
|
|
2851
2962
|
*/
|
|
2852
2963
|
async function prepareToolCallDispatch(
|
|
2853
2964
|
assistantMessage: AssistantMessage,
|
|
@@ -2856,6 +2967,9 @@ async function prepareToolCallDispatch(
|
|
|
2856
2967
|
signal: AbortSignal | undefined,
|
|
2857
2968
|
): Promise<Map<string, PreparedToolCall>> {
|
|
2858
2969
|
const { resolveFallbackTool, suggestFallbackToolNames, intentTracing, beforeToolCall } = config;
|
|
2970
|
+
// Unique, non-empty ids first: everything below and every consumer of this
|
|
2971
|
+
// message keys per-call state on `toolCall.id`.
|
|
2972
|
+
ensureUniqueToolCallIds(assistantMessage, toolCallIdsDispatchedUnder(context));
|
|
2859
2973
|
const prepared = new Map<string, PreparedToolCall>();
|
|
2860
2974
|
for (const toolCall of assistantMessage.content) {
|
|
2861
2975
|
if (toolCall.type !== "toolCall") continue;
|
|
@@ -2872,17 +2986,13 @@ async function prepareToolCallDispatch(
|
|
|
2872
2986
|
prepared.set(toolCall.id, entry);
|
|
2873
2987
|
let argsForExecution = toolCall.arguments as Record<string, unknown>;
|
|
2874
2988
|
if (intentTracing) {
|
|
2875
|
-
|
|
2989
|
+
// A schema-owned `i` is a tool argument, never a harness intent.
|
|
2990
|
+
const ownsIntent = tool !== undefined && schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD);
|
|
2991
|
+
const { intent, strippedArgs } = ownsIntent
|
|
2992
|
+
? { intent: undefined, strippedArgs: argsForExecution }
|
|
2993
|
+
: extractIntent(toolCall.arguments);
|
|
2876
2994
|
argsForExecution = strippedArgs;
|
|
2877
|
-
|
|
2878
|
-
// args. Unknown tools fall through to the not-found error; a tool that
|
|
2879
|
-
// owns `i` as a real parameter has nowhere else to put the value.
|
|
2880
|
-
if (
|
|
2881
|
-
intent !== undefined &&
|
|
2882
|
-
intent.length > MAX_INTENT_LENGTH &&
|
|
2883
|
-
tool &&
|
|
2884
|
-
!schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD)
|
|
2885
|
-
) {
|
|
2995
|
+
if (intent !== undefined && intent.length > MAX_INTENT_LENGTH && tool) {
|
|
2886
2996
|
entry.args = strippedArgs;
|
|
2887
2997
|
entry.validationErrorMessage = `\`${INTENT_FIELD}\` is a short intent label (at most ${MAX_INTENT_LENGTH} chars); the value you sent is ${intent.length} chars. The tool was not run. Put that content in the tool's own parameters and retry with a brief \`${INTENT_FIELD}\`.`;
|
|
2888
2998
|
continue;
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
isSkillReadToolResult,
|
|
15
15
|
type ProtectedToolMatcher,
|
|
16
16
|
} from "./tool-protection";
|
|
17
|
-
import { splitReadSelector } from "./utils";
|
|
17
|
+
import { isAlternateFormReadSelector, splitReadSelector } from "./utils";
|
|
18
18
|
|
|
19
19
|
export interface PruneConfig {
|
|
20
20
|
/** Keep the most recent tool output tokens intact. */
|
|
@@ -30,6 +30,8 @@ export interface PruneConfig {
|
|
|
30
30
|
* unchanged.
|
|
31
31
|
*/
|
|
32
32
|
supersedeKey?: SupersedeKeyFn;
|
|
33
|
+
/** Whether a keyed result shows its whole target (see {@link SupersedePruneConfig.supersedeComplete}). */
|
|
34
|
+
supersedeComplete?: SupersedeCompleteFn;
|
|
33
35
|
/** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
|
|
34
36
|
pruneUseless?: boolean;
|
|
35
37
|
/**
|
|
@@ -100,16 +102,37 @@ export const USELESS_NOTICE = "[Uneventful result elided]";
|
|
|
100
102
|
|
|
101
103
|
/**
|
|
102
104
|
* Maps a tool call to a supersede key. Results sharing a key form a group in
|
|
103
|
-
* which
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* `undefined` to exempt a call from
|
|
105
|
+
* which a newer successful result supersedes every older one. A key `K` also
|
|
106
|
+
* covers keys with prefix `K + "\u0000"`: they are superseded only when the
|
|
107
|
+
* newest successful result for `K` is complete (see
|
|
108
|
+
* {@link SupersedeCompleteFn}). Return `undefined` to exempt a call from
|
|
109
|
+
* supersede grouping.
|
|
107
110
|
*/
|
|
108
111
|
export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) => string | undefined;
|
|
109
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Whether a successful keyed result shows its whole target, so a result for a
|
|
115
|
+
* parent key (e.g. a selector-free read) may supersede child-key results (e.g.
|
|
116
|
+
* range reads). A summary, truncated page, or notice is not complete.
|
|
117
|
+
*/
|
|
118
|
+
export type SupersedeCompleteFn = (message: ToolResultMessage) => boolean;
|
|
119
|
+
|
|
110
120
|
export interface SupersedePruneConfig {
|
|
111
|
-
/** Supersede key function;
|
|
121
|
+
/** Supersede key function; a newer successful result with the same key supersedes older ones (see {@link SupersedeKeyFn}). */
|
|
112
122
|
supersedeKey?: SupersedeKeyFn;
|
|
123
|
+
/**
|
|
124
|
+
* Whether a successful keyed result is complete. Absent, every successful
|
|
125
|
+
* result is complete. Child-key results are superseded only when the parent
|
|
126
|
+
* key's newest successful result is complete; same-key results follow
|
|
127
|
+
* newest-wins, since a same-key re-read that shows a different view means
|
|
128
|
+
* the target changed. A failed result supersedes only older failed results.
|
|
129
|
+
*
|
|
130
|
+
* Trade-off, by design: after `read foo.ts:50-200` → edit → a bare
|
|
131
|
+
* `read foo.ts` that returns a summary, the pre-edit range stays in context
|
|
132
|
+
* until a complete bare read or a same-range re-read. Keeping possibly stale
|
|
133
|
+
* detail is preferred over losing detail the summary does not show.
|
|
134
|
+
*/
|
|
135
|
+
supersedeComplete?: SupersedeCompleteFn;
|
|
113
136
|
/** Also prune results flagged useless by their tool. Default false. */
|
|
114
137
|
pruneUseless?: boolean;
|
|
115
138
|
/** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
|
|
@@ -196,21 +219,35 @@ interface SupersedeCandidate {
|
|
|
196
219
|
notice: string;
|
|
197
220
|
}
|
|
198
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Newer results of one key. An entry exists once any newer result (even a
|
|
224
|
+
* failed one) was seen. `complete` describes the newest successful result
|
|
225
|
+
* only: older successes of the key are superseded, so they cannot vouch for
|
|
226
|
+
* child keys.
|
|
227
|
+
*/
|
|
228
|
+
interface NewerResults {
|
|
229
|
+
success: boolean;
|
|
230
|
+
complete: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
199
233
|
/**
|
|
200
234
|
* Collect superseded tool results: for every unpruned, unprotected tool result
|
|
201
|
-
* whose paired call resolves a supersede key, a LATER result
|
|
202
|
-
*
|
|
203
|
-
*
|
|
235
|
+
* whose paired call resolves a supersede key, a LATER successful result of the
|
|
236
|
+
* same key supersedes it, as does its `"\u0000"`-prefix parent key when that
|
|
237
|
+
* key's newest successful result is complete. A failed result supersedes only
|
|
238
|
+
* older failed results; an older failed result is superseded by any later
|
|
239
|
+
* result of its key or a later successful result of its parent key.
|
|
204
240
|
*/
|
|
205
241
|
function collectSupersededResults(
|
|
206
242
|
entries: readonly SessionEntry[],
|
|
207
243
|
tokenizer: Tokenizer,
|
|
208
244
|
toolCallsById: ReadonlyMap<string, AgentToolCall>,
|
|
209
245
|
supersedeKey: SupersedeKeyFn,
|
|
246
|
+
supersedeComplete: SupersedeCompleteFn | undefined,
|
|
210
247
|
protectedTools: readonly ProtectedToolMatcher[],
|
|
211
248
|
): SupersedeCandidate[] {
|
|
212
249
|
const candidates: SupersedeCandidate[] = [];
|
|
213
|
-
const
|
|
250
|
+
const newerByKey = new Map<string, NewerResults>();
|
|
214
251
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
215
252
|
const entry = entries[i];
|
|
216
253
|
const message = getToolResultMessage(entry);
|
|
@@ -221,8 +258,17 @@ function collectSupersededResults(
|
|
|
221
258
|
const key = supersedeKey(toolCall.name, toolCall.arguments as Record<string, unknown>);
|
|
222
259
|
if (key === undefined) continue;
|
|
223
260
|
const separator = key.indexOf("\u0000");
|
|
224
|
-
const
|
|
225
|
-
|
|
261
|
+
const sameKey = newerByKey.get(key);
|
|
262
|
+
const parent = separator >= 0 ? newerByKey.get(key.slice(0, separator)) : undefined;
|
|
263
|
+
const superseded = message.isError
|
|
264
|
+
? sameKey !== undefined || parent?.success === true
|
|
265
|
+
: sameKey?.success === true || parent?.complete === true;
|
|
266
|
+
const newer = sameKey ?? { success: false, complete: false };
|
|
267
|
+
if (!message.isError && !newer.success) {
|
|
268
|
+
newer.success = true;
|
|
269
|
+
newer.complete = supersedeComplete?.(message) ?? true;
|
|
270
|
+
}
|
|
271
|
+
newerByKey.set(key, newer);
|
|
226
272
|
if (!superseded) continue;
|
|
227
273
|
candidates.push({
|
|
228
274
|
entry: entry as SessionMessageEntry,
|
|
@@ -278,7 +324,14 @@ export function pruneSupersededToolResults(
|
|
|
278
324
|
): PruneResult {
|
|
279
325
|
const toolCallsById = collectToolCallsById(entries);
|
|
280
326
|
const candidates = config.supersedeKey
|
|
281
|
-
? collectSupersededResults(
|
|
327
|
+
? collectSupersededResults(
|
|
328
|
+
entries,
|
|
329
|
+
tokenizer,
|
|
330
|
+
toolCallsById,
|
|
331
|
+
config.supersedeKey,
|
|
332
|
+
config.supersedeComplete,
|
|
333
|
+
config.protectedTools,
|
|
334
|
+
)
|
|
282
335
|
: [];
|
|
283
336
|
if (config.pruneUseless) {
|
|
284
337
|
const exclude = new Set(candidates.map(candidate => candidate.message));
|
|
@@ -341,9 +394,14 @@ export function pruneToolOutputs(
|
|
|
341
394
|
const toolCallsById = collectToolCallsById(entries);
|
|
342
395
|
const supersededMessages = config.supersedeKey
|
|
343
396
|
? new Set(
|
|
344
|
-
collectSupersededResults(
|
|
345
|
-
|
|
346
|
-
|
|
397
|
+
collectSupersededResults(
|
|
398
|
+
entries,
|
|
399
|
+
tokenizer,
|
|
400
|
+
toolCallsById,
|
|
401
|
+
config.supersedeKey,
|
|
402
|
+
config.supersedeComplete,
|
|
403
|
+
config.protectedTools,
|
|
404
|
+
).map(candidate => candidate.message),
|
|
347
405
|
)
|
|
348
406
|
: undefined;
|
|
349
407
|
const uselessMessages =
|
|
@@ -440,10 +498,11 @@ export function pruneToolOutputs(
|
|
|
440
498
|
* selector stripped (the read tool's own splitter grammar via
|
|
441
499
|
* {@link splitReadSelector}, e.g. `src/foo.ts:50-200`, `:2-4:raw`).
|
|
442
500
|
* Internal/URL-scheme paths (`skill://…`, `https://…`) are exempt.
|
|
443
|
-
* Selector-free reads key on the bare path;
|
|
444
|
-
* `path + "\u0000" + selector`, so
|
|
445
|
-
*
|
|
446
|
-
*
|
|
501
|
+
* Selector-free reads key on the bare path; line-range reads key on
|
|
502
|
+
* `path + "\u0000" + selector`, so a complete bare-path read can supersede them.
|
|
503
|
+
* `raw` and `conflicts` reads show a different form than a plain read (e.g. raw
|
|
504
|
+
* notebook JSON vs converted notebook text), so they key on
|
|
505
|
+
* `path + "\u0001" + selector` and are superseded only by the same selector.
|
|
447
506
|
*/
|
|
448
507
|
export function readToolSupersedeKey(toolName: string, args: Record<string, unknown>): string | undefined {
|
|
449
508
|
if (toolName !== "read") return undefined;
|
|
@@ -451,5 +510,6 @@ export function readToolSupersedeKey(toolName: string, args: Record<string, unkn
|
|
|
451
510
|
if (typeof path !== "string" || path.length === 0) return undefined;
|
|
452
511
|
if (path.includes("://")) return undefined;
|
|
453
512
|
const { path: base, sel } = splitReadSelector(path);
|
|
454
|
-
|
|
513
|
+
if (sel === undefined) return base;
|
|
514
|
+
return `${base}${isAlternateFormReadSelector(sel) ? "\u0001" : "\u0000"}${sel}`;
|
|
455
515
|
}
|
package/src/compaction/utils.ts
CHANGED
|
@@ -38,6 +38,7 @@ const RANGE_LIST_SRC = `${RANGE_CHUNK_SRC}(?:,${RANGE_CHUNK_SRC})*`;
|
|
|
38
38
|
const READ_SELECTOR_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts)$`, "i");
|
|
39
39
|
const READ_RANGE_ONLY_RE = new RegExp(`^${RANGE_LIST_SRC}$`, "i");
|
|
40
40
|
const READ_RAW_ONLY_RE = /^raw$/i;
|
|
41
|
+
const READ_ALTERNATE_FORM_RE = /^(?:raw|conflicts)$/i;
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Split a read-tool path into its base path and trailing selector, mirroring the
|
|
@@ -77,6 +78,14 @@ export function stripReadSelector(path: string): string {
|
|
|
77
78
|
return splitReadSelector(path).path;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Whether a selector from {@link splitReadSelector} shows the file in a form other
|
|
83
|
+
* than its plain lines: verbatim `raw` bytes or the merge `conflicts` view.
|
|
84
|
+
*/
|
|
85
|
+
export function isAlternateFormReadSelector(sel: string): boolean {
|
|
86
|
+
return sel.split(":").some(part => READ_ALTERNATE_FORM_RE.test(part));
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
/**
|
|
81
90
|
* A real filesystem path never contains a `scheme://` URL. Tool-call paths that
|
|
82
91
|
* do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,
|
package/src/telemetry.ts
CHANGED
|
@@ -204,10 +204,18 @@ export interface ChatUsageSnapshot {
|
|
|
204
204
|
|
|
205
205
|
/** Context passed to the cost estimator. */
|
|
206
206
|
export interface CostEstimatorContext {
|
|
207
|
+
/** OTel provider label (`gen_ai.provider.name`). */
|
|
207
208
|
readonly provider: string;
|
|
209
|
+
/** Provider id before OTel normalization (e.g. `openai-codex` for the `openai` label). */
|
|
210
|
+
readonly providerId: string | undefined;
|
|
211
|
+
/** Model id reported on the response; a provider may report a served model instead of the requested one. */
|
|
208
212
|
readonly model: string;
|
|
213
|
+
/** Requested model id, when the caller knows it. */
|
|
214
|
+
readonly modelId: string | undefined;
|
|
209
215
|
readonly serviceTier: ServiceTier | undefined;
|
|
210
216
|
readonly usage: ChatUsageSnapshot;
|
|
217
|
+
/** Cost the provider already computed for this request; the value persisted with the message. */
|
|
218
|
+
readonly usageCost: Usage["cost"];
|
|
211
219
|
}
|
|
212
220
|
|
|
213
221
|
/**
|
|
@@ -1138,6 +1146,8 @@ export async function finishChatSpan(
|
|
|
1138
1146
|
message: AssistantMessage,
|
|
1139
1147
|
options: {
|
|
1140
1148
|
readonly stepNumber: number;
|
|
1149
|
+
/** Requested model id; `message.model` may name a served fallback model instead. */
|
|
1150
|
+
readonly modelId?: string;
|
|
1141
1151
|
readonly serviceTier?: ServiceTier;
|
|
1142
1152
|
readonly responseHeaders?: Readonly<Record<string, string>>;
|
|
1143
1153
|
readonly baseUrl?: string;
|
|
@@ -1147,7 +1157,7 @@ export async function finishChatSpan(
|
|
|
1147
1157
|
applyChatResponseAttributes(span, message);
|
|
1148
1158
|
applyUsageAttributes(span, message.usage);
|
|
1149
1159
|
applyGatewayAttributes(span, options.responseHeaders, options.baseUrl);
|
|
1150
|
-
const cost = applyCostEstimate(telemetry, span, message, options.serviceTier, options.stepNumber);
|
|
1160
|
+
const cost = applyCostEstimate(telemetry, span, message, options.serviceTier, options.stepNumber, options.modelId);
|
|
1151
1161
|
if (telemetry) {
|
|
1152
1162
|
await emitChatUsage(telemetry, span, {
|
|
1153
1163
|
operation: GenAIOperation.Chat,
|
|
@@ -1367,10 +1377,12 @@ function applyCostEstimate(
|
|
|
1367
1377
|
message: AssistantMessage,
|
|
1368
1378
|
serviceTier: ServiceTier | undefined,
|
|
1369
1379
|
stepNumber: number | undefined,
|
|
1380
|
+
modelId: string | undefined,
|
|
1370
1381
|
): AppliedCostEstimate {
|
|
1371
1382
|
if (!telemetry) return EMPTY_COST;
|
|
1372
1383
|
return applyCostEstimateForUsage(telemetry, span, {
|
|
1373
1384
|
model: message.model,
|
|
1385
|
+
modelId,
|
|
1374
1386
|
provider: message.provider,
|
|
1375
1387
|
serviceTier,
|
|
1376
1388
|
stepNumber,
|
|
@@ -1383,6 +1395,7 @@ function applyCostEstimateForUsage(
|
|
|
1383
1395
|
span: Span,
|
|
1384
1396
|
input: {
|
|
1385
1397
|
readonly model: string;
|
|
1398
|
+
readonly modelId: string | undefined;
|
|
1386
1399
|
readonly provider: string | undefined;
|
|
1387
1400
|
readonly serviceTier: ServiceTier | undefined;
|
|
1388
1401
|
readonly stepNumber: number | undefined;
|
|
@@ -1398,9 +1411,12 @@ function applyCostEstimateForUsage(
|
|
|
1398
1411
|
try {
|
|
1399
1412
|
result = estimator({
|
|
1400
1413
|
provider,
|
|
1414
|
+
providerId: input.provider,
|
|
1401
1415
|
model: input.model,
|
|
1416
|
+
modelId: input.modelId,
|
|
1402
1417
|
serviceTier: input.serviceTier,
|
|
1403
1418
|
usage,
|
|
1419
|
+
usageCost: input.usage.cost,
|
|
1404
1420
|
});
|
|
1405
1421
|
} catch (err) {
|
|
1406
1422
|
emitTelemetryWarning(telemetry, {
|
|
@@ -1620,6 +1636,7 @@ export async function recordManualChatTelemetry(
|
|
|
1620
1636
|
if (telemetry) {
|
|
1621
1637
|
const applied = applyCostEstimateForUsage(telemetry, span, {
|
|
1622
1638
|
model: options.responseModel ?? options.model.id,
|
|
1639
|
+
modelId: options.model.id,
|
|
1623
1640
|
provider: options.model.provider,
|
|
1624
1641
|
serviceTier: options.serviceTier,
|
|
1625
1642
|
stepNumber: options.stepNumber,
|
|
@@ -1889,6 +1906,7 @@ export async function instrumentedCompleteSimple<TApi extends Api>(
|
|
|
1889
1906
|
: await runOnce();
|
|
1890
1907
|
await finishChatSpan(telemetry, chatSpan, message, {
|
|
1891
1908
|
stepNumber,
|
|
1909
|
+
modelId: model.id,
|
|
1892
1910
|
serviceTier: options.serviceTier,
|
|
1893
1911
|
responseHeaders: capturedHeaders,
|
|
1894
1912
|
baseUrl: model.baseUrl,
|