@oh-my-pi/pi-agent-core 18.5.0 → 18.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.6.0] - 2026-10-03
6
+
7
+ ### Fixed
8
+
9
+ - When a DeepSeek turn ends with raw `<|DSML|…>` tool-call text that could not be parsed into a real call, and no tool call was made, the broken markup is now removed from the message before it is saved to history. The agent then tells the model the tool call failed, shows it the correct format, and asks it again, at most twice in a row. Previously the markup stayed in history, and the agent stopped as if the model had finished ([#14202](https://github.com/can1357/oh-my-pi/pull/14202) by [@H4vC](https://github.com/H4vC)).
10
+
11
+ ## [18.5.1] - 2026-10-03
12
+
13
+ ### Added
14
+
15
+ - 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.
16
+
17
+ ### Changed
18
+
19
+ - 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.
20
+
21
+ ### Fixed
22
+
23
+ - 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.
24
+ - Fixed completed tool calls being retried after a transient provider-stream read error; incomplete calls from the affected turn are now discarded.
25
+ - Fixed previously read code being removed from context when a subsequent read returned a summary, truncated content, or an error.
26
+ - Fixed intent tracing for tools that define i as a real argument, preserving the argument and its declared schema order.
27
+
5
28
  ## [18.4.11] - 2026-10-02
6
29
 
7
30
  ### 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 every result except the newest is a supersede candidate. A key `K`
60
- * additionally supersedes keys with prefix `K + "\u0000"` (selector-free read
61
- * supersedes selector-carrying reads of the same base path). Return
62
- * `undefined` to exempt a call from supersede grouping.
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; results sharing a key supersede older ones. */
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; selector-carrying reads key on
117
- * `path + "\u0000" + selector`, so two reads collide only when the newer is
118
- * selector-free or the selectors are identical (the pass's prefix rule lets a
119
- * bare-path read supersede selector-carrying reads of the same file).
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.0",
4
+ "version": "18.6.0",
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.0",
42
- "@oh-my-pi/pi-catalog": "18.5.0",
43
- "@oh-my-pi/pi-natives": "18.5.0",
44
- "@oh-my-pi/pi-utils": "18.5.0",
45
- "@oh-my-pi/pi-wire": "18.5.0",
46
- "@oh-my-pi/snapcompact": "18.5.0",
41
+ "@oh-my-pi/pi-ai": "18.6.0",
42
+ "@oh-my-pi/pi-catalog": "18.6.0",
43
+ "@oh-my-pi/pi-natives": "18.6.0",
44
+ "@oh-my-pi/pi-utils": "18.6.0",
45
+ "@oh-my-pi/pi-wire": "18.6.0",
46
+ "@oh-my-pi/snapcompact": "18.6.0",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.5.0",
50
+ "@oh-my-pi/omptype": "18.6.0",
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,
@@ -51,6 +53,7 @@ import {
51
53
  recoverHarmonyToolCall,
52
54
  signalListLabel,
53
55
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
56
+ import { isDsmlLeakRecoveryTarget, removeDsmlToolMarkupLeak } from "@oh-my-pi/pi-ai/utils/dsml-leak";
54
57
  import { cloneJsonTree, logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
55
58
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
56
59
  import { LiveSteeringChannel } from "./live-steering";
@@ -74,6 +77,7 @@ import {
74
77
  startInvokeAgentSpan,
75
78
  } from "./telemetry";
76
79
  import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
80
+ import dsmlToolCallLeakPrompt from "./prompts/dsml-tool-call-leak.md" with { type: "text" };
77
81
  import type {
78
82
  AgentContext,
79
83
  AgentEvent,
@@ -114,6 +118,14 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
114
118
  */
115
119
  const MAX_PAUSED_TURN_CONTINUATIONS = 8;
116
120
 
121
+ /**
122
+ * Cap on consecutive "tool call failed" nudges for a turn that ended with raw
123
+ * DeepSeek DSML tool-call markup in its text and no structured call. A model
124
+ * that keeps leaking after this many corrections is left to yield. Resets
125
+ * whenever a turn carries tool calls.
126
+ */
127
+ const MAX_DSML_LEAK_NUDGES = 2;
128
+
117
129
  /**
118
130
  * Cap on consecutive forced escalations for a single soft tool requirement.
119
131
  * A forced `toolChoice` guarantees the call, so this is purely defensive: if a
@@ -998,7 +1010,8 @@ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeT
998
1010
  const injectIntent = options.injectIntent && Bun.env.PI_NO_INTENT !== "1";
999
1011
  return tools?.map(t => {
1000
1012
  const intentMode = resolveIntentMode(t.intent);
1001
- const doInjectIntent = injectIntent && intentMode !== "omit";
1013
+ const doInjectIntent =
1014
+ injectIntent && intentMode !== "omit" && !schemaDefinesProperty(toolWireSchema(t), INTENT_FIELD);
1002
1015
  // When the full catalog is rendered into the system prompt, ship the tool
1003
1016
  // specs without their descriptions (top-level + nested schema annotations)
1004
1017
  // so they are not duplicated on the wire. Strip the STABLE wire schema (the
@@ -1219,6 +1232,7 @@ async function runLoopBody(
1219
1232
  let harmonyRetryAttempt = 0;
1220
1233
  let harmonyTruncateResumeCount = 0;
1221
1234
  let pausedTurnContinuations = 0;
1235
+ let dsmlLeakNudges = 0;
1222
1236
 
1223
1237
  // Soft tool requirement lifecycle (reminder then escalation; see SoftToolRequirement).
1224
1238
  // The host-owned state survives only a gate stop between Agent.prompt calls.
@@ -1479,6 +1493,15 @@ async function runLoopBody(
1479
1493
  }
1480
1494
  }
1481
1495
  if (recovered) {
1496
+ // Mint/repair ids before any snapshot: the recovered message skipped
1497
+ // the streamed prepare (the leak interruption landed first), and the
1498
+ // `message_start`/`message_end` copies below are what persistence and
1499
+ // every consumer retain. Repairing only at executeToolCalls would
1500
+ // leave those copies under a never-materialized id while the result
1501
+ // carries the minted one. The later prepare stays idempotent — the
1502
+ // repaired mark rides the snapshot (object spread), and the minted
1503
+ // ids are already claimed at run scope.
1504
+ ensureUniqueToolCallIds(message, toolCallIdsDispatchedUnder(currentContext));
1482
1505
  message = snapshotAssistantMessage(message);
1483
1506
  currentContext.messages.push(message);
1484
1507
  stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
@@ -1673,6 +1696,7 @@ async function runLoopBody(
1673
1696
 
1674
1697
  if (toolCalls.length > 0) {
1675
1698
  pausedTurnContinuations = 0;
1699
+ dsmlLeakNudges = 0;
1676
1700
  } else if (
1677
1701
  !hasMoreToolCalls &&
1678
1702
  message.stopReason === "stop" &&
@@ -1686,6 +1710,25 @@ async function runLoopBody(
1686
1710
  // mid-work turn.
1687
1711
  pausedTurnContinuations++;
1688
1712
  hasMoreToolCalls = true;
1713
+ } else if (
1714
+ !hasMoreToolCalls &&
1715
+ message.stopReason === "stop" &&
1716
+ dsmlLeakMessages.has(message) &&
1717
+ dsmlLeakNudges < MAX_DSML_LEAK_NUDGES
1718
+ ) {
1719
+ // DeepSeek wrote its tool call as raw DSML text the healer could not
1720
+ // recover (often missing the opening envelope). The markup was
1721
+ // stripped before commit; without a nudge the loop would yield as if
1722
+ // the model were done, so tell it the call failed and re-sample.
1723
+ const nudge = injectExecutionAdditionalContext(
1724
+ currentContext,
1725
+ newMessages,
1726
+ stream,
1727
+ dsmlToolCallLeakPrompt,
1728
+ );
1729
+ if (nudge) additionalMessages.push(nudge);
1730
+ dsmlLeakNudges++;
1731
+ hasMoreToolCalls = true;
1689
1732
  }
1690
1733
 
1691
1734
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
@@ -1955,6 +1998,7 @@ async function streamAssistantResponse(
1955
1998
  // per model without touching the shared session `serviceTier`.
1956
1999
  const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(model) : config.serviceTier;
1957
2000
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(model);
2001
+ const dsmlLeakRecoveryEnabled = isDsmlLeakRecoveryTarget(model);
1958
2002
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
1959
2003
  const requestSignal = harmonyAbortController
1960
2004
  ? signal
@@ -2031,6 +2075,7 @@ async function streamAssistantResponse(
2031
2075
  const finishChat = async (message: AssistantMessage): Promise<void> => {
2032
2076
  await finishChatSpan(telemetry, chatSpan, message, {
2033
2077
  stepNumber: chatStepNumber,
2078
+ modelId: model.id,
2034
2079
  serviceTier: effectiveServiceTier,
2035
2080
  responseHeaders: capturedHeaders,
2036
2081
  baseUrl: model.baseUrl,
@@ -2183,6 +2228,18 @@ async function streamAssistantResponse(
2183
2228
  }
2184
2229
  }
2185
2230
  finalMessage = snapshotAssistantMessage(finalMessage);
2231
+ // Unhealed DSML tool-call markup must never reach history: replaying
2232
+ // it teaches the model to keep writing calls as text (#10556). Strip
2233
+ // it before the context, the UI, and the session see the message.
2234
+ // Only DSML-speaking models qualify; from others it is prose.
2235
+ if (
2236
+ dsmlLeakRecoveryEnabled &&
2237
+ finalMessage.stopReason === "stop" &&
2238
+ !finalMessage.content.some(block => block.type === "toolCall") &&
2239
+ removeDsmlToolMarkupLeak(finalMessage)
2240
+ ) {
2241
+ dsmlLeakMessages.add(finalMessage);
2242
+ }
2186
2243
  // Expand inline macros (and any other registered rewrite) on the
2187
2244
  // finalized message before it reaches the context, the UI, or tool
2188
2245
  // dispatch — so a single mutation is the source of truth for all three.
@@ -2447,7 +2504,10 @@ async function streamAssistantResponse(
2447
2504
  }
2448
2505
 
2449
2506
  try {
2450
- let trailing = await response.result();
2507
+ let trailing = recoverTransientErrorToolTurn(
2508
+ retainCompletedToolCalls(await response.result(), completedToolCallIds),
2509
+ context.tools ?? [],
2510
+ );
2451
2511
  if (harmonyMitigationEnabled) {
2452
2512
  const detection = detectHarmonyLeakInAssistantMessage(trailing);
2453
2513
  if (detection) {
@@ -2736,6 +2796,9 @@ interface PreparedToolCall {
2736
2796
  prepareError?: unknown;
2737
2797
  }
2738
2798
 
2799
+ /** Committed assistant messages whose unhealed DSML tool-call markup was stripped; the loop answers them with a failure nudge. */
2800
+ const dsmlLeakMessages = new WeakSet<AssistantMessage>();
2801
+
2739
2802
  /**
2740
2803
  * Prepare results computed in the stream-done branch (before `message_start`/
2741
2804
  * `message_end`) so a `beforeToolCall` args revision is baked into the message
@@ -2839,15 +2902,110 @@ function formatToolNotFoundMessage(
2839
2902
  return `Tool ${name} not found. Closest available: ${suggestions.slice(0, MAX_TOOL_NAME_SUGGESTIONS).join(", ")}`;
2840
2903
  }
2841
2904
 
2905
+ /**
2906
+ * Enforce the invariant every downstream matcher keys on: `toolCall.id` is
2907
+ * unique and non-empty across the whole run — not just within one assistant
2908
+ * message. Providers can deliver a reused id (wire length truncation, Responses
2909
+ * `callId|itemId` composite collapse) or an id that never materialized (`""`) —
2910
+ * the same occurrences the replay pipeline repairs or drops
2911
+ * (`deduplicateToolCallIds` / `sanitizeMalformedToolCalls`). Id-keyed per-call
2912
+ * state — the prepare map below, every UI and persistence matcher — would
2913
+ * otherwise merge sibling calls: the later call's prepared entry overwrites the
2914
+ * earlier's, both calls execute the sibling's payload, and colliding result ids
2915
+ * merge or drop one sibling's output in every consumer. Message-scope
2916
+ * uniqueness is not enough for the session-wide consumers
2917
+ * (`formatSessionHistoryMarkdown`'s last-entry-wins map, the advisor delta
2918
+ * split, `EventController.#toolTimelineComponents`, persistence keys): a second
2919
+ * assistant turn whose ids never materialized used to re-mint `call_1`,
2920
+ * `call_2`, … and re-derive the same `_dup` suffixes, so those consumers merged
2921
+ * the cross-turn collisions. Every id kept or minted here is therefore claimed
2922
+ * in the run-scoped registry (`toolCallIdsDispatchedUnder`) and every candidate
2923
+ * is generated until unused at run scope. Never-materialized is the sanitizer's
2924
+ * trim test (`isMalformedToolCallId`): a whitespace-only id is minted like an
2925
+ * empty one, never kept — it would be dropped from provider replay together
2926
+ * with its result, the live/replay divergence this repair exists to close.
2927
+ * Cursor server-resolved blocks are never re-keyed (the provider correlates
2928
+ * their results out-of-band under its own ids) but their ids still join the
2929
+ * registry so no minted candidate collides with one. Idempotent: the message is
2930
+ * marked repaired and re-entry is a no-op, so the Harmony path can repair
2931
+ * before its snapshots while the dispatch-time prepare sees the already-minted
2932
+ * ids and leaves them alone.
2933
+ */
2934
+ const kToolCallIdsRepaired = Symbol("agent-loop.toolCallIdsRepaired");
2935
+
2936
+ /** Assistant message optionally carrying the {@link kToolCallIdsRepaired} mark. */
2937
+ type ToolCallIdsRepairedCarrier = AssistantMessage & { [kToolCallIdsRepaired]?: true };
2938
+
2939
+ /**
2940
+ * Run-scoped registry of every tool-call id {@link ensureUniqueToolCallIds} has
2941
+ * kept, minted, or seen on a server-resolved block under one
2942
+ * {@link AgentContext}. Keyed by the dispatch context (the same object every
2943
+ * `prepareToolCallDispatch` receives) so the uniqueness window spans every
2944
+ * assistant turn of the run rather than resetting per message. Each run gets a
2945
+ * fresh loop context, so minted ids use the process-unique `mintToolCallId`.
2946
+ */
2947
+ const dispatchedToolCallIdsByContext = new WeakMap<AgentContext, Set<string>>();
2948
+
2949
+ function toolCallIdsDispatchedUnder(context: AgentContext): Set<string> {
2950
+ let dispatched = dispatchedToolCallIdsByContext.get(context);
2951
+ if (!dispatched) {
2952
+ dispatched = new Set();
2953
+ dispatchedToolCallIdsByContext.set(context, dispatched);
2954
+ }
2955
+ return dispatched;
2956
+ }
2957
+
2958
+ function ensureUniqueToolCallIds(message: ToolCallIdsRepairedCarrier, dispatchedIds: Set<string>): void {
2959
+ if (message[kToolCallIdsRepaired] === true) return;
2960
+ const content = message.content;
2961
+ const reserved = new Set<string>();
2962
+ for (const block of content) {
2963
+ if (block.type === "toolCall" && block.id) reserved.add(block.id);
2964
+ }
2965
+ const seen = new Set<string>();
2966
+ for (const block of content) {
2967
+ if (block.type !== "toolCall") continue;
2968
+ if ((block as CursorExecResolvedCarrier)[kCursorExecResolved] === true) {
2969
+ if (block.id?.trim()) dispatchedIds.add(block.id);
2970
+ continue;
2971
+ }
2972
+ if (block.id?.trim() && !seen.has(block.id) && !dispatchedIds.has(block.id)) {
2973
+ seen.add(block.id);
2974
+ dispatchedIds.add(block.id);
2975
+ continue;
2976
+ }
2977
+ let candidate: string;
2978
+ if (block.id?.trim()) {
2979
+ let suffix = 1;
2980
+ candidate = appendDuplicateSuffix(block.id, `_dup${suffix}`, MAX_TOOL_CALL_ID_LENGTH);
2981
+ while (seen.has(candidate) || reserved.has(candidate) || dispatchedIds.has(candidate)) {
2982
+ suffix += 1;
2983
+ candidate = appendDuplicateSuffix(block.id, `_dup${suffix}`, MAX_TOOL_CALL_ID_LENGTH);
2984
+ }
2985
+ } else {
2986
+ // Process-unique minter: a per-run counter would re-mint `call_1` in
2987
+ // every run and collide with earlier runs' calls in session history.
2988
+ do {
2989
+ candidate = mintToolCallId();
2990
+ } while (seen.has(candidate) || reserved.has(candidate) || dispatchedIds.has(candidate));
2991
+ }
2992
+ block.id = candidate;
2993
+ seen.add(candidate);
2994
+ dispatchedIds.add(candidate);
2995
+ }
2996
+ message[kToolCallIdsRepaired] = true;
2997
+ }
2998
+
2842
2999
  /**
2843
3000
  * Pre-dispatch phase for every pending tool call on `assistantMessage`, run in
2844
- * call order: intent extraction, argument validation, and the `beforeToolCall`
2845
- * hook. A hook `args` revision is revalidated against the tool schema and
2846
- * written back to `toolCall.arguments`; run before `message_start`/`message_end`
2847
- * (the streamed path) that makes the revision the single source of truth —
2848
- * history, execution events, persistence, provider replay, concurrency
2849
- * scheduling, and `tool.execute` all agree. Failures are recorded per call and
2850
- * surfaced by `executeToolCalls` at the record's scheduled slot.
3001
+ * call order: tool-call id repair, intent extraction, argument validation, and
3002
+ * the `beforeToolCall` hook. A hook `args` revision is revalidated against the
3003
+ * tool schema and written back to `toolCall.arguments`; run before
3004
+ * `message_start`/`message_end` (the streamed path) that makes the revision the
3005
+ * single source of truth — history, execution events, persistence, provider
3006
+ * replay, concurrency scheduling, and `tool.execute` all agree. Id repair in
3007
+ * {@link ensureUniqueToolCallIds} follows the same rule. Failures are recorded
3008
+ * per call and surfaced by `executeToolCalls` at the record's scheduled slot.
2851
3009
  */
2852
3010
  async function prepareToolCallDispatch(
2853
3011
  assistantMessage: AssistantMessage,
@@ -2856,6 +3014,9 @@ async function prepareToolCallDispatch(
2856
3014
  signal: AbortSignal | undefined,
2857
3015
  ): Promise<Map<string, PreparedToolCall>> {
2858
3016
  const { resolveFallbackTool, suggestFallbackToolNames, intentTracing, beforeToolCall } = config;
3017
+ // Unique, non-empty ids first: everything below and every consumer of this
3018
+ // message keys per-call state on `toolCall.id`.
3019
+ ensureUniqueToolCallIds(assistantMessage, toolCallIdsDispatchedUnder(context));
2859
3020
  const prepared = new Map<string, PreparedToolCall>();
2860
3021
  for (const toolCall of assistantMessage.content) {
2861
3022
  if (toolCall.type !== "toolCall") continue;
@@ -2872,17 +3033,13 @@ async function prepareToolCallDispatch(
2872
3033
  prepared.set(toolCall.id, entry);
2873
3034
  let argsForExecution = toolCall.arguments as Record<string, unknown>;
2874
3035
  if (intentTracing) {
2875
- const { intent, strippedArgs } = extractIntent(toolCall.arguments);
3036
+ // A schema-owned `i` is a tool argument, never a harness intent.
3037
+ const ownsIntent = tool !== undefined && schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD);
3038
+ const { intent, strippedArgs } = ownsIntent
3039
+ ? { intent: undefined, strippedArgs: argsForExecution }
3040
+ : extractIntent(toolCall.arguments);
2876
3041
  argsForExecution = strippedArgs;
2877
- // A payload in `i` would be stripped and the tool run with the leftover
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
- ) {
3042
+ if (intent !== undefined && intent.length > MAX_INTENT_LENGTH && tool) {
2886
3043
  entry.args = strippedArgs;
2887
3044
  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
3045
  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 every result except the newest is a supersede candidate. A key `K`
104
- * additionally supersedes keys with prefix `K + "\u0000"` (selector-free read
105
- * supersedes selector-carrying reads of the same base path). Return
106
- * `undefined` to exempt a call from supersede grouping.
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; results sharing a key supersede older ones. */
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 with the same key
202
- * — or with a key that is the `"\u0000"`-prefix parent of this one — marks it
203
- * superseded. Returned in message order.
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 seenKeys = new Set<string>();
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 superseded = seenKeys.has(key) || (separator >= 0 && seenKeys.has(key.slice(0, separator)));
225
- seenKeys.add(key);
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(entries, tokenizer, toolCallsById, config.supersedeKey, config.protectedTools)
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(entries, tokenizer, toolCallsById, config.supersedeKey, config.protectedTools).map(
345
- candidate => candidate.message,
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; selector-carrying reads key on
444
- * `path + "\u0000" + selector`, so two reads collide only when the newer is
445
- * selector-free or the selectors are identical (the pass's prefix rule lets a
446
- * bare-path read supersede selector-carrying reads of the same file).
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
- return sel === undefined ? base : `${base}\u0000${sel}`;
513
+ if (sel === undefined) return base;
514
+ return `${base}${isAlternateFormReadSelector(sel) ? "\u0001" : "\u0000"}${sel}`;
455
515
  }
@@ -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://…`,
@@ -0,0 +1,19 @@
1
+ Tool call failed: your previous response tried to invoke a tool, but the DSML markup was malformed, so it arrived as plain text, no tool was executed, and the broken markup was removed from your response. Usually the opening `<|DSML|tool_calls>` and/or `<|DSML|invoke name="…">` tags are missing, or the tool name was written as a plain line.
2
+
3
+ A tool call must be one complete envelope:
4
+
5
+ ```
6
+ <|DSML|tool_calls>
7
+ <|DSML|invoke name="TOOL_NAME">
8
+ <|DSML|parameter name="ARG_NAME" string="true">text value</|DSML|parameter>
9
+ <|DSML|parameter name="OTHER_ARG" string="false">{"json": "value"}</|DSML|parameter>
10
+ </|DSML|invoke>
11
+ </|DSML|tool_calls>
12
+ ```
13
+
14
+ - `<|DSML|tool_calls>` wraps every call; put one `<|DSML|invoke>` per call inside it.
15
+ - The tool name goes in the `name` attribute of `<|DSML|invoke>`, never on its own line.
16
+ - Use `string="true"` for raw text values and `string="false"` for numbers, booleans, arrays, and objects (JSON).
17
+ - Close every tag.
18
+
19
+ Re-issue the intended call now in this shape, or reply in plain prose if no tool is needed.
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,