@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.js CHANGED
@@ -36,6 +36,107 @@ function isLocalBackendUrl(baseUrl) {
36
36
  return false;
37
37
  }
38
38
 
39
+ // src/tool-call-hints.ts
40
+ import { resolveToolSchema } from "@prestyj/ai";
41
+ function normalizeName(name) {
42
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
43
+ }
44
+ function editDistance(a, b) {
45
+ let beforePrevious = [];
46
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
47
+ for (let i = 1; i <= a.length; i++) {
48
+ const current = [i];
49
+ for (let j = 1; j <= b.length; j++) {
50
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
51
+ let distance = Math.min(
52
+ (previous[j] ?? 0) + 1,
53
+ (current[j - 1] ?? 0) + 1,
54
+ (previous[j - 1] ?? 0) + cost
55
+ );
56
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
57
+ distance = Math.min(distance, (beforePrevious[j - 2] ?? 0) + 1);
58
+ }
59
+ current[j] = distance;
60
+ }
61
+ beforePrevious = previous;
62
+ previous = current;
63
+ }
64
+ return previous[b.length] ?? 0;
65
+ }
66
+ function closestName(name, candidates) {
67
+ const target = normalizeName(name);
68
+ if (!target) return void 0;
69
+ const limit = Math.min(3, Math.max(1, Math.floor(target.length / 3)));
70
+ let best;
71
+ for (const candidate of [...candidates].sort()) {
72
+ if (candidate === name) continue;
73
+ const distance = editDistance(target, normalizeName(candidate));
74
+ if (distance > limit) continue;
75
+ if (!best || distance < best.distance) best = { name: candidate, distance };
76
+ }
77
+ return best?.name;
78
+ }
79
+ function unknownToolMessage(name, available) {
80
+ const suggestion = closestName(name, available);
81
+ if (suggestion) return `Unknown tool: ${name}. Did you mean \`${suggestion}\`?`;
82
+ const names = [...available].sort();
83
+ const listed = names.length > 40 ? `${names.slice(0, 40).join(", ")}, \u2026` : names.join(", ");
84
+ const searchHint = available.includes("tool_search") ? " If the capability is not listed, call `tool_search` to load it." : "";
85
+ return `Unknown tool: ${name}. Available tools: ${listed}.${searchHint}`;
86
+ }
87
+ function isRecord(value) {
88
+ return typeof value === "object" && value !== null && !Array.isArray(value);
89
+ }
90
+ function typeLabel(node) {
91
+ if (!isRecord(node)) return "any";
92
+ if (Array.isArray(node.enum)) {
93
+ return node.enum.map((value) => JSON.stringify(value)).join(" | ");
94
+ }
95
+ const variants = node.anyOf ?? node.oneOf;
96
+ if (Array.isArray(variants)) {
97
+ return [...new Set(variants.map(typeLabel))].filter((t) => t !== "null").join(" | ") || "any";
98
+ }
99
+ if (node.type === "array") return `${typeLabel(node.items)}[]`;
100
+ if (Array.isArray(node.type)) {
101
+ return node.type.filter((t) => t !== "null").join(" | ") || "any";
102
+ }
103
+ return typeof node.type === "string" ? node.type : "any";
104
+ }
105
+ function objectShape(tool) {
106
+ let schema;
107
+ try {
108
+ schema = resolveToolSchema(tool);
109
+ } catch {
110
+ return void 0;
111
+ }
112
+ if (!isRecord(schema) || !isRecord(schema.properties)) return void 0;
113
+ const required = new Set(
114
+ Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []
115
+ );
116
+ return { properties: schema.properties, required };
117
+ }
118
+ function argumentHints(tool, args) {
119
+ const shape = objectShape(tool);
120
+ if (!shape) return "";
121
+ const names = Object.keys(shape.properties);
122
+ if (names.length === 0) return `Expected arguments: none.`;
123
+ const fields = names.map((name) => {
124
+ const optional = shape.required.has(name) ? "" : "?";
125
+ return `${name}${optional}: ${typeLabel(shape.properties[name])}`;
126
+ });
127
+ const lines = [`Expected arguments: { ${fields.join(", ")} }`];
128
+ if (isRecord(args)) {
129
+ for (const key of Object.keys(args).sort()) {
130
+ if (key in shape.properties) continue;
131
+ const suggestion = closestName(key, names);
132
+ lines.push(
133
+ suggestion ? `Unknown field \`${key}\` \u2014 did you mean \`${suggestion}\`?` : `Unknown field \`${key}\` is not a parameter of this tool.`
134
+ );
135
+ }
136
+ }
137
+ return lines.join("\n");
138
+ }
139
+
39
140
  // src/output-ceiling.ts
40
141
  var TTL_MS = 24 * 60 * 60 * 1e3;
