@prestyj/agent 5.28.0 → 5.29.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.cjs CHANGED
@@ -22,7 +22,12 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  Agent: () => Agent,
24
24
  AgentStream: () => AgentStream,
25
+ DEFAULT_STREAM_RULE_MAX_RETRIES: () => DEFAULT_STREAM_RULE_MAX_RETRIES,
26
+ DEFAULT_STREAM_RULE_WINDOW_CHARS: () => DEFAULT_STREAM_RULE_WINDOW_CHARS,
27
+ JsonEscapeDecoder: () => JsonEscapeDecoder,
28
+ StreamRuleMonitor: () => StreamRuleMonitor,
25
29
  agentLoop: () => agentLoop,
30
+ buildStreamRuleReminder: () => buildStreamRuleReminder,
26
31
  cancelledBeforeStartText: () => cancelledBeforeStartText,
27
32
  indeterminateOutcomeText: () => indeterminateOutcomeText,
28
33
  isAbortError: () => isAbortError,
@@ -30,16 +35,17 @@ __export(index_exports, {
30
35
  isContextOverflow: () => isContextOverflow,
31
36
  isLocalBackendUrl: () => isLocalBackendUrl,
32
37
  isUsageLimitError: () => isUsageLimitError,
38
+ repairToolPairingAdjacent: () => repairToolPairingAdjacent,
33
39
  setStreamDiagnostic: () => setStreamDiagnostic
34
40
  });
35
41
  module.exports = __toCommonJS(index_exports);
36
42
 
37
43
  // src/agent.ts
38
- var import_ai2 = require("@prestyj/ai");
44
+ var import_ai3 = require("@prestyj/ai");
39
45
 
40
46
  // src/agent-loop.ts
41
47
  var import_zod = require("zod");
42
- var import_ai = require("@prestyj/ai");
48
+ var import_ai2 = require("@prestyj/ai");
43
49
 
44
50
  // src/local-backend.ts
45
51
  function isLocalBackendUrl(baseUrl) {
@@ -63,6 +69,107 @@ function isLocalBackendUrl(baseUrl) {
63
69
  return false;
64
70
  }
65
71
 
72
+ // src/tool-call-hints.ts
73
+ var import_ai = require("@prestyj/ai");
74
+ function normalizeName(name) {
75
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
76
+ }
77
+ function editDistance(a, b) {
78
+ let beforePrevious = [];
79
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
80
+ for (let i = 1; i <= a.length; i++) {
81
+ const current = [i];
82
+ for (let j = 1; j <= b.length; j++) {
83
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
84
+ let distance = Math.min(
85
+ (previous[j] ?? 0) + 1,
86
+ (current[j - 1] ?? 0) + 1,
87
+ (previous[j - 1] ?? 0) + cost
88
+ );
89
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
90
+ distance = Math.min(distance, (beforePrevious[j - 2] ?? 0) + 1);
91
+ }
92
+ current[j] = distance;
93
+ }
94
+ beforePrevious = previous;
95
+ previous = current;
96
+ }
97
+ return previous[b.length] ?? 0;
98
+ }
99
+ function closestName(name, candidates) {
100
+ const target = normalizeName(name);
101
+ if (!target) return void 0;
102
+ const limit = Math.min(3, Math.max(1, Math.floor(target.length / 3)));
103
+ let best;
104
+ for (const candidate of [...candidates].sort()) {
105
+ if (candidate === name) continue;
106
+ const distance = editDistance(target, normalizeName(candidate));
107
+ if (distance > limit) continue;
108
+ if (!best || distance < best.distance) best = { name: candidate, distance };
109
+ }
110
+ return best?.name;
111
+ }
112
+ function unknownToolMessage(name, available) {
113
+ const suggestion = closestName(name, available);
114
+ if (suggestion) return `Unknown tool: ${name}. Did you mean \`${suggestion}\`?`;
115
+ const names = [...available].sort();
116
+ const listed = names.length > 40 ? `${names.slice(0, 40).join(", ")}, \u2026` : names.join(", ");
117
+ const searchHint = available.includes("tool_search") ? " If the capability is not listed, call `tool_search` to load it." : "";
118
+ return `Unknown tool: ${name}. Available tools: ${listed}.${searchHint}`;
119
+ }
120
+ function isRecord(value) {
121
+ return typeof value === "object" && value !== null && !Array.isArray(value);
122
+ }
123
+ function typeLabel(node) {
124
+ if (!isRecord(node)) return "any";
125
+ if (Array.isArray(node.enum)) {
126
+ return node.enum.map((value) => JSON.stringify(value)).join(" | ");
127
+ }
128
+ const variants = node.anyOf ?? node.oneOf;
129
+ if (Array.isArray(variants)) {
130
+ return [...new Set(variants.map(typeLabel))].filter((t) => t !== "null").join(" | ") || "any";
131
+ }
132
+ if (node.type === "array") return `${typeLabel(node.items)}[]`;
133
+ if (Array.isArray(node.type)) {
134
+ return node.type.filter((t) => t !== "null").join(" | ") || "any";
135
+ }
136
+ return typeof node.type === "string" ? node.type : "any";
137
+ }
138
+ function objectShape(tool) {
139
+ let schema;
140
+ try {
141
+ schema = (0, import_ai.resolveToolSchema)(tool);
142
+ } catch {
143
+ return void 0;
144
+ }
145
+ if (!isRecord(schema) || !isRecord(schema.properties)) return void 0;
146
+ const required = new Set(
147
+ Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []
148
+ );
149
+ return { properties: schema.properties, required };
150
+ }
151
+ function argumentHints(tool, args) {
152
+ const shape = objectShape(tool);
153
+ if (!shape) return "";
154
+ const names = Object.keys(shape.properties);
155
+ if (names.length === 0) return `Expected arguments: none.`;
156
+ const fields = names.map((name) => {
157
+ const optional = shape.required.has(name) ? "" : "?";
158
+ return `${name}${optional}: ${typeLabel(shape.properties[name])}`;
159
+ });
160
+ const lines = [`Expected arguments: { ${fields.join(", ")} }`];
161
+ if (isRecord(args)) {
162
+ for (const key of Object.keys(args).sort()) {
163
+ if (key in shape.properties) continue;
164
+ const suggestion = closestName(key, names);
165
+ lines.push(
166
+ suggestion ? `Unknown field \`${key}\` \u2014 did you mean \`${suggestion}\`?` : `Unknown field \`${key}\` is not a parameter of this tool.`
167
+ );
168
+ }
169
+ }
170
+ return lines.join("\n");
171
+ }
172
+
66
173
  // src/output-ceiling.ts
