@prestyj/agent 5.10.0 → 5.11.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/dist/index.d.cts CHANGED
@@ -20,6 +20,13 @@ interface AgentTool<T extends z.ZodType = z.ZodType> extends Tool {
20
20
  * batch runs in source order so stateful mutations cannot race each other.
21
21
  */
22
22
  executionMode?: ToolExecutionMode;
23
+ /**
24
+ * Overrides the loop's default per-tool timeout. A tool that owns a longer
25
+ * internal budget than the default must declare it here, or the loop cancels
26
+ * it first and the tool's own timeout — with its specific, actionable error
27
+ * message — becomes unreachable.
28
+ */
29
+ timeoutMs?: number;
23
30
  execute: (args: z.infer<T>, context: ToolContext) => ToolExecuteResult | Promise<ToolExecuteResult>;
24
31
  }
25
32
  interface AgentTextDeltaEvent {
@@ -70,6 +77,21 @@ interface AgentTurnEndEvent {
70
77
  usage: Usage;
71
78
  timing: AgentTurnTiming;
72
79
  }
80
+ /**
81
+ * A safe point between steps: the assistant message and every tool result for
82
+ * this turn are now in the message array, and no provider call is in flight.
83
+ *
84
+ * Hosts that persist a transcript flush here. Without it a crash mid-run loses
85
+ * the WHOLE turn — including tool results whose side effects already landed on
86
+ * disk — because the only flush happens after the loop returns.
87
+ *
88
+ * Yielded immediately after tool results are appended, so it pairs with
89
+ * `turn_end` (which covers the assistant half) to cover every message.
90
+ */
91
+ interface AgentCheckpointEvent {
92
+ type: "checkpoint";
93
+ turn: number;
94
+ }
73
95
  interface AgentDoneEvent {
74
96
  type: "agent_done";
75
97
  totalTurns: number;
@@ -87,6 +109,21 @@ interface AgentMaxTurnsEvent {
87
109
  totalTurns: number;
88
110
  maxTurns: number;
89
111
  }
112
+ /**
113
+ * Emitted when the loop was about to stop on an exhausted turn budget but the
114
+ * host granted an extension instead. The effective budget is raised and the
115
+ * loop continues with a continuation prompt, so this is NOT terminal — unlike
116
+ * `max_turns`, which still fires if the extended budget is also spent.
117
+ */
118
+ interface AgentTurnBudgetExtendedEvent {
119
+ type: "turn_budget_extended";
120
+ /** Turn number at which the budget was exhausted. */
121
+ turn: number;
122
+ /** New effective `maxTurns` after the extension. */
123
+ grantedTurns: number;
124
+ /** 1-based extension count for this run. */
125
+ extension: number;
126
+ }
90
127
  /**
91
128
  * Warning signal emitted when a turn ended on a non-clean stop reason —
92
129
  * `max_tokens` (output clipped at the model's output-token limit), `refusal`,
@@ -148,7 +185,7 @@ interface AgentFollowUpMessageEvent {
148
185
  type: "follow_up_message";
149
186
  content: Message["content"];
150
187
  }
151
- type AgentEvent = AgentTextDeltaEvent | AgentThinkingDeltaEvent | AgentToolCallStartEvent | AgentToolCallUpdateEvent | AgentToolCallEndEvent | AgentToolCallDeltaEvent | AgentServerToolCallEvent | AgentServerToolResultEvent | AgentSteeringMessageEvent | AgentFollowUpMessageEvent | AgentRetryEvent | AgentTurnEndEvent | AgentDoneEvent | AgentMaxTurnsEvent | AgentTruncatedEvent | AgentErrorEvent;
188
+ type AgentEvent = AgentTextDeltaEvent | AgentThinkingDeltaEvent | AgentToolCallStartEvent | AgentToolCallUpdateEvent | AgentToolCallEndEvent | AgentToolCallDeltaEvent | AgentServerToolCallEvent | AgentServerToolResultEvent | AgentSteeringMessageEvent | AgentFollowUpMessageEvent | AgentRetryEvent | AgentTurnEndEvent | AgentCheckpointEvent | AgentDoneEvent | AgentMaxTurnsEvent | AgentTurnBudgetExtendedEvent | AgentTruncatedEvent | AgentErrorEvent;
152
189
  interface TransformContextOptions {
153
190
  /** Force a transform after the provider reports context overflow. */
154
191
  force?: boolean;
@@ -168,6 +205,12 @@ interface AgentOptions {
168
205
  /** Control whether tools may/must be called, or select a named tool when supported. */
169
206
  toolChoice?: StreamOptions["toolChoice"];
170
207
  maxTurns?: number;
208
+ /**
209
+ * How many times `onTurnBudgetExhausted` may grant extra turns in one run.
210
+ * Each grant raises the effective budget by the original `maxTurns`.
211
+ * Default: 2. Set 0 to disable extensions entirely.
212
+ */
213
+ maxTurnExtensions?: number;
171
214
  maxTokens?: number;
172
215
  temperature?: number;
173
216
  thinking?: StreamOptions["thinking"];
@@ -234,6 +277,18 @@ interface AgentOptions {
234
277
  * on read.
235
278
  */
236
279
  getFollowUpMessages?: () => Promise<Message[] | null> | Message[] | null;
280
+ /**
281
+ * Consulted when a tool-running turn exhausts the turn budget mid-task,
282
+ * before the loop emits the terminal `max_turns` event. Return true to grant
283
+ * another `maxTurns` worth of turns; false (the default when unset) keeps
284
+ * today's hard cut-off. Hosts should only grant on evidence of progress —
285
+ * extending a spinning agent just buys it more tokens to spin with.
286
+ */
287
+ onTurnBudgetExhausted?: (ctx: {
288
+ turn: number;
289
+ maxTurns: number;
290
+ extension: number;
291
+ }) => Promise<boolean> | boolean;
237
292
  }
238
293
  interface AgentResult {
239
294
  message: AssistantMessage;
package/dist/index.d.ts CHANGED
@@ -20,6 +20,13 @@ interface AgentTool<T extends z.ZodType = z.ZodType> extends Tool {
20
20
  * batch runs in source order so stateful mutations cannot race each other.
21
21
  */
22
22
  executionMode?: ToolExecutionMode;
23
+ /**
24
+ * Overrides the loop's default per-tool timeout. A tool that owns a longer
25
+ * internal budget than the default must declare it here, or the loop cancels
26
+ * it first and the tool's own timeout — with its specific, actionable error
27
+ * message — becomes unreachable.
28
+ */
29
+ timeoutMs?: number;
23
30
  execute: (args: z.infer<T>, context: ToolContext) => ToolExecuteResult | Promise<ToolExecuteResult>;
24
31
  }
25
32
  interface AgentTextDeltaEvent {
@@ -70,6 +77,21 @@ interface AgentTurnEndEvent {
70
77
  usage: Usage;
71
78
  timing: AgentTurnTiming;
72
79
  }
80
+ /**
81
+ * A safe point between steps: the assistant message and every tool result for
82
+ * this turn are now in the message array, and no provider call is in flight.
83
+ *
84
+ * Hosts that persist a transcript flush here. Without it a crash mid-run loses
85
+ * the WHOLE turn — including tool results whose side effects already landed on
86
+ * disk — because the only flush happens after the loop returns.
87
+ *
88
+ * Yielded immediately after tool results are appended, so it pairs with
89
+ * `turn_end` (which covers the assistant half) to cover every message.
90
+ */
91
+ interface AgentCheckpointEvent {
92
+ type: "checkpoint";
93
+ turn: number;
94
+ }
73
95
  interface AgentDoneEvent {
74
96
  type: "agent_done";
75
97
  totalTurns: number;
@@ -87,6 +109,21 @@ interface AgentMaxTurnsEvent {
87
109
  totalTurns: number;
88
110
  maxTurns: number;
89
111
  }
112
+ /**
113
+ * Emitted when the loop was about to stop on an exhausted turn budget but the
114
+ * host granted an extension instead. The effective budget is raised and the
115
+ * loop continues with a continuation prompt, so this is NOT terminal — unlike
116
+ * `max_turns`, which still fires if the extended budget is also spent.
117
+ */
118
+ interface AgentTurnBudgetExtendedEvent {
119
+ type: "turn_budget_extended";
120
+ /** Turn number at which the budget was exhausted. */
121
+ turn: number;
122
+ /** New effective `maxTurns` after the extension. */
123
+ grantedTurns: number;
124
+ /** 1-based extension count for this run. */
125
+ extension: number;
126
+ }
90
127
  /**
91
128
  * Warning signal emitted when a turn ended on a non-clean stop reason —
92
129
  * `max_tokens` (output clipped at the model's output-token limit), `refusal`,
@@ -148,7 +185,7 @@ interface AgentFollowUpMessageEvent {
148
185
  type: "follow_up_message";
149
186
  content: Message["content"];
150
187
  }
151
- type AgentEvent = AgentTextDeltaEvent | AgentThinkingDeltaEvent | AgentToolCallStartEvent | AgentToolCallUpdateEvent | AgentToolCallEndEvent | AgentToolCallDeltaEvent | AgentServerToolCallEvent | AgentServerToolResultEvent | AgentSteeringMessageEvent | AgentFollowUpMessageEvent | AgentRetryEvent | AgentTurnEndEvent | AgentDoneEvent | AgentMaxTurnsEvent | AgentTruncatedEvent | AgentErrorEvent;
188
+ type AgentEvent = AgentTextDeltaEvent | AgentThinkingDeltaEvent | AgentToolCallStartEvent | AgentToolCallUpdateEvent | AgentToolCallEndEvent | AgentToolCallDeltaEvent | AgentServerToolCallEvent | AgentServerToolResultEvent | AgentSteeringMessageEvent | AgentFollowUpMessageEvent | AgentRetryEvent | AgentTurnEndEvent | AgentCheckpointEvent | AgentDoneEvent | AgentMaxTurnsEvent | AgentTurnBudgetExtendedEvent | AgentTruncatedEvent | AgentErrorEvent;
152
189
  interface TransformContextOptions {
153
190
  /** Force a transform after the provider reports context overflow. */
154
191
  force?: boolean;
@@ -168,6 +205,12 @@ interface AgentOptions {
168
205
  /** Control whether tools may/must be called, or select a named tool when supported. */
169
206
  toolChoice?: StreamOptions["toolChoice"];
170
207
  maxTurns?: number;
208
+ /**
209
+ * How many times `onTurnBudgetExhausted` may grant extra turns in one run.
210
+ * Each grant raises the effective budget by the original `maxTurns`.
211
+ * Default: 2. Set 0 to disable extensions entirely.
212
+ */
213
+ maxTurnExtensions?: number;
171
214
  maxTokens?: number;
172
215
  temperature?: number;
173
216
  thinking?: StreamOptions["thinking"];
@@ -234,6 +277,18 @@ interface AgentOptions {
234
277
  * on read.
235
278
  */
236
279
  getFollowUpMessages?: () => Promise<Message[] | null> | Message[] | null;
280
+ /**
281
+ * Consulted when a tool-running turn exhausts the turn budget mid-task,
282
+ * before the loop emits the terminal `max_turns` event. Return true to grant
283
+ * another `maxTurns` worth of turns; false (the default when unset) keeps
284
+ * today's hard cut-off. Hosts should only grant on evidence of progress —
285
+ * extending a spinning agent just buys it more tokens to spin with.
286
+ */
287
+ onTurnBudgetExhausted?: (ctx: {
288
+ turn: number;
289
+ maxTurns: number;
290
+ extension: number;
291
+ }) => Promise<boolean> | boolean;
237
292
  }
238
293
  interface AgentResult {
239
294
  message: AssistantMessage;
package/dist/index.js CHANGED
@@ -8,7 +8,9 @@ import {
8
8
  EventStream,
9
9
  EZCoderAIError,
10
10
  isHardBillingMessage,
11
- redactValue
11
+ redactValue,
12
+ sliceHead,
13
+ sliceTail
12
14
  } from "@prestyj/ai";
13
15
 
14
16
  // src/local-backend.ts
@@ -35,6 +37,7 @@ function isLocalBackendUrl(baseUrl) {
35
37
 
36
38
  // src/agent-loop.ts
37
39
  var DEFAULT_MAX_TURNS = 300;
40
+ var DEFAULT_TOOL_TIMEOUT_MS = 3e5;
38
41
  var _diagFn = null;
39
42
  function setStreamDiagnostic(fn) {
40
43
  _diagFn = fn;
@@ -237,6 +240,9 @@ function abortablePromise(promise, signal) {
237
240
  promise.then(resolveOnce, rejectOnce);
238
241
  });
239
242
  }
243
+ function turnBudgetContinuationPrompt() {
244
+ return "[You reached the turn limit for this segment but the work is not finished, so you have been granted more turns. Before continuing, state in one or two sentences what is already done and what remains, then keep going from there \u2014 do not restart work that is already complete.]";
245
+ }
240
246
  function abortableSleep(ms, signal) {
241
247
  if (signal?.aborted) return Promise.reject(createAbortError());
242
248
  return new Promise((resolve, reject) => {
@@ -258,6 +264,9 @@ function closeIterator(iterator) {
258
264
  }
259
265
  async function* agentLoop(messages, options) {
260
266
  const maxTurns = options.maxTurns ?? DEFAULT_MAX_TURNS;
267
+ let effectiveMaxTurns = maxTurns;
268
+ const maxTurnExtensions = Math.max(0, options.maxTurnExtensions ?? 2);
269
+ let turnExtensions = 0;
261
270
  const maxContinuations = options.maxContinuations ?? 5;
262
271
  let toolMap = new Map((options.tools ?? []).map((t) => [t.name, t]));
263
272
  const totalUsage = { inputTokens: 0, outputTokens: 0 };
@@ -305,12 +314,12 @@ async function* agentLoop(messages, options) {
305
314
  const firstEventTimeoutMs = localBackend ? Number.POSITIVE_INFINITY : usesSilentReasoningBudget ? STREAM_THINKING_IDLE_TIMEOUT_MS : STREAM_FIRST_EVENT_TIMEOUT_MS;
306
315
  const initialHardTimeoutMs = localBackend || usesSilentReasoningBudget ? STREAM_THINKING_HARD_TIMEOUT_MS : STREAM_HARD_TIMEOUT_MS;
307
316
  const MAX_TOOLCALL_DELTA_CHARS = 1e6;
308
- const MAX_TOOLCALL_DELTA_EVENTS = 2e4;
317
+ const MAX_TOOLCALL_NO_PROGRESS_EVENTS = 2e4;
309
318
  let logicalTurnStartedAt = 0;
310
319
  let firstProviderEventAt;
311
320
  let providerDurationMs = 0;
312
321
  try {
313
- while (turn < maxTurns) {
322
+ while (turn < effectiveMaxTurns) {
314
323
  options.signal?.throwIfAborted();
315
324
  turn++;
316
325
  if (logicalTurnStartedAt === 0) logicalTurnStartedAt = Date.now();
@@ -381,6 +390,7 @@ async function* agentLoop(messages, options) {
381
390
  let lastEventType = "";
382
391
  let toolcallDeltaChars = 0;
383
392
  let toolcallDeltaCount = 0;
393
+ let toolcallNoProgressCount = 0;
384
394
  let runawayDetected = null;
385
395
  let attemptText = "";
386
396
  let lastYieldEndTime = Date.now();
@@ -535,11 +545,13 @@ async function* agentLoop(messages, options) {
535
545
  const chunkChars = event.argsJson?.length ?? 0;
536
546
  toolcallDeltaChars += chunkChars;
537
547
  toolcallDeltaCount++;
538
- if (!runawayDetected && (toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS || toolcallDeltaCount > MAX_TOOLCALL_DELTA_EVENTS)) {
548
+ toolcallNoProgressCount = chunkChars > 0 ? 0 : toolcallNoProgressCount + 1;
549
+ if (!runawayDetected && (toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS || toolcallNoProgressCount > MAX_TOOLCALL_NO_PROGRESS_EVENTS)) {
539
550
  runawayDetected = {
540
551
  kind: toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS ? "chars" : "events",
541
552
  chars: toolcallDeltaChars,
542
- events: toolcallDeltaCount
553
+ events: toolcallDeltaCount,
554
+ noProgressEvents: toolcallNoProgressCount
543
555
  };
544
556
  diag("runaway_toolcall_detected", {
545
557
  ...runawayDetected,
@@ -721,11 +733,15 @@ async function* agentLoop(messages, options) {
721
733
  turn--;
722
734
  continue;
723
735
  }
724
- const detail = runawayDetected.kind === "chars" ? `${(runawayDetected.chars / 1024).toFixed(0)} KB of tool-call arguments` : `${runawayDetected.events} tool-call delta events`;
736
+ const detail = runawayDetected.kind === "chars" ? `${(runawayDetected.chars / 1024).toFixed(0)} KB of tool-call arguments` : `${runawayDetected.noProgressEvents} consecutive tool-call delta events without argument progress`;
725
737
  yield {
726
738
  type: "error",
727
- error: new Error(
728
- `The model repeatedly failed to close a tool call after ${MAX_RUNAWAY_TOOLCALL_RETRIES} automatic retries (${detail}). Switch models and retry; your conversation is preserved.`
739
+ error: new EZCoderAIError(
740
+ `The model repeatedly failed to close a tool call after ${MAX_RUNAWAY_TOOLCALL_RETRIES} automatic retries (${detail}). Your conversation is preserved.`,
741
+ {
742
+ source: "provider",
743
+ hint: "Retry once. If it keeps happening, switch models and continue the preserved conversation."
744
+ }
729
745
  )
730
746
  };
731
747
  break;
@@ -752,7 +768,15 @@ async function* agentLoop(messages, options) {
752
768
  role: "assistant",
753
769
  content: [{ type: "text", text: attemptText }]
754
770
  });
755
- messages.push({ role: "user", content: PARTIAL_CONTINUATION_PROMPT });
771
+ messages.push({
772
+ role: "user",
773
+ content: PARTIAL_CONTINUATION_PROMPT,
774
+ provenance: {
775
+ source: "runtime",
776
+ kind: "continuation",
777
+ visibility: "hidden"
778
+ }
779
+ });
756
780
  preservedChars = attemptText.length;
757
781
  }
758
782
  diag("retry", {
@@ -871,6 +895,9 @@ async function* agentLoop(messages, options) {
871
895
  useNonStreamingFallback = false;
872
896
  totalUsage.inputTokens += response.usage.inputTokens;
873
897
  totalUsage.outputTokens += response.usage.outputTokens;
898
+ if (response.usage.reasoningTokens) {
899
+ totalUsage.reasoningTokens = (totalUsage.reasoningTokens ?? 0) + response.usage.reasoningTokens;
900
+ }
874
901
  if (response.usage.cacheRead) {
875
902
  totalUsage.cacheRead = (totalUsage.cacheRead ?? 0) + response.usage.cacheRead;
876
903
  }
@@ -922,7 +949,15 @@ async function* agentLoop(messages, options) {
922
949
  model: options.model
923
950
  });
924
951
  yield { type: "truncated", reason: "max_tokens", continued: true };
925
- messages.push({ role: "user", content: MAX_TOKENS_CONTINUATION_PROMPT });
952
+ messages.push({
953
+ role: "user",
954
+ content: MAX_TOKENS_CONTINUATION_PROMPT,
955
+ provenance: {
956
+ source: "runtime",
957
+ kind: "continuation",
958
+ visibility: "hidden"
959
+ }
960
+ });
926
961
  continue;
927
962
  }
928
963
  yield { type: "truncated", reason: "max_tokens", continued: false };
@@ -998,6 +1033,7 @@ async function* agentLoop(messages, options) {
998
1033
  );
999
1034
  const executionResult = hasSequentialToolCall ? yield* executeToolCallsMixed(toolCalls, toolResults, executionOptions) : yield* executeToolCallsParallel(toolCalls, toolResults, executionOptions);
1000
1035
  messages.push({ role: "tool", content: executionResult.toolResults });
1036
+ yield { type: "checkpoint", turn };
1001
1037
  const toolsAborted = executionResult.aborted;
1002
1038
  if (fatalToolArgumentError) {
1003
1039
  if (fatalToolArgumentRecoverable && !toolArgumentAutoContinueUsed) {
@@ -1029,8 +1065,51 @@ async function* agentLoop(messages, options) {
1029
1065
  }
1030
1066
  }
1031
1067
  }
1032
- if (turn >= maxTurns) {
1033
- hitMaxTurns = true;
1068
+ if (turn >= effectiveMaxTurns) {
1069
+ let extended = false;
1070
+ if (options.onTurnBudgetExhausted && turnExtensions < maxTurnExtensions) {
1071
+ const extension = turnExtensions + 1;
1072
+ let granted = false;
1073
+ try {
1074
+ granted = await options.onTurnBudgetExhausted({
1075
+ turn,
1076
+ maxTurns: effectiveMaxTurns,
1077
+ extension
1078
+ });
1079
+ } catch {
1080
+ granted = false;
1081
+ }
1082
+ if (granted) {
1083
+ turnExtensions = extension;
1084
+ effectiveMaxTurns += maxTurns;
1085
+ extended = true;
1086
+ diag("turn_budget_extended", {
1087
+ turn,
1088
+ grantedTurns: effectiveMaxTurns,
1089
+ extension,
1090
+ provider: options.provider,
1091
+ model: options.model
1092
+ });
1093
+ yield {
1094
+ type: "turn_budget_extended",
1095
+ turn,
1096
+ grantedTurns: effectiveMaxTurns,
1097
+ extension
1098
+ };
1099
+ messages.push({
1100
+ role: "user",
1101
+ content: turnBudgetContinuationPrompt(),
1102
+ provenance: {
1103
+ source: "runtime",
1104
+ kind: "continuation",
1105
+ visibility: "hidden"
1106
+ }
1107
+ });
1108
+ }
1109
+ }
1110
+ if (!extended) {
1111
+ hitMaxTurns = true;
1112
+ }
1034
1113
  }
1035
1114
  }
1036
1115
  } finally {
@@ -1046,14 +1125,15 @@ async function* agentLoop(messages, options) {
1046
1125
  if (hitMaxTurns) {
1047
1126
  diag("max_turns_reached", {
1048
1127
  turn,
1049
- maxTurns,
1128
+ maxTurns: effectiveMaxTurns,
1129
+ extensions: turnExtensions,
1050
1130
  provider: options.provider,
1051
1131
  model: options.model
1052
1132
  });
1053
1133
  yield {
1054
1134
  type: "max_turns",
1055
1135
  totalTurns: turn,
1056
- maxTurns
1136
+ maxTurns: effectiveMaxTurns
1057
1137
  };
1058
1138
  }
1059
1139
  yield {
@@ -1089,7 +1169,7 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1089
1169
  try {
1090
1170
  const parsed = tool.parameters.parse(toolCall.args);
1091
1171
  const callerSignal = options.signal;
1092
- const toolTimeout = AbortSignal.timeout(3e5);
1172
+ const toolTimeout = AbortSignal.timeout(tool.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS);
1093
1173
  const ctx = {
1094
1174
  signal: callerSignal ? AbortSignal.any([callerSignal, toolTimeout]) : toolTimeout,
1095
1175
  toolCallId: toolCall.id,
@@ -1302,9 +1382,9 @@ function capToolResults(toolResults, maxToolResultChars) {
1302
1382
  const originalChars = toolResult.content.length;
1303
1383
  const headChars = Math.floor(max * 0.7);
1304
1384
  const tailChars = max - headChars;
1305
- const head = toolResult.content.slice(0, headChars);
1306
- const tail = toolResult.content.slice(-tailChars);
1307
- const omitted = originalChars - headChars - tailChars;
1385
+ const head = sliceHead(toolResult.content, headChars);
1386
+ const tail = sliceTail(toolResult.content, tailChars);
1387
+ const omitted = originalChars - head.length - tail.length;
1308
1388
  toolResult.content = head + `
1309
1389
 
1310
1390
  [... ${omitted} characters omitted ...]
@@ -1339,11 +1419,11 @@ function capTurnToolResults(toolResults, maxTurnToolResultChars) {
1339
1419
  const headChars = Math.floor(fairShare * 0.7);
1340
1420
  const tailChars = fairShare - headChars;
1341
1421
  const omitted = originalChars - fairShare;
1342
- toolResult.content = toolResult.content.slice(0, headChars) + `
1422
+ toolResult.content = sliceHead(toolResult.content, headChars) + `
1343
1423
 
1344
1424
  [... ${omitted} characters trimmed: this turn's combined tool results exceeded the per-turn budget. Re-run this call alone with narrower filters or offset/limit if you need the omitted content ...]
1345
1425
 
1346
- ` + (tailChars > 0 ? toolResult.content.slice(-tailChars) : "");
1426
+ ` + sliceTail(toolResult.content, tailChars);
1347
1427
  toolResult.capped = {
1348
1428
  originalChars: toolResult.capped?.originalChars ?? originalChars,
1349
1429
  keptChars: toolResult.content.length,
@@ -1362,12 +1442,14 @@ function truncateToolResultText(text, maxChars) {
1362
1442
  if (text.length <= maxChars) return text;
1363
1443
  const tailChars = Math.min(Math.floor(maxChars * 0.3), 2e4);
1364
1444
  const headChars = Math.max(maxChars - tailChars, 0);
1365
- const omitted = text.length - headChars - tailChars;
1366
- return `${text.slice(0, headChars)}
1445
+ const head = sliceHead(text, headChars);
1446
+ const tail = sliceTail(text, tailChars);
1447
+ const omitted = text.length - head.length - tail.length;
1448
+ return `${head}
1367
1449
 
1368
1450
  [... ${omitted} characters omitted after context overflow ...]
1369
1451
 
1370
- ${text.slice(-tailChars)}`;
1452
+ ${tail}`;
1371
1453
  }
1372
1454
  function truncateOversizedToolResults(messages, maxChars) {
1373
1455
  if (maxChars <= 0) return false;