41
142
  var MIN_PLAUSIBLE_CEILING = 256;
@@ -90,9 +191,159 @@ function clampOutputTokens(key, requested) {
90
191
  return requested === void 0 ? ceiling : Math.min(requested, ceiling);
91
192
  }
92
193
 
194
+ // src/stream-rules.ts
195
+ var DEFAULT_STREAM_RULE_MAX_RETRIES = 3;
196
+ var DEFAULT_STREAM_RULE_WINDOW_CHARS = 1024;
197
+ var JSON_ESCAPES = {
198
+ n: "\n",
199
+ r: "\r",
200
+ t: " ",
201
+ b: "\b",
202
+ f: "\f",
203
+ '"': '"',
204
+ "\\": "\\",
205
+ "/": "/"
206
+ };
207
+ var JsonEscapeDecoder = class {
208
+ pending = "";
209
+ push(chunk) {
210
+ const input = this.pending + chunk;
211
+ this.pending = "";
212
+ if (!input.includes("\\")) return input;
213
+ let out = "";
214
+ let i = 0;
215
+ while (i < input.length) {
216
+ const ch = input.charAt(i);
217
+ if (ch !== "\\") {
218
+ out += ch;
219
+ i++;
220
+ continue;
221
+ }
222
+ if (i + 1 >= input.length) {
223
+ this.pending = input.slice(i);
224
+ break;
225
+ }
226
+ const next = input.charAt(i + 1);
227
+ if (next === "u") {
228
+ if (i + 6 > input.length) {
229
+ this.pending = input.slice(i);
230
+ break;
231
+ }
232
+ const hex = input.slice(i + 2, i + 6);
233
+ out += /^[0-9a-fA-F]{4}$/.test(hex) ? String.fromCharCode(Number.parseInt(hex, 16)) : input.slice(i, i + 6);
234
+ i += 6;
235
+ continue;
236
+ }
237
+ out += JSON_ESCAPES[next] ?? next;
238
+ i += 2;
239
+ }
240
+ return out;
241
+ }
242
+ };
243
+ var StreamRuleMonitor = class {
244
+ maxRetries;
245
+ rules;
246
+ windowChars;
247
+ fired = /* @__PURE__ */ new Set();
248
+ retries = 0;
249
+ textWindow = "";
250
+ toolStreams = /* @__PURE__ */ new Map();
251
+ constructor(config) {
252
+ this.rules = config.rules;
253
+ this.maxRetries = Math.max(0, config.maxRetries ?? DEFAULT_STREAM_RULE_MAX_RETRIES);
254
+ this.windowChars = Math.max(64, config.windowChars ?? DEFAULT_STREAM_RULE_WINDOW_CHARS);
255
+ }
256
+ /** False once the retry cap is spent or every rule has fired — skip all matching. */
257
+ get active() {
258
+ return this.retries < this.maxRetries && this.fired.size < this.rules.length;
259
+ }
260
+ get retriesUsed() {
261
+ return this.retries;
262
+ }
263
+ beginAttempt() {
264
+ this.textWindow = "";
265
+ this.toolStreams.clear();
266
+ }
267
+ checkText(delta) {
268
+ if (!this.active || !delta) return null;
269
+ const scan = this.textWindow + delta;
270
+ this.textWindow = scan.slice(-this.windowChars);
271
+ const rules = this.match(scan, "text");
272
+ return rules.length > 0 ? { rules, source: "text" } : null;
273
+ }
274
+ checkToolArgs(id, toolName, argsDelta) {
275
+ if (!this.active || !argsDelta) return null;
276
+ let state = this.toolStreams.get(id);
277
+ if (!state) {
278
+ state = { window: "", decoder: new JsonEscapeDecoder() };
279
+ this.toolStreams.set(id, state);
280
+ }
281
+ const scan = state.window + state.decoder.push(argsDelta);
282
+ state.window = scan.slice(-this.windowChars);
283
+ const rules = this.match(scan, "tool", toolName);
284
+ return rules.length > 0 ? { rules, source: "tool", toolName } : null;
285
+ }
286
+ /** Consume one retry and mark the rules fired. Returns the retry ordinal (1-based). */
287
+ recordTrigger(rules) {
288
+ for (const rule of rules) this.fired.add(rule.name);
289
+ this.retries++;
290
+ return this.retries;
291
+ }
292
+ match(scan, source, toolName) {
293
+ const hits = [];
294
+ for (const rule of this.rules) {
295
+ if (this.fired.has(rule.name)) continue;
296
+ if (rule.scope !== "both" && rule.scope !== source) continue;
297
+ if (source === "tool" && rule.tools && toolName !== void 0) {
298
+ if (!rule.tools.includes(toolName)) continue;
299
+ }
300
+ rule.pattern.lastIndex = 0;
301
+ if (rule.pattern.test(scan)) hits.push(rule);
302
+ }
303
+ return hits;
304
+ }
305
+ };
306
+ function buildStreamRuleReminder(rules) {
307
+ const blocks = rules.map(
308
+ (rule) => `<system-reminder reason="stream_rule" rule="${rule.name.replace(/"/g, "'")}">
309
+ 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:
310
+
311
+ ${rule.reminder.trim()}
312
+ </system-reminder>`
313
+ );
314
+ return {
315
+ role: "user",
316
+ content: blocks.join("\n\n"),
317
+ provenance: { source: "runtime", kind: "notification", visibility: "hidden" }
318
+ };
319
+ }
320
+
93
321
  // src/agent-loop.ts