67
174
  var TTL_MS = 24 * 60 * 60 * 1e3;
68
175
  var MIN_PLAUSIBLE_CEILING = 256;
@@ -117,12 +224,162 @@ function clampOutputTokens(key, requested) {
117
224
  return requested === void 0 ? ceiling : Math.min(requested, ceiling);
118
225
  }
119
226
 
227
+ // src/stream-rules.ts
228
+ var DEFAULT_STREAM_RULE_MAX_RETRIES = 3;
229
+ var DEFAULT_STREAM_RULE_WINDOW_CHARS = 1024;
230
+ var JSON_ESCAPES = {
231
+ n: "\n",
232
+ r: "\r",
233
+ t: " ",
234
+ b: "\b",
235
+ f: "\f",
236
+ '"': '"',
237
+ "\\": "\\",
238
+ "/": "/"
239
+ };
240
+ var JsonEscapeDecoder = class {
241
+ pending = "";
242
+ push(chunk) {
243
+ const input = this.pending + chunk;
244
+ this.pending = "";
245
+ if (!input.includes("\\")) return input;
246
+ let out = "";
247
+ let i = 0;
248
+ while (i < input.length) {
249
+ const ch = input.charAt(i);
250
+ if (ch !== "\\") {
251
+ out += ch;
252
+ i++;
253
+ continue;
254
+ }
255
+ if (i + 1 >= input.length) {
256
+ this.pending = input.slice(i);
257
+ break;
258
+ }
259
+ const next = input.charAt(i + 1);
260
+ if (next === "u") {
261
+ if (i + 6 > input.length) {
262
+ this.pending = input.slice(i);
263
+ break;
264
+ }
265
+ const hex = input.slice(i + 2, i + 6);
266
+ out += /^[0-9a-fA-F]{4}$/.test(hex) ? String.fromCharCode(Number.parseInt(hex, 16)) : input.slice(i, i + 6);
267
+ i += 6;
268
+ continue;
269
+ }
270
+ out += JSON_ESCAPES[next] ?? next;
271
+ i += 2;
272
+ }
273
+ return out;
274
+ }
275
+ };
276
+ var StreamRuleMonitor = class {
277
+ maxRetries;
278
+ rules;
279
+ windowChars;
280
+ fired = /* @__PURE__ */ new Set();
281
+ retries = 0;
282
+ textWindow = "";
283
+ toolStreams = /* @__PURE__ */ new Map();
284
+ constructor(config) {
285
+ this.rules = config.rules;
286
+ this.maxRetries = Math.max(0, config.maxRetries ?? DEFAULT_STREAM_RULE_MAX_RETRIES);
287
+ this.windowChars = Math.max(64, config.windowChars ?? DEFAULT_STREAM_RULE_WINDOW_CHARS);
288
+ }
289
+ /** False once the retry cap is spent or every rule has fired — skip all matching. */
290
+ get active() {
291
+ return this.retries < this.maxRetries && this.fired.size < this.rules.length;
292
+ }
293
+ get retriesUsed() {
294
+ return this.retries;
295
+ }
296
+ beginAttempt() {
297
+ this.textWindow = "";
298
+ this.toolStreams.clear();
299
+ }
300
+ checkText(delta) {
301
+ if (!this.active || !delta) return null;
302
+ const scan = this.textWindow + delta;
303
+ this.textWindow = scan.slice(-this.windowChars);
304
+ const rules = this.match(scan, "text");
305
+ return rules.length > 0 ? { rules, source: "text" } : null;
306
+ }
307
+ checkToolArgs(id, toolName, argsDelta) {
308
+ if (!this.active || !argsDelta) return null;
309
+ let state = this.toolStreams.get(id);
310
+ if (!state) {
311
+ state = { window: "", decoder: new JsonEscapeDecoder() };
312
+ this.toolStreams.set(id, state);
313
+ }
314
+ const scan = state.window + state.decoder.push(argsDelta);
315
+ state.window = scan.slice(-this.windowChars);
316
+ const rules = this.match(scan, "tool", toolName);
317
+ return rules.length > 0 ? { rules, source: "tool", toolName } : null;
318
+ }
319
+ /** Consume one retry and mark the rules fired. Returns the retry ordinal (1-based). */
320
+ recordTrigger(rules) {
321
+ for (const rule of rules) this.fired.add(rule.name);
322
+ this.retries++;
323
+ return this.retries;
324
+ }
325
+ match(scan, source, toolName) {
326
+ const hits = [];
327
+ for (const rule of this.rules) {
328
+ if (this.fired.has(rule.name)) continue;
329
+ if (rule.scope !== "both" && rule.scope !== source) continue;
330
+ if (source === "tool" && rule.tools && toolName !== void 0) {
331
+ if (!rule.tools.includes(toolName)) continue;
332
+ }
333
+ rule.pattern.lastIndex = 0;
334
+ if (rule.pattern.test(scan)) hits.push(rule);
335
+ }
336
+ return hits;
337
+ }
338
+ };
339
+ function buildStreamRuleReminder(rules) {
340
+ const blocks = rules.map(
341
+ (rule) => `<system-reminder reason="stream_rule" rule="${rule.name.replace(/"/g, "'")}">
342
+ Your previous response was interrupted mid-stream because it violated this rule. The interrupted output was discarded: the user never saw it and no tool call from it ran. Regenerate the response from the start and follow the rule:
343
+
344
+ ${rule.reminder.trim()}
345
+ </system-reminder>`
346
+ );
347
+ return {
348
+ role: "user",
349
+ content: blocks.join("\n\n"),
350
+ provenance: { source: "runtime", kind: "notification", visibility: "hidden" }
351
+ };
352
+ }
353
+
120
354
  // src/agent-loop.ts