94
322
  var DEFAULT_MAX_TURNS = 300;
95
323
  var DEFAULT_TOOL_TIMEOUT_MS = 3e5;
324
+ var PREEMPT_GRACE_MS = 500;
325
+ var STEER_INTERRUPTED_TEXT = "Interrupted: the user sent a new message.";
326
+ var ATOMIC_TOOL_NAMES = /* @__PURE__ */ new Set(["edit", "write", "multi_edit", "apply_patch", "notebook_edit"]);
327
+ function isInterruptibleTool(tool, name) {
328
+ if (tool?.interruptible !== void 0) return tool.interruptible;
329
+ return !ATOMIC_TOOL_NAMES.has(name);
330
+ }
331
+ function markSteerInterrupted(record) {
332
+ const content = record.content;
333
+ let next;
334
+ if (typeof content === "string") {
335
+ next = content.trim() ? `${STEER_INTERRUPTED_TEXT}
336
+ Partial output:
337
+ ${content}` : STEER_INTERRUPTED_TEXT;
338
+ } else {
339
+ next = [
340
+ { type: "text", text: `${STEER_INTERRUPTED_TEXT}
341
+ Partial output:` },
342
+ ...content
343
+ ];
344
+ }
345
+ return { toolCallId: record.toolCallId, content: next, isError: true };
346
+ }
96
347
  var _toolRedaction;