121
355
  var DEFAULT_MAX_TURNS = 300;
122
356
  var DEFAULT_TOOL_TIMEOUT_MS = 3e5;
357
+ var PREEMPT_GRACE_MS = 500;
358
+ var STEER_INTERRUPTED_TEXT = "Interrupted: the user sent a new message.";
359
+ var ATOMIC_TOOL_NAMES = /* @__PURE__ */ new Set(["edit", "write", "multi_edit", "apply_patch", "notebook_edit"]);
360
+ function isInterruptibleTool(tool, name) {
361
+ if (tool?.interruptible !== void 0) return tool.interruptible;
362
+ return !ATOMIC_TOOL_NAMES.has(name);
363
+ }
364
+ function markSteerInterrupted(record) {
365
+ const content = record.content;
366
+ let next;
367
+ if (typeof content === "string") {
368
+ next = content.trim() ? `${STEER_INTERRUPTED_TEXT}
369
+ Partial output:
370
+ ${content}` : STEER_INTERRUPTED_TEXT;
371
+ } else {
372
+ next = [
373
+ { type: "text", text: `${STEER_INTERRUPTED_TEXT}
374
+ Partial output:` },
375
+ ...content
376
+ ];
377
+ }
378
+ return { toolCallId: record.toolCallId, content: next, isError: true };
379
+ }
123
380
  var _toolRedaction;
124
381
  function toolRedactionOptions() {
125
- _toolRedaction ??= { secrets: (0, import_ai.environmentSecrets)(process.env) };
382
+ _toolRedaction ??= { secrets: (0, import_ai2.environmentSecrets)(process.env) };
126
383
  return _toolRedaction;
127
384
  }
128
385
  var _diagFn = null;
@@ -205,7 +462,7 @@ function isBillingError(err) {
205
462
  if (!(err instanceof Error)) return false;
206
463
  const statusCode = err.statusCode;
207
464
  if (statusCode === 402) return true;
208
- return (0, import_ai.isHardBillingMessage)(err.message);
465
+ return (0, import_ai2.isHardBillingMessage)(err.message);
209
466
  }
210
467
  function isUsageLimitError(err) {
211
468
  if (!(err instanceof Error)) return false;
@@ -430,6 +687,7 @@ async function* agentLoop(messages, options) {
430
687
  let runawayToolcallRetries = 0;
431
688
  let overflowCompactionAttempts = 0;
432
689
  let toolResultTruncationAttempted = false;
690
+ const streamRuleMonitor = options.streamRules && options.streamRules.rules.length > 0 ? new StreamRuleMonitor(options.streamRules) : null;
433
691
  const invalidToolArgumentCounts = /* @__PURE__ */ new Map();
434
692
  let toolArgumentAutoContinueUsed = false;
435
693
  let useNonStreamingFallback = false;
@@ -483,16 +741,7 @@ async function* agentLoop(messages, options) {
483
741
  turn++;
484
742
  if (logicalTurnStartedAt === 0) logicalTurnStartedAt = Date.now();
485
743
  toolMap = new Map((options.tools ?? []).map((t) => [t.name, t]));
486
- let msgChars = 0;
487
- for (const m of messages) {
488
- if (typeof m.content === "string") msgChars += m.content.length;
489
- else if (Array.isArray(m.content)) {
490
- for (const p of m.content) {
491
- if ("text" in p && typeof p.text === "string") msgChars += p.text.length;
492
- if ("content" in p && typeof p.content === "string") msgChars += p.content.length;
493
- }
494
- }
495
- }
744
+ const msgChars = countMessageChars(messages);
496
745
  let firstEventTimeoutMs;
497
746
  let initialHardTimeoutMs;
498
747
  if (baseFirstEventTimeoutMs === STREAM_FIRST_EVENT_TIMEOUT_MS) {
@@ -563,6 +812,9 @@ async function* agentLoop(messages, options) {
563
812
  let toolcallNoProgressCount = 0;
564
813
  let runawayDetected = null;
565
814
  let attemptText = "";
815
+ let streamRuleHit = null;
816
+ let attemptThinkingChars = 0;
817
+ streamRuleMonitor?.beginAttempt();
566
818
  let lastYieldEndTime = Date.now();
567
819
  let maxConsumerLagMs = 0;
568
820
  const forwardAbort = () => streamController.abort();
@@ -629,7 +881,7 @@ async function* agentLoop(messages, options) {
629
881
  });
630
882
  }
631
883
  }
632
- const result = (0, import_ai.stream)({
884
+ const result = (0, import_ai2.stream)({
633
885
  provider: options.provider,
634
886
  model: options.model,
635
887
  messages,
@@ -649,6 +901,7 @@ async function* agentLoop(messages, options) {
649
901
  transportSessionId: options.transportSessionId,
650
902
  projectId: liveProjectId,
651
903
  cacheRetention: options.cacheRetention,
904
+ onContextPrepared: options.onContextPrepared,
652
905
  promptCacheKey: options.promptCacheKey,
653
906
  serviceTier: options.serviceTier,
654
907
  supportsImages: options.supportsImages,
@@ -726,8 +979,14 @@ async function* agentLoop(messages, options) {
726
979
  }
727
980
  if (event.type === "text_delta") {
728
981
  attemptText += event.text;
982
+ streamRuleHit = streamRuleMonitor?.checkText(event.text) ?? null;
983
+ if (streamRuleHit) {
984
+ streamController.abort();
985
+ break;
986
+ }
729
987
  yield { type: "text_delta", text: event.text };
730
988
  } else if (event.type === "thinking_delta") {
989
+ attemptThinkingChars += event.text.length;
731
990
  yield { type: "thinking_delta", text: event.text };
732
991
  } else if (event.type === "server_toolcall") {
733
992
  yield {
@@ -748,6 +1007,11 @@ async function* agentLoop(messages, options) {
748
1007
  toolcallDeltaChars += chunkChars;
749
1008
  toolcallDeltaCount++;
750
1009
  toolcallNoProgressCount = chunkChars > 0 ? 0 : toolcallNoProgressCount + 1;
1010
+ streamRuleHit = streamRuleMonitor?.checkToolArgs(event.id, event.name, event.argsJson ?? "") ?? null;
1011
+ if (streamRuleHit) {
1012
+ streamController.abort();
1013
+ break;
1014
+ }
751
1015
  if (!runawayDetected && (toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS || toolcallNoProgressCount > MAX_TOOLCALL_NO_PROGRESS_EVENTS)) {
752
1016
  runawayDetected = {
753
1017
  kind: toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS ? "chars" : "events",
@@ -770,6 +1034,54 @@ async function* agentLoop(messages, options) {
770
1034
  lastYieldEndTime = Date.now();
771
1035
  resetIdleTimer();
772
1036
  }
1037
+ if (streamRuleHit && streamRuleMonitor) {
1038
+ const attempt = streamRuleMonitor.recordTrigger(streamRuleHit.rules);
1039
+ const ruleNames = streamRuleHit.rules.map((rule) => rule.name);
1040
+ const priorPrompt = latestProviderUsage ? latestProviderUsage.inputTokens + (latestProviderUsage.cacheRead ?? 0) + (latestProviderUsage.cacheWrite ?? 0) : 0;
1041
+ const newChars = priorPrompt > 0 && usageAnchorIndex !== void 0 ? countMessageChars(messages.slice(usageAnchorIndex + 1)) : msgChars;
1042
+ const abortedUsage = {
1043
+ inputTokens: Math.ceil(newChars / 4),
1044
+ outputTokens: Math.ceil(
1045
+ (attemptText.length + attemptThinkingChars + toolcallDeltaChars) / 4
1046
+ ),
1047
+ ...priorPrompt > 0 ? { cacheRead: priorPrompt } : {}
1048
+ };
1049
+ totalUsage.inputTokens += abortedUsage.inputTokens;
1050
+ totalUsage.outputTokens += abortedUsage.outputTokens;
1051
+ if (abortedUsage.cacheRead) {
1052
+ totalUsage.cacheRead = (totalUsage.cacheRead ?? 0) + abortedUsage.cacheRead;
1053
+ }
1054
+ diag("stream_rule_triggered", {
1055
+ rules: ruleNames.join(","),
1056
+ source: streamRuleHit.source,
1057
+ toolName: streamRuleHit.toolName,
1058
+ attempt,
1059
+ maxAttempts: streamRuleMonitor.maxRetries,
1060
+ discardedChars: attemptText.length + toolcallDeltaChars,
1061
+ provider: options.provider,
1062
+ model: options.model
1063
+ });
1064
+ messages.push(buildStreamRuleReminder(streamRuleHit.rules));
1065
+ yield {
1066
+ type: "stream_rule_triggered",
1067
+ rules: ruleNames,
1068
+ source: streamRuleHit.source,
1069
+ ...streamRuleHit.toolName !== void 0 ? { toolName: streamRuleHit.toolName } : {},
1070
+ attempt,
1071
+ maxAttempts: streamRuleMonitor.maxRetries,
1072
+ usage: abortedUsage
1073
+ };
1074
+ yield {
1075
+ type: "retry",
1076
+ reason: "stream_rule",
1077
+ attempt,
1078
+ maxAttempts: streamRuleMonitor.maxRetries,
1079
+ delayMs: 0,
1080
+ silent: true
1081
+ };
1082
+ turn--;
1083
+ continue;
1084
+ }
773
1085
  diag("stream_done", {
774
1086
  events: streamEventCount,
775
1087
  totalMs: Date.now() - streamCallStart,
@@ -961,7 +1273,7 @@ async function* agentLoop(messages, options) {
961
1273
  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`;
962
1274
  yield {
963
1275
  type: "error",
964
- error: new import_ai.EZCoderAIError(
1276
+ error: new import_ai2.EZCoderAIError(
965
1277
  `The model repeatedly failed to close a tool call after ${MAX_RUNAWAY_TOOLCALL_RETRIES} automatic retries (${detail}). Your conversation is preserved.`,
966
1278
  {
967
1279
  source: "provider",
@@ -1043,7 +1355,7 @@ async function* agentLoop(messages, options) {
1043
1355
  });
1044
1356
  yield {
1045
1357
  type: "error",
1046
- error: new import_ai.EZCoderAIError(
1358
+ error: new import_ai2.EZCoderAIError(
1047
1359
  `The connection to the API provider stopped responding after ${MAX_STALL_RETRIES} automatic retries. Your conversation is preserved.`,
1048
1360
  {
1049
1361
  source: "network",
@@ -1276,10 +1588,14 @@ async function* agentLoop(messages, options) {
1276
1588
  fatalToolArgumentRecoverable = recoverable;
1277
1589
  fatalToolArgumentToolName = toolName;
1278
1590
  };
1591
+ const preempt = new AbortController();
1592
+ const unsubscribeSteering = options.onSteeringAvailable?.(() => preempt.abort());
1279
1593
  const executionOptions = {
1280
1594
  signal: options.signal,
1595
+ preemptSignal: options.onSteeringAvailable ? preempt.signal : void 0,
1281
1596
  maxToolResultChars: options.maxToolResultChars,
1282
1597
  maxTurnToolResultChars: options.maxTurnToolResultChars,
1598
+ transformToolResult: options.transformToolResult,
1283
1599
  toolMap,
1284
1600
  invalidToolArgumentCounts,
1285
1601
  markFatalToolArgumentError,
@@ -1288,7 +1604,15 @@ async function* agentLoop(messages, options) {
1288
1604
  const hasSequentialToolCall = toolCalls.some(
1289
1605
  (toolCall) => toolMap.get(toolCall.name)?.executionMode === "sequential"
1290
1606
  );
1291
- const executionResult = hasSequentialToolCall ? yield* executeToolCallsMixed(toolCalls, toolResults, executionOptions) : yield* executeToolCallsParallel(toolCalls, toolResults, executionOptions);
1607
+ let executionResult;
1608
+ try {
1609
+ executionResult = hasSequentialToolCall ? yield* executeToolCallsMixed(toolCalls, toolResults, executionOptions) : yield* executeToolCallsParallel(toolCalls, toolResults, executionOptions);
1610
+ } finally {
1611
+ unsubscribeSteering?.();
1612
+ }
1613
+ if (executionResult.preempted) {
1614
+ diag("steer_preempted_tools", { turn, provider: options.provider, model: options.model });
1615
+ }
1292
1616
  messages.push({ role: "tool", content: executionResult.toolResults });
1293
1617
  yield { type: "checkpoint", turn };
1294
1618
  const toolsAborted = executionResult.aborted;
@@ -1445,7 +1769,7 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1445
1769
  options.seenToolCalls.add(signature);
1446
1770
  }
1447
1771
  if (!tool) {
1448
- resultContent = `Unknown tool: ${toolCall.name}`;
1772
+ resultContent = unknownToolMessage(toolCall.name, [...options.toolMap.keys()]);
1449
1773
  isError = true;
1450
1774
  } else {
1451
1775
  try {
@@ -1462,8 +1786,12 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1462
1786
  }
1463
1787
  const callerSignal = options.signal;
1464
1788
  const toolTimeout = AbortSignal.timeout(tool.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS);
1789
+ const preemptSignal = isInterruptibleTool(tool, toolCall.name) ? options.preemptSignal : void 0;
1790
+ const toolSignals = [callerSignal, toolTimeout, preemptSignal].filter(
1791
+ (s) => s !== void 0
1792
+ );
1465
1793
  const ctx = {
1466
- signal: callerSignal ? AbortSignal.any([callerSignal, toolTimeout]) : toolTimeout,
1794
+ signal: toolSignals.length === 1 ? toolTimeout : AbortSignal.any(toolSignals),
1467
1795
  toolCallId: toolCall.id,
1468
1796
  onUpdate: (update) => {
1469
1797
  pushEvent({
@@ -1473,13 +1801,25 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1473
1801
  });
1474
1802
  }
1475
1803
  };
1804
+ const hardSignals = [callerSignal, toolTimeout].filter(
1805
+ (s) => s !== void 0
1806
+ );
1476
1807
  const raw = await abortablePromise(
1477
1808
  Promise.resolve().then(() => tool.execute(parsed, ctx)),
1478
- ctx.signal
1809
+ hardSignals.length === 1 ? toolTimeout : AbortSignal.any(hardSignals)
1479
1810
  );
1480
1811
  const normalized = normalizeToolResult(raw);
1481
- resultContent = (0, import_ai.redactValue)(normalized.content, toolRedactionOptions());
1482
- details = (0, import_ai.redactValue)(normalized.details, toolRedactionOptions());
1812
+ resultContent = (0, import_ai2.redactValue)(normalized.content, toolRedactionOptions());
1813
+ details = (0, import_ai2.redactValue)(normalized.details, toolRedactionOptions());
1814
+ if (options.transformToolResult) {
1815
+ try {
1816
+ resultContent = options.transformToolResult(
1817
+ { name: toolCall.name, args: toolCall.args },
1818
+ resultContent
1819
+ );
1820
+ } catch {
1821
+ }
1822
+ }
1483
1823
  for (const key of options.invalidToolArgumentCounts.keys()) {
1484
1824
  if (key.startsWith(`${toolCall.name}:`)) options.invalidToolArgumentCounts.delete(key);
1485
1825
  }
@@ -1491,12 +1831,14 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1491
1831
  const failureCount = (options.invalidToolArgumentCounts.get(failureKey) ?? 0) + 1;
1492
1832
  options.invalidToolArgumentCounts.set(failureKey, failureCount);
1493
1833
  invalidArgAttempt = failureCount;
1834
+ const hints = argumentHints(tool, toolCall.args);
1494
1835
  resultContent = `Invalid arguments for tool \`${toolCall.name}\`:
1495
- ` + prettyError + "\nRe-issue the call with each field as the correct type.";
1836
+ ` + prettyError + (hints ? `
1837
+ ${hints}` : "") + "\nRe-issue the call with each field as the correct type.";
1496
1838
  if (failureCount >= 3) {
1497
1839
  const recoverable = Object.keys(toolCall.args ?? {}).length === 0;
1498
1840
  options.markFatalToolArgumentError(
1499
- new import_ai.EZCoderAIError(
1841
+ new import_ai2.EZCoderAIError(
1500
1842
  `The model repeatedly issued invalid arguments for tool \`${toolCall.name}\`. This is usually an upstream model/tool-calling bug` + (recoverable ? " (the provider's stream returned empty tool-call arguments)" : "") + `. Your conversation is preserved; send another message or switch models to continue.`,
1501
1843
  {
1502
1844
  source: "provider",
@@ -1510,15 +1852,24 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1510
1852
  } else if (options.signal?.aborted && isAbortError(err)) {
1511
1853
  resultContent = indeterminateOutcomeText(toolCall.name);
1512
1854
  } else {
1513
- resultContent = (0, import_ai.redactValue)(
1855
+ resultContent = (0, import_ai2.redactValue)(
1514
1856
  err instanceof Error ? err.message : String(err),
1515
1857
  toolRedactionOptions()
1516
1858
  );
1517
1859
  }
1518
1860
  }
1519
1861
  }
1520
- resultContent = (0, import_ai.redactValue)(resultContent, toolRedactionOptions());
1521
- details = (0, import_ai.redactValue)(details, toolRedactionOptions());
1862
+ resultContent = (0, import_ai2.redactValue)(resultContent, toolRedactionOptions());
1863
+ details = (0, import_ai2.redactValue)(details, toolRedactionOptions());
1864
+ if (options.preemptSignal?.aborted && isInterruptibleTool(options.toolMap.get(toolCall.name), toolCall.name)) {
1865
+ const marked = markSteerInterrupted({
1866
+ toolCallId: toolCall.id,
1867
+ content: resultContent,
1868
+ isError
1869
+ });
1870
+ resultContent = marked.content;
1871
+ isError = true;
1872
+ }
1522
1873
  const durationMs = Date.now() - startTime;
1523
1874
  pushEvent({
1524
1875
  type: "tool_call_end",
@@ -1531,13 +1882,67 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1531
1882
  });
1532
1883
  return { toolCallId: toolCall.id, content: resultContent, isError };
1533
1884
  }
1885
+ function createPreemptTracker(options, eventStream, state, resultsById, dispatchedIds) {
1886
+ const running = /* @__PURE__ */ new Map();
1887
+ let graceTimer;
1888
+ const preemptSignal = options.preemptSignal;
1889
+ const abandon = () => {
1890
+ graceTimer = void 0;
1891
+ for (const [id, entry] of running) {
1892
+ if (!entry.interruptible || resultsById.has(id)) continue;
1893
+ resultsById.set(id, { toolCallId: id, content: STEER_INTERRUPTED_TEXT, isError: true });
1894
+ pushToolEvent(eventStream, state, {
1895
+ type: "tool_call_end",
1896
+ toolCallId: id,
1897
+ result: STEER_INTERRUPTED_TEXT,
1898
+ isError: true,
1899
+ durationMs: Date.now() - entry.startedAt
1900
+ });
1901
+ running.delete(id);
1902
+ }
1903
+ if (running.size === 0) eventStream.close();
1904
+ };
1905
+ const onPreempt = () => {
1906
+ if (graceTimer === void 0) graceTimer = setTimeout(abandon, PREEMPT_GRACE_MS);
1907
+ };
1908
+ preemptSignal?.addEventListener("abort", onPreempt, { once: true });
1909
+ return {
1910
+ async run(toolCall) {
1911
+ if (options.preemptSignal?.aborted) return;
1912
+ dispatchedIds.add(toolCall.id);
1913
+ const interruptible = isInterruptibleTool(options.toolMap.get(toolCall.name), toolCall.name);
1914
+ running.set(toolCall.id, { toolCall, interruptible, startedAt: Date.now() });
1915
+ const record = await executeSingleToolCall(
1916
+ toolCall,
1917
+ options,
1918
+ (event) => pushToolEvent(eventStream, state, event)
1919
+ );
1920
+ running.delete(toolCall.id);
1921
+ if (resultsById.has(toolCall.id)) return;
1922
+ const alreadyMarked = typeof record.content === "string" ? record.content.startsWith(STEER_INTERRUPTED_TEXT) : record.content[0]?.type === "text" && record.content[0].text.startsWith(STEER_INTERRUPTED_TEXT);
1923
+ resultsById.set(
1924
+ toolCall.id,
1925
+ interruptible && preemptSignal?.aborted && !alreadyMarked ? markSteerInterrupted(record) : record
1926
+ );
1927
+ if (preemptSignal?.aborted && graceTimer === void 0 && running.size === 0) {
1928
+ eventStream.close();
1929
+ }
1930
+ },
1931
+ preempted: () => preemptSignal?.aborted === true,
1932
+ dispose: () => {
1933
+ if (graceTimer !== void 0) clearTimeout(graceTimer);
1934
+ preemptSignal?.removeEventListener("abort", onPreempt);
1935
+ }
1936
+ };
1937
+ }
1534
1938
  async function* executeToolCallsMixed(toolCalls, initialToolResults, options) {
1535
- const eventStream = new import_ai.EventStream();
1939
+ const eventStream = new import_ai2.EventStream();
1536
1940
  const state = { finalized: false };
1537
1941
  const resultsById = /* @__PURE__ */ new Map();
1538
1942
  const dispatchedIds = /* @__PURE__ */ new Set();
1539
1943
  const abortHandler = () => eventStream.abort(new Error("aborted"));
1540
1944
  options.signal?.addEventListener("abort", abortHandler, { once: true });
1945
+ const tracker = createPreemptTracker(options, eventStream, state, resultsById, dispatchedIds);
1541
1946
  const phases = [];
1542
1947
  let currentParallel = [];
1543
1948
  for (const toolCall of toolCalls) {
@@ -1558,40 +1963,18 @@ async function* executeToolCallsMixed(toolCalls, initialToolResults, options) {
1558
1963
  void (async () => {
1559
1964
  try {
1560
1965
  for (const phase of phases) {
1561
- if (options.signal?.aborted) break;
1966
+ if (options.signal?.aborted || options.preemptSignal?.aborted) break;
1562
1967
  if (phase.sequential) {
1563
1968
  const signature = JSON.stringify([
1564
1969
  phase.sequential.name,
1565
1970
  canonicalToolArgs(phase.sequential.args)
1566
1971
  ]);
1567
1972
  if (!options.seenToolCalls.has(signature)) options.seenToolCalls.clear();
1568
- dispatchedIds.add(phase.sequential.id);
1569
- const record = await executeSingleToolCall(
1570
- phase.sequential,
1571
- options,
1572
- (event) => pushToolEvent(eventStream, state, event)
1573
- );
1574
- resultsById.set(record.toolCallId, record);
1973
+ await tracker.run(phase.sequential);
1575
1974
  } else if (phase.parallel.length === 1) {
1576
- dispatchedIds.add(phase.parallel[0].id);
1577
- const record = await executeSingleToolCall(
1578
- phase.parallel[0],
1579
- options,
1580
- (event) => pushToolEvent(eventStream, state, event)
1581
- );
1582
- resultsById.set(record.toolCallId, record);
1975
+ await tracker.run(phase.parallel[0]);
1583
1976
  } else {
1584
- await Promise.all(
1585
- phase.parallel.map(async (toolCall) => {
1586
- dispatchedIds.add(toolCall.id);
1587
- const record = await executeSingleToolCall(
1588
- toolCall,
1589
- options,
1590
- (event) => pushToolEvent(eventStream, state, event)
1591
- );
1592
- resultsById.set(record.toolCallId, record);
1593
- })
1594
- );
1977
+ await Promise.all(phase.parallel.map((toolCall) => tracker.run(toolCall)));
1595
1978
  }
1596
1979
  }
1597
1980
  if (!state.finalized) eventStream.close();
@@ -1612,31 +1995,23 @@ async function* executeToolCallsMixed(toolCalls, initialToolResults, options) {
1612
1995
  }
1613
1996
  } finally {
1614
1997
  options.signal?.removeEventListener("abort", abortHandler);
1998
+ tracker.dispose();
1615
1999
  state.finalized = true;
1616
2000
  }
1617
2001
  const toolResults = buildToolResults(initialToolResults, toolCalls, resultsById, dispatchedIds);
1618
2002
  capToolResults(toolResults, options.maxToolResultChars);
1619
2003
  capTurnToolResults(toolResults, options.maxTurnToolResultChars);
1620
- return { toolResults, aborted };
2004
+ return { toolResults, aborted, preempted: !aborted && tracker.preempted() };
1621
2005
  }
1622
2006
  async function* executeToolCallsParallel(toolCalls, initialToolResults, options) {
1623
- const eventStream = new import_ai.EventStream();
2007
+ const eventStream = new import_ai2.EventStream();
1624
2008
  const state = { finalized: false };
1625
2009
  const resultsById = /* @__PURE__ */ new Map();
1626
2010
  const dispatchedIds = /* @__PURE__ */ new Set();
1627
2011
  const abortHandler = () => eventStream.abort(new Error("aborted"));
1628
2012
  options.signal?.addEventListener("abort", abortHandler, { once: true });
1629
- Promise.all(
1630
- toolCalls.map(async (toolCall) => {
1631
- dispatchedIds.add(toolCall.id);
1632
- const record = await executeSingleToolCall(
1633
- toolCall,
1634
- options,
1635
- (event) => pushToolEvent(eventStream, state, event)
1636
- );
1637
- resultsById.set(record.toolCallId, record);
1638
- })
1639
- ).then(() => {
2013
+ const tracker = createPreemptTracker(options, eventStream, state, resultsById, dispatchedIds);
2014
+ Promise.all(toolCalls.map((toolCall) => tracker.run(toolCall))).then(() => {
1640
2015
  if (!state.finalized) eventStream.close();
1641
2016
  }).catch((err) => {
1642
2017
  if (!state.finalized) eventStream.abort(err instanceof Error ? err : new Error(String(err)));
@@ -1654,12 +2029,13 @@ async function* executeToolCallsParallel(toolCalls, initialToolResults, options)
1654
2029
  }
1655
2030
  } finally {
1656
2031
  options.signal?.removeEventListener("abort", abortHandler);
2032
+ tracker.dispose();
1657
2033
  state.finalized = true;
1658
2034
  }
1659
2035
  const toolResults = buildToolResults(initialToolResults, toolCalls, resultsById, dispatchedIds);
1660
2036
  capToolResults(toolResults, options.maxToolResultChars);
1661
2037
  capTurnToolResults(toolResults, options.maxTurnToolResultChars);
1662
- return { toolResults, aborted };
2038
+ return { toolResults, aborted, preempted: !aborted && tracker.preempted() };
1663
2039
  }
1664
2040
  function cancelledBeforeStartText(name) {
1665
2041
  return `\`${name}\` was cancelled before it started, so it had no effect. Safe to retry.`;
@@ -1699,8 +2075,8 @@ function capToolResults(toolResults, maxToolResultChars) {
1699
2075
  const originalChars = toolResult.content.length;
1700
2076
  const headChars = Math.floor(max * 0.7);
1701
2077
  const tailChars = max - headChars;
1702
- const head = (0, import_ai.sliceHead)(toolResult.content, headChars);
1703
- const tail = (0, import_ai.sliceTail)(toolResult.content, tailChars);
2078
+ const head = (0, import_ai2.sliceHead)(toolResult.content, headChars);
2079
+ const tail = (0, import_ai2.sliceTail)(toolResult.content, tailChars);
1704
2080
  const omitted = originalChars - head.length - tail.length;
1705
2081
  toolResult.content = head + `
1706
2082
 
@@ -1736,11 +2112,11 @@ function capTurnToolResults(toolResults, maxTurnToolResultChars) {
1736
2112
  const headChars = Math.floor(fairShare * 0.7);
1737
2113
  const tailChars = fairShare - headChars;
1738
2114
  const omitted = originalChars - fairShare;
1739
- toolResult.content = (0, import_ai.sliceHead)(toolResult.content, headChars) + `
2115
+ toolResult.content = (0, import_ai2.sliceHead)(toolResult.content, headChars) + `
1740
2116
 
1741
2117
  [... ${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 ...]
1742
2118
 
1743
- ` + (0, import_ai.sliceTail)(toolResult.content, tailChars);
2119
+ ` + (0, import_ai2.sliceTail)(toolResult.content, tailChars);
1744
2120
  toolResult.capped = {
1745
2121
  originalChars: toolResult.capped?.originalChars ?? originalChars,
1746
2122
  keptChars: toolResult.content.length,
@@ -1759,8 +2135,8 @@ function truncateToolResultText(text, maxChars) {
1759
2135
  if (text.length <= maxChars) return text;
1760
2136
  const tailChars = Math.min(Math.floor(maxChars * 0.3), 2e4);
1761
2137
  const headChars = Math.max(maxChars - tailChars, 0);
1762
- const head = (0, import_ai.sliceHead)(text, headChars);
1763
- const tail = (0, import_ai.sliceTail)(text, tailChars);
2138
+ const head = (0, import_ai2.sliceHead)(text, headChars);
2139
+ const tail = (0, import_ai2.sliceTail)(text, tailChars);
1764
2140
  const omitted = text.length - head.length - tail.length;
1765
2141
  return `${head}
1766
2142
 
@@ -1826,6 +2202,19 @@ function sanitizeOrphanedServerTools(messages) {
1826
2202
  break;
1827
2203
  }
1828
2204
  }
2205
+ function countMessageChars(messages) {
2206
+ let chars = 0;
2207
+ for (const m of messages) {
2208
+ if (typeof m.content === "string") chars += m.content.length;
2209
+ else if (Array.isArray(m.content)) {
2210
+ for (const p of m.content) {
2211
+ if ("text" in p && typeof p.text === "string") chars += p.text.length;
2212
+ if ("content" in p && typeof p.content === "string") chars += p.content.length;
2213
+ }
2214
+ }
2215
+ }
2216
+ return chars;
2217
+ }
1829
2218
  function repairToolPairingAdjacent(messages) {
1830
2219
  for (let i = 0; i < messages.length; i++) {
1831
2220
  const msg = messages[i];
@@ -1896,7 +2285,7 @@ var AgentStream = class {
1896
2285
  rejectResult;
1897
2286
  hasConsumer = false;
1898
2287
  constructor(generator, onDone) {
1899
- this.events = new import_ai2.EventStream();
2288
+ this.events = new import_ai3.EventStream();
1900
2289
  this.resultPromise = new Promise((resolve, reject) => {
1901
2290
  this.resolveResult = resolve;
1902
2291
  this.rejectResult = reject;
@@ -1942,6 +2331,7 @@ var Agent = class {
1942
2331
  options;
1943
2332
  steeringQueue = [];
1944
2333
  followUpQueue = [];
2334
+ steeringListeners = /* @__PURE__ */ new Set();
1945
2335
  constructor(options) {
1946
2336
  this.options = options;
1947
2337
  if (options.system) {
@@ -1966,9 +2356,14 @@ var Agent = class {
1966
2356
  get running() {
1967
2357
  return this._running;
1968
2358
  }
1969
- /** Queue a steering message for injection after current tool execution completes. */
2359
+ /**
2360
+ * Queue a steering message. Instant interrupt: if tools are running, the
2361
+ * interruptible ones are preempted right away and the message is injected
2362
+ * before the next model call (the turn continues — unlike abort/Stop).
2363
+ */
1970
2364
  steer(msg) {
1971
2365
  this.steeringQueue.push(msg);
2366
+ for (const listener of [...this.steeringListeners]) listener();
1972
2367
  }
1973
2368
  /** Queue a follow-up message for injection when the agent would otherwise stop. */
1974
2369
  followUp(msg) {
@@ -1988,6 +2383,14 @@ var Agent = class {
1988
2383
  const all = [...callerResult ?? [], ...queued];
1989
2384
  return all.length > 0 ? all : null;
1990
2385
  },
2386
+ onSteeringAvailable: (listener) => {
2387
+ this.steeringListeners.add(listener);
2388
+ const unsubscribeCaller = this.options.onSteeringAvailable?.(listener);
2389
+ return () => {
2390
+ this.steeringListeners.delete(listener);
2391
+ unsubscribeCaller?.();
2392
+ };
2393
+ },
1991
2394
  getFollowUpMessages: async () => {
1992
2395
  const callerResult = await this.options.getFollowUpMessages?.() ?? [];
1993
2396
  const queued = this.followUpQueue.splice(0);
@@ -2005,7 +2408,12 @@ var Agent = class {
2005
2408
  0 && (module.exports = {
2006
2409
  Agent,
2007
2410
  AgentStream,
2411
+ DEFAULT_STREAM_RULE_MAX_RETRIES,
2412
+ DEFAULT_STREAM_RULE_WINDOW_CHARS,
2413
+ JsonEscapeDecoder,
2414
+ StreamRuleMonitor,
2008
2415
  agentLoop,
2416
+ buildStreamRuleReminder,
2009
2417
  cancelledBeforeStartText,
2010
2418
  indeterminateOutcomeText,
2011
2419
  isAbortError,
@@ -2013,6 +2421,7 @@ var Agent = class {
2013
2421
  isContextOverflow,
2014
2422
  isLocalBackendUrl,
2015
2423
  isUsageLimitError,
2424
+ repairToolPairingAdjacent,
2016
2425
  setStreamDiagnostic
2017
2426
  });
2018
2427
  //# sourceMappingURL=index.cjs.map