97
348
  function toolRedactionOptions() {
98
349
  _toolRedaction ??= { secrets: environmentSecrets(process.env) };
@@ -403,6 +654,7 @@ async function* agentLoop(messages, options) {
403
654
  let runawayToolcallRetries = 0;
404
655
  let overflowCompactionAttempts = 0;
405
656
  let toolResultTruncationAttempted = false;
657
+ const streamRuleMonitor = options.streamRules && options.streamRules.rules.length > 0 ? new StreamRuleMonitor(options.streamRules) : null;
406
658
  const invalidToolArgumentCounts = /* @__PURE__ */ new Map();
407
659
  let toolArgumentAutoContinueUsed = false;
408
660
  let useNonStreamingFallback = false;
@@ -456,16 +708,7 @@ async function* agentLoop(messages, options) {
456
708
  turn++;
457
709
  if (logicalTurnStartedAt === 0) logicalTurnStartedAt = Date.now();
458
710
  toolMap = new Map((options.tools ?? []).map((t) => [t.name, t]));
459
- let msgChars = 0;
460
- for (const m of messages) {
461
- if (typeof m.content === "string") msgChars += m.content.length;
462
- else if (Array.isArray(m.content)) {
463
- for (const p of m.content) {
464
- if ("text" in p && typeof p.text === "string") msgChars += p.text.length;
465
- if ("content" in p && typeof p.content === "string") msgChars += p.content.length;
466
- }
467
- }
468
- }
711
+ const msgChars = countMessageChars(messages);
469
712
  let firstEventTimeoutMs;
470
713
  let initialHardTimeoutMs;
471
714
  if (baseFirstEventTimeoutMs === STREAM_FIRST_EVENT_TIMEOUT_MS) {
@@ -536,6 +779,9 @@ async function* agentLoop(messages, options) {
536
779
  let toolcallNoProgressCount = 0;
537
780
  let runawayDetected = null;
538
781
  let attemptText = "";
782
+ let streamRuleHit = null;
783
+ let attemptThinkingChars = 0;
784
+ streamRuleMonitor?.beginAttempt();
539
785
  let lastYieldEndTime = Date.now();
540
786
  let maxConsumerLagMs = 0;
541
787
  const forwardAbort = () => streamController.abort();
@@ -622,6 +868,7 @@ async function* agentLoop(messages, options) {
622
868
  transportSessionId: options.transportSessionId,
623
869
  projectId: liveProjectId,
624
870
  cacheRetention: options.cacheRetention,
871
+ onContextPrepared: options.onContextPrepared,
625
872
  promptCacheKey: options.promptCacheKey,
626
873
  serviceTier: options.serviceTier,
627
874
  supportsImages: options.supportsImages,
@@ -699,8 +946,14 @@ async function* agentLoop(messages, options) {
699
946
  }
700
947
  if (event.type === "text_delta") {
701
948
  attemptText += event.text;
949
+ streamRuleHit = streamRuleMonitor?.checkText(event.text) ?? null;
950
+ if (streamRuleHit) {
951
+ streamController.abort();
952
+ break;
953
+ }
702
954
  yield { type: "text_delta", text: event.text };
703
955
  } else if (event.type === "thinking_delta") {
956
+ attemptThinkingChars += event.text.length;
704
957
  yield { type: "thinking_delta", text: event.text };
705
958
  } else if (event.type === "server_toolcall") {
706
959
  yield {
@@ -721,6 +974,11 @@ async function* agentLoop(messages, options) {
721
974
  toolcallDeltaChars += chunkChars;
722
975
  toolcallDeltaCount++;
723
976
  toolcallNoProgressCount = chunkChars > 0 ? 0 : toolcallNoProgressCount + 1;
977
+ streamRuleHit = streamRuleMonitor?.checkToolArgs(event.id, event.name, event.argsJson ?? "") ?? null;
978
+ if (streamRuleHit) {
979
+ streamController.abort();
980
+ break;
981
+ }
724
982
  if (!runawayDetected && (toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS || toolcallNoProgressCount > MAX_TOOLCALL_NO_PROGRESS_EVENTS)) {
725
983
  runawayDetected = {
726
984
  kind: toolcallDeltaChars > MAX_TOOLCALL_DELTA_CHARS ? "chars" : "events",
@@ -743,6 +1001,54 @@ async function* agentLoop(messages, options) {
743
1001
  lastYieldEndTime = Date.now();
744
1002
  resetIdleTimer();
745
1003
  }
1004
+ if (streamRuleHit && streamRuleMonitor) {
1005
+ const attempt = streamRuleMonitor.recordTrigger(streamRuleHit.rules);
1006
+ const ruleNames = streamRuleHit.rules.map((rule) => rule.name);
1007
+ const priorPrompt = latestProviderUsage ? latestProviderUsage.inputTokens + (latestProviderUsage.cacheRead ?? 0) + (latestProviderUsage.cacheWrite ?? 0) : 0;
1008
+ const newChars = priorPrompt > 0 && usageAnchorIndex !== void 0 ? countMessageChars(messages.slice(usageAnchorIndex + 1)) : msgChars;
1009
+ const abortedUsage = {
1010
+ inputTokens: Math.ceil(newChars / 4),
1011
+ outputTokens: Math.ceil(
1012
+ (attemptText.length + attemptThinkingChars + toolcallDeltaChars) / 4
1013
+ ),
1014
+ ...priorPrompt > 0 ? { cacheRead: priorPrompt } : {}
1015
+ };
1016
+ totalUsage.inputTokens += abortedUsage.inputTokens;
1017
+ totalUsage.outputTokens += abortedUsage.outputTokens;
1018
+ if (abortedUsage.cacheRead) {
1019
+ totalUsage.cacheRead = (totalUsage.cacheRead ?? 0) + abortedUsage.cacheRead;
1020
+ }
1021
+ diag("stream_rule_triggered", {
1022
+ rules: ruleNames.join(","),
1023
+ source: streamRuleHit.source,
1024
+ toolName: streamRuleHit.toolName,
1025
+ attempt,
1026
+ maxAttempts: streamRuleMonitor.maxRetries,
1027
+ discardedChars: attemptText.length + toolcallDeltaChars,
1028
+ provider: options.provider,
1029
+ model: options.model
1030
+ });
1031
+ messages.push(buildStreamRuleReminder(streamRuleHit.rules));
1032
+ yield {
1033
+ type: "stream_rule_triggered",
1034
+ rules: ruleNames,
1035
+ source: streamRuleHit.source,
1036
+ ...streamRuleHit.toolName !== void 0 ? { toolName: streamRuleHit.toolName } : {},
1037
+ attempt,
1038
+ maxAttempts: streamRuleMonitor.maxRetries,
1039
+ usage: abortedUsage
1040
+ };
1041
+ yield {
1042
+ type: "retry",
1043
+ reason: "stream_rule",
1044
+ attempt,
1045
+ maxAttempts: streamRuleMonitor.maxRetries,
1046
+ delayMs: 0,
1047
+ silent: true
1048
+ };
1049
+ turn--;
1050
+ continue;
1051
+ }
746
1052
  diag("stream_done", {
747
1053
  events: streamEventCount,
748
1054
  totalMs: Date.now() - streamCallStart,
@@ -1249,10 +1555,14 @@ async function* agentLoop(messages, options) {
1249
1555
  fatalToolArgumentRecoverable = recoverable;
1250
1556
  fatalToolArgumentToolName = toolName;
1251
1557
  };
1558
+ const preempt = new AbortController();
1559
+ const unsubscribeSteering = options.onSteeringAvailable?.(() => preempt.abort());
1252
1560
  const executionOptions = {
1253
1561
  signal: options.signal,
1562
+ preemptSignal: options.onSteeringAvailable ? preempt.signal : void 0,
1254
1563
  maxToolResultChars: options.maxToolResultChars,
1255
1564
  maxTurnToolResultChars: options.maxTurnToolResultChars,
1565
+ transformToolResult: options.transformToolResult,
1256
1566
  toolMap,
1257
1567
  invalidToolArgumentCounts,
1258
1568
  markFatalToolArgumentError,
@@ -1261,7 +1571,15 @@ async function* agentLoop(messages, options) {
1261
1571
  const hasSequentialToolCall = toolCalls.some(
1262
1572
  (toolCall) => toolMap.get(toolCall.name)?.executionMode === "sequential"
1263
1573
  );
1264
- const executionResult = hasSequentialToolCall ? yield* executeToolCallsMixed(toolCalls, toolResults, executionOptions) : yield* executeToolCallsParallel(toolCalls, toolResults, executionOptions);
1574
+ let executionResult;
1575
+ try {
1576
+ executionResult = hasSequentialToolCall ? yield* executeToolCallsMixed(toolCalls, toolResults, executionOptions) : yield* executeToolCallsParallel(toolCalls, toolResults, executionOptions);
1577
+ } finally {
1578
+ unsubscribeSteering?.();
1579
+ }
1580
+ if (executionResult.preempted) {
1581
+ diag("steer_preempted_tools", { turn, provider: options.provider, model: options.model });
1582
+ }
1265
1583
  messages.push({ role: "tool", content: executionResult.toolResults });
1266
1584
  yield { type: "checkpoint", turn };
1267
1585
  const toolsAborted = executionResult.aborted;
@@ -1418,7 +1736,7 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1418
1736
  options.seenToolCalls.add(signature);
1419
1737
  }
1420
1738
  if (!tool) {
1421
- resultContent = `Unknown tool: ${toolCall.name}`;
1739
+ resultContent = unknownToolMessage(toolCall.name, [...options.toolMap.keys()]);
1422
1740
  isError = true;
1423
1741
  } else {
1424
1742
  try {
@@ -1435,8 +1753,12 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1435
1753
  }
1436
1754
  const callerSignal = options.signal;
1437
1755
  const toolTimeout = AbortSignal.timeout(tool.timeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS);
1756
+ const preemptSignal = isInterruptibleTool(tool, toolCall.name) ? options.preemptSignal : void 0;
1757
+ const toolSignals = [callerSignal, toolTimeout, preemptSignal].filter(
1758
+ (s) => s !== void 0
1759
+ );
1438
1760
  const ctx = {
1439
- signal: callerSignal ? AbortSignal.any([callerSignal, toolTimeout]) : toolTimeout,
1761
+ signal: toolSignals.length === 1 ? toolTimeout : AbortSignal.any(toolSignals),
1440
1762
  toolCallId: toolCall.id,
1441
1763
  onUpdate: (update) => {
1442
1764
  pushEvent({
@@ -1446,13 +1768,25 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1446
1768
  });
1447
1769
  }
1448
1770
  };
1771
+ const hardSignals = [callerSignal, toolTimeout].filter(
1772
+ (s) => s !== void 0
1773
+ );
1449
1774
  const raw = await abortablePromise(
1450
1775
  Promise.resolve().then(() => tool.execute(parsed, ctx)),
1451
- ctx.signal
1776
+ hardSignals.length === 1 ? toolTimeout : AbortSignal.any(hardSignals)
1452
1777
  );
1453
1778
  const normalized = normalizeToolResult(raw);
1454
1779
  resultContent = redactValue(normalized.content, toolRedactionOptions());
1455
1780
  details = redactValue(normalized.details, toolRedactionOptions());
1781
+ if (options.transformToolResult) {
1782
+ try {
1783
+ resultContent = options.transformToolResult(
1784
+ { name: toolCall.name, args: toolCall.args },
1785
+ resultContent
1786
+ );
1787
+ } catch {
1788
+ }
1789
+ }
1456
1790
  for (const key of options.invalidToolArgumentCounts.keys()) {
1457
1791
  if (key.startsWith(`${toolCall.name}:`)) options.invalidToolArgumentCounts.delete(key);
1458
1792
  }
@@ -1464,8 +1798,10 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1464
1798
  const failureCount = (options.invalidToolArgumentCounts.get(failureKey) ?? 0) + 1;
1465
1799
  options.invalidToolArgumentCounts.set(failureKey, failureCount);
1466
1800
  invalidArgAttempt = failureCount;
1801
+ const hints = argumentHints(tool, toolCall.args);
1467
1802
  resultContent = `Invalid arguments for tool \`${toolCall.name}\`:
1468
- ` + prettyError + "\nRe-issue the call with each field as the correct type.";
1803
+ ` + prettyError + (hints ? `
1804
+ ${hints}` : "") + "\nRe-issue the call with each field as the correct type.";
1469
1805
  if (failureCount >= 3) {
1470
1806
  const recoverable = Object.keys(toolCall.args ?? {}).length === 0;
1471
1807
  options.markFatalToolArgumentError(
@@ -1492,6 +1828,15 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1492
1828
  }
1493
1829
  resultContent = redactValue(resultContent, toolRedactionOptions());
1494
1830
  details = redactValue(details, toolRedactionOptions());
1831
+ if (options.preemptSignal?.aborted && isInterruptibleTool(options.toolMap.get(toolCall.name), toolCall.name)) {
1832
+ const marked = markSteerInterrupted({
1833
+ toolCallId: toolCall.id,
1834
+ content: resultContent,
1835
+ isError
1836
+ });
1837
+ resultContent = marked.content;
1838
+ isError = true;
1839
+ }
1495
1840
  const durationMs = Date.now() - startTime;
1496
1841
  pushEvent({
1497
1842
  type: "tool_call_end",
@@ -1504,6 +1849,59 @@ async function executeSingleToolCall(toolCall, options, pushEvent) {
1504
1849
  });
1505
1850
  return { toolCallId: toolCall.id, content: resultContent, isError };
1506
1851
  }
1852
+ function createPreemptTracker(options, eventStream, state, resultsById, dispatchedIds) {
1853
+ const running = /* @__PURE__ */ new Map();
1854
+ let graceTimer;
1855
+ const preemptSignal = options.preemptSignal;
1856
+ const abandon = () => {
1857
+ graceTimer = void 0;
1858
+ for (const [id, entry] of running) {
1859
+ if (!entry.interruptible || resultsById.has(id)) continue;
1860
+ resultsById.set(id, { toolCallId: id, content: STEER_INTERRUPTED_TEXT, isError: true });
1861
+ pushToolEvent(eventStream, state, {
1862
+ type: "tool_call_end",
1863
+ toolCallId: id,
1864
+ result: STEER_INTERRUPTED_TEXT,
1865
+ isError: true,
1866
+ durationMs: Date.now() - entry.startedAt
1867
+ });
1868
+ running.delete(id);
1869
+ }
1870
+ if (running.size === 0) eventStream.close();
1871
+ };
1872
+ const onPreempt = () => {
1873
+ if (graceTimer === void 0) graceTimer = setTimeout(abandon, PREEMPT_GRACE_MS);
1874
+ };
1875
+ preemptSignal?.addEventListener("abort", onPreempt, { once: true });
1876
+ return {
1877
+ async run(toolCall) {
1878
+ if (options.preemptSignal?.aborted) return;
1879
+ dispatchedIds.add(toolCall.id);
1880
+ const interruptible = isInterruptibleTool(options.toolMap.get(toolCall.name), toolCall.name);
1881
+ running.set(toolCall.id, { toolCall, interruptible, startedAt: Date.now() });
1882
+ const record = await executeSingleToolCall(
1883
+ toolCall,
1884
+ options,
1885
+ (event) => pushToolEvent(eventStream, state, event)
1886
+ );
1887
+ running.delete(toolCall.id);
1888
+ if (resultsById.has(toolCall.id)) return;
1889
+ 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);
1890
+ resultsById.set(
1891
+ toolCall.id,
1892
+ interruptible && preemptSignal?.aborted && !alreadyMarked ? markSteerInterrupted(record) : record
1893
+ );
1894
+ if (preemptSignal?.aborted && graceTimer === void 0 && running.size === 0) {
1895
+ eventStream.close();
1896
+ }
1897
+ },
1898
+ preempted: () => preemptSignal?.aborted === true,
1899
+ dispose: () => {
1900
+ if (graceTimer !== void 0) clearTimeout(graceTimer);
1901
+ preemptSignal?.removeEventListener("abort", onPreempt);
1902
+ }
1903
+ };
1904
+ }
1507
1905
  async function* executeToolCallsMixed(toolCalls, initialToolResults, options) {
1508
1906
  const eventStream = new EventStream();
1509
1907
  const state = { finalized: false };
@@ -1511,6 +1909,7 @@ async function* executeToolCallsMixed(toolCalls, initialToolResults, options) {
1511
1909
  const dispatchedIds = /* @__PURE__ */ new Set();
1512
1910
  const abortHandler = () => eventStream.abort(new Error("aborted"));
1513
1911
  options.signal?.addEventListener("abort", abortHandler, { once: true });
1912
+ const tracker = createPreemptTracker(options, eventStream, state, resultsById, dispatchedIds);
1514
1913
  const phases = [];
1515
1914
  let currentParallel = [];
1516
1915
  for (const toolCall of toolCalls) {
@@ -1531,40 +1930,18 @@ async function* executeToolCallsMixed(toolCalls, initialToolResults, options) {
1531
1930
  void (async () => {
1532
1931
  try {
1533
1932
  for (const phase of phases) {
1534
- if (options.signal?.aborted) break;
1933
+ if (options.signal?.aborted || options.preemptSignal?.aborted) break;
1535
1934
  if (phase.sequential) {
1536
1935
  const signature = JSON.stringify([
1537
1936
  phase.sequential.name,
1538
1937
  canonicalToolArgs(phase.sequential.args)
1539
1938
  ]);
1540
1939
  if (!options.seenToolCalls.has(signature)) options.seenToolCalls.clear();
1541
- dispatchedIds.add(phase.sequential.id);
1542
- const record = await executeSingleToolCall(
1543
- phase.sequential,
1544
- options,
1545
- (event) => pushToolEvent(eventStream, state, event)
1546
- );
1547
- resultsById.set(record.toolCallId, record);
1940
+ await tracker.run(phase.sequential);
1548
1941
  } else if (phase.parallel.length === 1) {
1549
- dispatchedIds.add(phase.parallel[0].id);
1550
- const record = await executeSingleToolCall(
1551
- phase.parallel[0],
1552
- options,
1553
- (event) => pushToolEvent(eventStream, state, event)
1554
- );
1555
- resultsById.set(record.toolCallId, record);
1942
+ await tracker.run(phase.parallel[0]);
1556
1943
  } else {
1557
- await Promise.all(
1558
- phase.parallel.map(async (toolCall) => {
1559
- dispatchedIds.add(toolCall.id);
1560
- const record = await executeSingleToolCall(
1561
- toolCall,
1562
- options,
1563
- (event) => pushToolEvent(eventStream, state, event)
1564
- );
1565
- resultsById.set(record.toolCallId, record);
1566
- })
1567
- );
1944
+ await Promise.all(phase.parallel.map((toolCall) => tracker.run(toolCall)));
1568
1945
  }
1569
1946
  }
1570
1947
  if (!state.finalized) eventStream.close();
@@ -1585,12 +1962,13 @@ async function* executeToolCallsMixed(toolCalls, initialToolResults, options) {
1585
1962
  }
1586
1963
  } finally {
1587
1964
  options.signal?.removeEventListener("abort", abortHandler);
1965
+ tracker.dispose();
1588
1966
  state.finalized = true;
1589
1967
  }
1590
1968
  const toolResults = buildToolResults(initialToolResults, toolCalls, resultsById, dispatchedIds);
1591
1969
  capToolResults(toolResults, options.maxToolResultChars);
1592
1970
  capTurnToolResults(toolResults, options.maxTurnToolResultChars);
1593
- return { toolResults, aborted };
1971
+ return { toolResults, aborted, preempted: !aborted && tracker.preempted() };
1594
1972
  }
1595
1973
  async function* executeToolCallsParallel(toolCalls, initialToolResults, options) {
1596
1974
  const eventStream = new EventStream();
@@ -1599,17 +1977,8 @@ async function* executeToolCallsParallel(toolCalls, initialToolResults, options)
1599
1977
  const dispatchedIds = /* @__PURE__ */ new Set();
1600
1978
  const abortHandler = () => eventStream.abort(new Error("aborted"));
1601
1979
  options.signal?.addEventListener("abort", abortHandler, { once: true });
1602
- Promise.all(
1603
- toolCalls.map(async (toolCall) => {
1604
- dispatchedIds.add(toolCall.id);
1605
- const record = await executeSingleToolCall(
1606
- toolCall,
1607
- options,
1608
- (event) => pushToolEvent(eventStream, state, event)
1609
- );
1610
- resultsById.set(record.toolCallId, record);
1611
- })
1612
- ).then(() => {
1980
+ const tracker = createPreemptTracker(options, eventStream, state, resultsById, dispatchedIds);
1981
+ Promise.all(toolCalls.map((toolCall) => tracker.run(toolCall))).then(() => {
1613
1982
  if (!state.finalized) eventStream.close();
1614
1983
  }).catch((err) => {
1615
1984
  if (!state.finalized) eventStream.abort(err instanceof Error ? err : new Error(String(err)));
@@ -1627,12 +1996,13 @@ async function* executeToolCallsParallel(toolCalls, initialToolResults, options)
1627
1996
  }
1628
1997
  } finally {
1629
1998
  options.signal?.removeEventListener("abort", abortHandler);
1999
+ tracker.dispose();
1630
2000
  state.finalized = true;
1631
2001
  }
1632
2002
  const toolResults = buildToolResults(initialToolResults, toolCalls, resultsById, dispatchedIds);
1633
2003
  capToolResults(toolResults, options.maxToolResultChars);
1634
2004
  capTurnToolResults(toolResults, options.maxTurnToolResultChars);
1635
- return { toolResults, aborted };
2005
+ return { toolResults, aborted, preempted: !aborted && tracker.preempted() };
1636
2006
  }
1637
2007
  function cancelledBeforeStartText(name) {
1638
2008
  return `\`${name}\` was cancelled before it started, so it had no effect. Safe to retry.`;
@@ -1799,6 +2169,19 @@ function sanitizeOrphanedServerTools(messages) {
1799
2169
  break;
1800
2170
  }
1801
2171
  }
2172
+ function countMessageChars(messages) {
2173
+ let chars = 0;
2174
+ for (const m of messages) {
2175
+ if (typeof m.content === "string") chars += m.content.length;
2176
+ else if (Array.isArray(m.content)) {
2177
+ for (const p of m.content) {
2178
+ if ("text" in p && typeof p.text === "string") chars += p.text.length;
2179
+ if ("content" in p && typeof p.content === "string") chars += p.content.length;
2180
+ }
2181
+ }
2182
+ }
2183
+ return chars;
2184
+ }
1802
2185
  function repairToolPairingAdjacent(messages) {
1803
2186
  for (let i = 0; i < messages.length; i++) {
1804
2187
  const msg = messages[i];
@@ -1915,6 +2298,7 @@ var Agent = class {
1915
2298
  options;
1916
2299
  steeringQueue = [];
1917
2300
  followUpQueue = [];
2301
+ steeringListeners = /* @__PURE__ */ new Set();
1918
2302
  constructor(options) {
1919
2303
  this.options = options;
1920
2304
  if (options.system) {
@@ -1939,9 +2323,14 @@ var Agent = class {
1939
2323
  get running() {
1940
2324
  return this._running;
1941
2325
  }
1942
- /** Queue a steering message for injection after current tool execution completes. */
2326
+ /**
2327
+ * Queue a steering message. Instant interrupt: if tools are running, the
2328
+ * interruptible ones are preempted right away and the message is injected
2329
+ * before the next model call (the turn continues — unlike abort/Stop).
2330
+ */
1943
2331
  steer(msg) {
1944
2332
  this.steeringQueue.push(msg);
2333
+ for (const listener of [...this.steeringListeners]) listener();
1945
2334
  }
1946
2335
  /** Queue a follow-up message for injection when the agent would otherwise stop. */
1947
2336
  followUp(msg) {
@@ -1961,6 +2350,14 @@ var Agent = class {
1961
2350
  const all = [...callerResult ?? [], ...queued];
1962
2351
  return all.length > 0 ? all : null;
1963
2352
  },
2353
+ onSteeringAvailable: (listener) => {
2354
+ this.steeringListeners.add(listener);
2355
+ const unsubscribeCaller = this.options.onSteeringAvailable?.(listener);
2356
+ return () => {
2357
+ this.steeringListeners.delete(listener);
2358
+ unsubscribeCaller?.();
2359
+ };
2360
+ },
1964
2361
  getFollowUpMessages: async () => {
1965
2362
  const callerResult = await this.options.getFollowUpMessages?.() ?? [];
1966
2363
  const queued = this.followUpQueue.splice(0);
@@ -1977,7 +2374,12 @@ var Agent = class {
1977
2374
  export {
1978
2375
  Agent,
1979
2376
  AgentStream,
2377
+ DEFAULT_STREAM_RULE_MAX_RETRIES,
2378
+ DEFAULT_STREAM_RULE_WINDOW_CHARS,
2379
+ JsonEscapeDecoder,
2380
+ StreamRuleMonitor,
1980
2381
  agentLoop,
2382
+ buildStreamRuleReminder,
1981
2383
  cancelledBeforeStartText,
1982
2384
  indeterminateOutcomeText,
1983
2385
  isAbortError,
@@ -1985,6 +2387,7 @@ export {
1985
2387
  isContextOverflow,
1986
2388
  isLocalBackendUrl,
1987
2389
  isUsageLimitError,
2390
+ repairToolPairingAdjacent,
1988
2391
  setStreamDiagnostic
1989
2392
  };
1990
2393
  //# sourceMappingURL=index.js.map