agent-accelerator 0.3.0 → 0.4.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.
@@ -3,11 +3,49 @@ import type { StreamEvent, AgentResponse } from "../types/response.ts";
3
3
  /** Listener invoked for a matching stream event type or `*`. */
4
4
  export type StreamEventListener = (event: StreamEvent) => void;
5
5
 
6
+ /** Options for AssistantMessageEventStream (Q-29). */
7
+ export interface EventStreamOptions {
8
+ /**
9
+ * When false, `partialText`/`partialThinking` cumulative fields are stripped
10
+ * from events before delivery. Default true for compat (providers attach
11
+ * cumulative partials; consumers like wrapThinking rely on them).
12
+ */
13
+ includePartial?: boolean;
14
+ /** Max buffered events when no iterator is attached. Default 1000. Oldest dropped. */
15
+ maxBufferedEvents?: number;
16
+ /** Max approx bytes for a single delta event. Default 1MB. Oversized dropped. */
17
+ maxEventBytes?: number;
18
+ }
19
+
6
20
  type Resolver = {
7
21
  resolve: (value: IteratorResult<StreamEvent>) => void;
8
22
  reject: (err: Error) => void;
9
23
  };
10
24
 
25
+ /** Default max buffered events (Q-29). */
26
+ export const MAX_BUFFERED_EVENTS = 1000;
27
+ /** Default max approx bytes per delta event (Q-29). */
28
+ export const MAX_EVENT_BYTES = 1_048_576;
29
+
30
+ function approxEventBytes(event: StreamEvent): number {
31
+ let n = 0;
32
+ if (typeof event.delta === "string") n += event.delta.length;
33
+ if (typeof event.thinkingDelta === "string") n += event.thinkingDelta.length;
34
+ if (typeof event.partialText === "string") n += event.partialText.length;
35
+ if (typeof event.partialThinking === "string") n += event.partialThinking.length;
36
+ return n;
37
+ }
38
+
39
+ function reportStreamAnomaly(detail: unknown): void {
40
+ // why: telemetry hook only, never console — streams are hot paths and
41
+ // logging per-drop would spam and break no-console policy (Q-29/Q-41).
42
+ try {
43
+ (globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
44
+ } catch {
45
+ // why: anomaly reporting must never break the stream itself.
46
+ }
47
+ }
48
+
11
49
  /** Async-iterable event stream returned by Agent.stream and streaming Agent.run. */
12
50
  export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
13
51
  private queue: StreamEvent[] = [];
@@ -20,6 +58,13 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
20
58
  private finalResultPromise: Promise<AgentResponse>;
21
59
  private resolveFinalResult!: (result: AgentResponse) => void;
22
60
  private rejectFinalResult!: (err: Error) => void;
61
+ private readonly includePartial: boolean;
62
+ private readonly maxBufferedEvents: number;
63
+ private readonly maxEventBytes: number;
64
+ /** First listener exception captured for inspection (Q-29/Q-41). */
65
+ private listenerError: Error | null = null;
66
+ /** Count of events dropped by bounds (buffer cap / oversize / unbuffered). */
67
+ private droppedEvents = 0;
23
68
 
24
69
  /**
25
70
  * Creates an empty event stream. `Agent.stream()` creates and completes
@@ -31,7 +76,16 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
31
76
  * stream.on("text_delta", (event) => process.stdout.write(event.delta ?? ""));
32
77
  * ```
33
78
  */
34
- constructor() {
79
+ constructor(opts?: EventStreamOptions) {
80
+ this.includePartial = opts?.includePartial !== false;
81
+ this.maxBufferedEvents =
82
+ typeof opts?.maxBufferedEvents === "number" && Number.isFinite(opts.maxBufferedEvents) && opts.maxBufferedEvents > 0
83
+ ? Math.floor(opts.maxBufferedEvents)
84
+ : MAX_BUFFERED_EVENTS;
85
+ this.maxEventBytes =
86
+ typeof opts?.maxEventBytes === "number" && Number.isFinite(opts.maxEventBytes) && opts.maxEventBytes > 0
87
+ ? Math.floor(opts.maxEventBytes)
88
+ : MAX_EVENT_BYTES;
35
89
  this.finalResultPromise = new Promise<AgentResponse>((resolve, reject) => {
36
90
  this.resolveFinalResult = resolve;
37
91
  this.rejectFinalResult = reject;
@@ -40,70 +94,128 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
40
94
  this.finalResultPromise.catch(() => {});
41
95
  }
42
96
 
97
+ /** First listener exception, if any listener threw (stored, never logged). */
98
+ getListenerError(): Error | null {
99
+ return this.listenerError;
100
+ }
101
+
102
+ /** Number of events dropped by Q-29 bounds. */
103
+ getDroppedCount(): number {
104
+ return this.droppedEvents;
105
+ }
106
+
43
107
  /** Publishes an event to listeners and async iterators. */
44
108
  push(event: StreamEvent): void {
45
109
  if (this.finished) return;
46
110
 
47
- if (event.type === "error") {
48
- this.error = (event.error instanceof Error ? event.error : new Error(String(event.error))) ?? new Error("Unknown error in stream");
49
- // Reject final result but don't finish iterator yet — let iterators receive error event then fail
50
- // Still deliver error event to listeners/consumers before marking finished? Keep consistent with agent-accel: error terminates stream
51
- try { this.rejectFinalResult(this.error); } catch {}
111
+ // Q-29: 1MB oversize guard — drop oversized deltas (no console).
112
+ if (approxEventBytes(event) > this.maxEventBytes) {
113
+ this.droppedEvents++;
114
+ reportStreamAnomaly({ type: "stream_event_oversize", eventType: event.type });
115
+ return;
52
116
  }
53
117
 
54
- // Trigger registered event listener callbacks
55
- const specificListeners = this.listeners.get(event.type);
56
- if (specificListeners) {
57
- for (const listener of specificListeners) {
58
- try { listener(event); } catch {}
59
- }
60
- }
61
- const allListeners = this.listeners.get("*");
62
- if (allListeners) {
63
- for (const listener of allListeners) {
64
- try { listener(event); } catch {}
65
- }
118
+ let out: StreamEvent = event;
119
+ if (!this.includePartial && (event.partialText !== undefined || event.partialThinking !== undefined)) {
120
+ out = { ...event };
121
+ delete (out as { partialText?: unknown }).partialText;
122
+ delete (out as { partialThinking?: unknown }).partialThinking;
66
123
  }
67
124
 
68
- if (event.type === "error") {
69
- // Mark finished after delivering error event, reject pending resolvers
125
+ if (out.type === "error") {
126
+ this.error = (out.error instanceof Error ? out.error : new Error(String(out.error))) ?? new Error("Unknown error in stream");
127
+ // Single error semantic (Q-29): deliver the error event to listeners,
128
+ // reject result(), mark finished, reject pending iterators, and queue
129
+ // the error event so a late iterator observes it before failing.
130
+ this.deliverToListeners(out);
131
+ try { this.rejectFinalResult(this.error); } catch {
132
+ // why: rejecting an already-settled promise helper must not throw.
133
+ }
70
134
  this.finished = true;
71
135
  while (this.resolvers.length > 0) {
72
136
  const r = this.resolvers.shift()!;
73
- // First deliver error event if they were waiting, then reject next call
74
- // For simplicity, reject immediately — consumer will see error via result() or next()
75
137
  r.reject(this.error!);
76
138
  }
77
- // Also queue error event for later pulls if no resolver
78
- if (this.queue.length === 0) this.queue.push(event);
79
- else this.queue.push(event);
139
+ this.enqueueBounded(out);
80
140
  return;
81
141
  }
82
142
 
143
+ // Trigger registered event listener callbacks
144
+ this.deliverToListeners(out);
145
+
83
146
  if (this.resolvers.length > 0) {
84
147
  const resolver = this.resolvers.shift()!;
85
- resolver.resolve({ value: event, done: false });
148
+ resolver.resolve({ value: out, done: false });
86
149
  } else {
87
- this.queue.push(event);
150
+ // No waiter right now (typical between `next()` polls of a burst):
151
+ // buffer for the iterator with a hard cap (Q-29). Overflow drops the
152
+ // oldest with an anomaly report — see `enqueueBounded`.
153
+ this.enqueueBounded(out);
154
+ }
155
+ }
156
+
157
+ private deliverToListeners(event: StreamEvent): void {
158
+ const specificListeners = this.listeners.get(event.type);
159
+ if (specificListeners) {
160
+ for (const listener of specificListeners) {
161
+ try { listener(event); } catch (e) {
162
+ // why: one bad listener must not break the stream or other
163
+ // listeners; capture the first exception for inspection instead.
164
+ if (!this.listenerError) {
165
+ this.listenerError = e instanceof Error ? e : new Error(String(e));
166
+ }
167
+ }
168
+ }
169
+ }
170
+ const allListeners = this.listeners.get("*");
171
+ if (allListeners) {
172
+ for (const listener of allListeners) {
173
+ try { listener(event); } catch (e) {
174
+ // why: same as above — isolate wildcard-listener failures.
175
+ if (!this.listenerError) {
176
+ this.listenerError = e instanceof Error ? e : new Error(String(e));
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ private enqueueBounded(event: StreamEvent): void {
184
+ this.queue.push(event);
185
+ while (this.queue.length > this.maxBufferedEvents) {
186
+ // Drop oldest buffered event to bound memory (Q-29). Error events are
187
+ // never dropped: if the oldest is an error, drop the next-oldest
188
+ // non-error instead; if the queue is all errors, keep them.
189
+ if (this.queue[0]?.type === "error") {
190
+ const idx = this.queue.findIndex((e) => e.type !== "error");
191
+ if (idx === -1) break;
192
+ this.queue.splice(idx, 1);
193
+ } else {
194
+ this.queue.shift();
195
+ }
196
+ this.droppedEvents++;
197
+ reportStreamAnomaly({ type: "stream_buffer_overflow" });
88
198
  }
89
199
  }
90
200
 
91
201
  /** Completes the stream and resolves result() with the final response. */
92
202
  end(finalResponse?: AgentResponse): void {
93
203
  if (this.finished) return;
204
+ if (!finalResponse) {
205
+ // Q-29: never hang result() — ending without a response is a
206
+ // programming error, so fail loudly instead of leaving consumers
207
+ // waiting forever.
208
+ this.fail(new Error("end() requires response"));
209
+ return;
210
+ }
94
211
  this.finished = true;
95
212
 
96
- if (finalResponse) {
97
- this.resolveFinalResult(finalResponse);
98
- } else {
99
- // Resolve with empty if no response — should not happen, but prevent hanging
100
- // Do not reject; just finish
101
- }
213
+ this.resolveFinalResult(finalResponse);
102
214
 
103
215
  // Flush any waiting resolvers as done
104
216
  while (this.resolvers.length > 0) {
105
217
  const resolver = this.resolvers.shift()!;
106
- resolver.resolve({ value: undefined as any, done: true });
218
+ resolver.resolve({ value: undefined as unknown as StreamEvent, done: true });
107
219
  }
108
220
  }
109
221
 
@@ -122,7 +234,9 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
122
234
  /** Registers a callback invoked once on cancel(). Returns an unsubscribe fn. */
123
235
  onCancel(handler: () => void): () => void {
124
236
  if (this.cancelled) {
125
- try { handler(); } catch {}
237
+ try { handler(); } catch {
238
+ // why: a throwing cancel handler must not break the canceller.
239
+ }
126
240
  return () => {};
127
241
  }
128
242
  this.cancelHandlers.add(handler);
@@ -135,7 +249,9 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
135
249
  if (this.finished || this.cancelled) return;
136
250
  this.cancelled = true;
137
251
  for (const h of [...this.cancelHandlers]) {
138
- try { h(); } catch {}
252
+ try { h(); } catch {
253
+ // why: isolate faulty cancel handlers so every handler still runs.
254
+ }
139
255
  }
140
256
  this.cancelHandlers.clear();
141
257
  const err = reason instanceof Error ? reason : null;
@@ -174,7 +290,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
174
290
  iteratorDone = true;
175
291
  this.cancel();
176
292
  }
177
- return Promise.resolve({ value: undefined as any, done: true });
293
+ return Promise.resolve({ value: undefined as unknown as StreamEvent, done: true });
178
294
  },
179
295
  throw: (err?: unknown): Promise<IteratorResult<StreamEvent>> => {
180
296
  if (!iteratorDone) {
@@ -186,10 +302,9 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
186
302
  next: (): Promise<IteratorResult<StreamEvent>> => {
187
303
  if (this.queue.length > 0) {
188
304
  const value = this.queue.shift()!;
189
- // If queued error, next call should reject, but deliver error event first?
190
- // Deliver error event then on next call reject
305
+ // If queued error, deliver the error event first; the NEXT next()
306
+ // call rejects so consumers observe the event before the failure.
191
307
  if (value.type === "error" && this.error) {
192
- // Return error event as value, next next() will reject
193
308
  return Promise.resolve({ value, done: false });
194
309
  }
195
310
  return Promise.resolve({ value, done: false });
@@ -199,7 +314,7 @@ export class AssistantMessageEventStream implements AsyncIterable<StreamEvent> {
199
314
  if (this.error) {
200
315
  return Promise.reject(this.error);
201
316
  }
202
- return Promise.resolve({ value: undefined as any, done: true });
317
+ return Promise.resolve({ value: undefined as unknown as StreamEvent, done: true });
203
318
  }
204
319
 
205
320
  return new Promise<IteratorResult<StreamEvent>>((resolve, reject) => {
@@ -5,6 +5,7 @@ import type {
5
5
  ToolExecutionContext,
6
6
  } from "../types/tool.ts";
7
7
  import { toJsonSafe } from "../utils/serialization.ts";
8
+ import { ConfigError } from "../types/errors.ts";
8
9
 
9
10
  export interface ExecuteToolsOptions {
10
11
  tools: Record<string, ToolDefinition>;
@@ -48,6 +49,180 @@ export function normalizeToolName(name: string): string {
48
49
  .toLowerCase();
49
50
  }
50
51
 
52
+ /** Default cap for string tool results kept for model context. */
53
+ export const DEFAULT_MAX_RESULT_CHARS = 20000;
54
+
55
+ /** Identical-call repeats blocked once the per-fingerprint count reaches this. */
56
+ export const REPEAT_BLOCK_THRESHOLD = 3;
57
+
58
+ /**
59
+ * True when a tool call carries only `{ raw: string }` arguments — the marker
60
+ * shape produced by `parseStreamedToolArguments` when streamed JSON chunks
61
+ * never parsed to an object (truncated/incomplete arguments). Such calls must
62
+ * NOT execute: the model must retry with complete JSON.
63
+ *
64
+ * @example `if (isTruncatedToolCall(call)) return truncatedError(call);`
65
+ */
66
+ export function isTruncatedToolCall(call: ToolCallRecord): boolean {
67
+ const args = call?.arguments;
68
+ if (!args || typeof args !== "object" || Array.isArray(args)) return false;
69
+ const keys = Object.keys(args);
70
+ return (
71
+ keys.length === 1 &&
72
+ keys[0] === "raw" &&
73
+ typeof (args as Record<string, unknown>).raw === "string"
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Throws {@link ConfigError} when two distinct tool entries normalize to the
79
+ * same name (case/format-insensitive via {@link normalizeToolName}), which
80
+ * would otherwise make model tool calls ambiguous.
81
+ */
82
+ export function assertNoToolCollisions(
83
+ tools: Record<string, ToolDefinition>
84
+ ): void {
85
+ if (!tools) return;
86
+ const seen = new Map<string, string>();
87
+ for (const [key, definition] of Object.entries(tools)) {
88
+ const declared = definition?.name || key;
89
+ for (const candidate of new Set([key, declared])) {
90
+ const normalized = normalizeToolName(candidate);
91
+ const previous = seen.get(normalized);
92
+ if (previous !== undefined && previous !== key) {
93
+ throw new ConfigError(
94
+ `Tool name collision: '${previous}' and '${key}' both normalize to '${normalized}'. Rename one tool so model calls are unambiguous.`
95
+ );
96
+ }
97
+ seen.set(normalized, key);
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Redacts secrets and machine-local details from an error message before it
104
+ * is sent back to the model: credentials in URLs/connection strings
105
+ * (`scheme://...@` → `scheme://[redacted]@`) and absolute file paths
106
+ * (`/home/...`, `/Users/...`, `C:\...` → `[redacted-path]`).
107
+ */
108
+ export function sanitizeErrorMessage(message: string): string {
109
+ let out = message;
110
+ out = out.replace(
111
+ /([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\s"'`@]+@/g,
112
+ "$1[redacted]@"
113
+ );
114
+ out = out.replace(/\/home\/[^\s"'`:;,)]*/g, "[redacted-path]");
115
+ out = out.replace(/\/Users\/[^\s"'`:;,)]*/g, "[redacted-path]");
116
+ out = out.replace(/[A-Za-z]:\\[^\s"'`:;,)]*/g, "[redacted-path]");
117
+ return out;
118
+ }
119
+
120
+ function resultCharLimit(toolDef: ToolDefinition): number {
121
+ const configured = integerOption((toolDef as any).maxResultChars);
122
+ return configured && configured > 0 ? configured : DEFAULT_MAX_RESULT_CHARS;
123
+ }
124
+
125
+ function truncateResultString(value: string, limit: number): string {
126
+ if (value.length <= limit) return value;
127
+ return value.slice(0, limit) + `\n[Truncated: showing ${limit} of ${value.length} chars]`;
128
+ }
129
+
130
+ /**
131
+ * Drops `stack` from an Error-shaped sanitized result for model context.
132
+ * Keeps `{ name, message }` plus any custom keys; nested values untouched.
133
+ */
134
+ function stripTopLevelErrorStack(value: unknown): unknown {
135
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
136
+ const record = value as Record<string, unknown>;
137
+ if (
138
+ typeof record.name === "string" &&
139
+ typeof record.message === "string" &&
140
+ "stack" in record
141
+ ) {
142
+ const { stack: _dropped, ...rest } = record;
143
+ return rest;
144
+ }
145
+ return value;
146
+ }
147
+
148
+ /** Stable serialization for repeat-guard fingerprints (key order independent). */
149
+ function stableSerialize(value: unknown, seen = new Set<unknown>()): string {
150
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
151
+ if (seen.has(value)) return "[Circular]";
152
+ seen.add(value);
153
+ try {
154
+ if (Array.isArray(value)) {
155
+ return `[${value.map((item) => stableSerialize(item, seen)).join(",")}]`;
156
+ }
157
+ const record = value as Record<string, unknown>;
158
+ const body = Object.keys(record)
159
+ .sort()
160
+ .map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key], seen)}`)
161
+ .join(",");
162
+ return `{${body}}`;
163
+ } finally {
164
+ seen.delete(value);
165
+ }
166
+ }
167
+
168
+ /** Fingerprint identifying an identical tool call (name + canonical args). */
169
+ export function toolCallFingerprint(call: ToolCallRecord): string {
170
+ return `${call.name}\u0000${stableSerialize(call.arguments ?? {})}`;
171
+ }
172
+
173
+ /**
174
+ * Whether a tool definition allows identical repeats across consecutive
175
+ * turns. Explicit `repeatable` wins; otherwise idempotent tools default to
176
+ * repeatable, side-effecting tools do not.
177
+ */
178
+ export function isRepeatableTool(toolDef: ToolDefinition): boolean {
179
+ const explicit = (toolDef as any).repeatable;
180
+ if (typeof explicit === "boolean") return explicit;
181
+ return (toolDef as any).idempotent === true;
182
+ }
183
+
184
+ /**
185
+ * Repeat-guard helper for the agent loop (which owns consecutive-turn state).
186
+ * Tracks per-fingerprint occurrence counts in `countMap`: a first sighting
187
+ * records count 1 and never blocks; a fingerprint already present in
188
+ * `fingerprints` (the previous turn's set) increments and blocks once the
189
+ * count reaches {@link REPEAT_BLOCK_THRESHOLD} (3). Callers must skip this
190
+ * check for repeatable tools (see {@link isRepeatableTool}); this helper
191
+ * itself stays tool-agnostic for backward compatibility.
192
+ */
193
+ export function shouldBlockRepeat(
194
+ fingerprints: Set<string>,
195
+ call: ToolCallRecord,
196
+ countMap: Map<string, number>
197
+ ): boolean {
198
+ const fingerprint = toolCallFingerprint(call);
199
+ if (!fingerprints.has(fingerprint)) {
200
+ if (!countMap.has(fingerprint)) countMap.set(fingerprint, 1);
201
+ return false;
202
+ }
203
+ const next = (countMap.get(fingerprint) ?? 1) + 1;
204
+ countMap.set(fingerprint, next);
205
+ return next >= REPEAT_BLOCK_THRESHOLD;
206
+ }
207
+
208
+ /**
209
+ * Builds the synthetic blocked result for a repeat-guarded call. The
210
+ * machine-readable code lives at `(result as any).errorCode` while the human
211
+ * prefix keeps plain-string consumers working.
212
+ */
213
+ export function makeRepeatBlockedResult(call: ToolCallRecord): ToolResultRecord {
214
+ const message =
215
+ `repeat_blocked: Tool '${call.name}' was called again with identical arguments. ` +
216
+ "The previous result is already available; reuse it or call the tool with different arguments.";
217
+ return {
218
+ id: call.id,
219
+ name: call.name,
220
+ result: { message, errorCode: "repeat_blocked" },
221
+ isError: true,
222
+ durationMs: 1,
223
+ };
224
+ }
225
+
51
226
  function levenshtein(a: string, b: string): number {
52
227
  const row = Array.from({ length: b.length + 1 }, (_, i) => i);
53
228
  for (let i = 1; i <= a.length; i++) {
@@ -149,16 +324,19 @@ class Semaphore {
149
324
  }
150
325
  }
151
326
 
327
+ /**
328
+ * True for retry-worthy failures: explicit opt-in flags (`transient` /
329
+ * `retryable`), retryable HTTP statuses, or retryable network codes. Never
330
+ * matches on message text — message content is untrusted and locale-fragile.
331
+ */
152
332
  function isTransientError(error: any): boolean {
153
333
  if (!error) return false;
154
- if (error.transient === true || error.code === "ETIMEDOUT") return true;
334
+ if (error.transient === true || error.retryable === true) return true;
155
335
  const status = Number(error.status ?? error.statusCode ?? error.response?.status);
156
336
  if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;
157
337
  const code = String(error.code || "").toUpperCase();
158
- if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "EPIPE"].includes(code)) return true;
159
- return /(?:timed? ?out|temporar(?:y|ily)|connection reset|connection refused|service unavailable|rate limit|\b5\d\d\b)/i.test(
160
- String(error.message || error)
161
- );
338
+ if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ETIMEDOUT", "EPIPE", "UND_ERR_CONNECT_TIMEOUT"].includes(code)) return true;
339
+ return false;
162
340
  }
163
341
 
164
342
  function waitForRetry(ms: number, signal?: AbortSignal): Promise<void> {
@@ -236,6 +414,7 @@ export async function executeToolCalls(
236
414
  options: ExecuteToolsOptions
237
415
  ): Promise<ToolResultRecord[]> {
238
416
  const { tools, toolCalls, agentName, parallel = true, signal, sessionId, onSubagentEvent } = options;
417
+ assertNoToolCollisions(tools);
239
418
  const globalSemaphore = new Semaphore(DEFAULT_TOOL_CONCURRENCY);
240
419
  const perToolSemaphores = new Map<string, Semaphore>();
241
420
 
@@ -253,6 +432,23 @@ export async function executeToolCalls(
253
432
 
254
433
  const runSingle = async (call: ToolCallRecord): Promise<ToolResultRecord> => {
255
434
  const startTime = nowMs();
435
+
436
+ // Truncated streamed arguments (Q-12): the `{ raw }` marker means the
437
+ // provider's partial JSON never parsed. Never execute or overwrite wire
438
+ // finishReason here — return a synthetic error so the model retries with
439
+ // complete JSON.
440
+ if (isTruncatedToolCall(call)) {
441
+ return {
442
+ id: call.id,
443
+ name: call.name,
444
+ result:
445
+ `Error: Tool '${call.name}' arguments are truncated or incomplete and could not be parsed. ` +
446
+ "Call the tool again with the complete JSON arguments. Do not explain the error, just retry the call.",
447
+ isError: true,
448
+ durationMs: elapsedMs(startTime),
449
+ };
450
+ }
451
+
256
452
  const resolved = resolveTool(tools, call.name);
257
453
  const toolDef = resolved.definition;
258
454
 
@@ -320,11 +516,15 @@ export async function executeToolCalls(
320
516
  }
321
517
 
322
518
  const configuredTries = integerOption(toolDef.maxTries);
323
- // A positive value is the total attempt count. 0/omitted falls back to
324
- // a bounded default: an unbounded retry loop on a persistently failing
325
- // dependency (e.g. steady 503/429) would hang the agent loop forever,
326
- // so callers opt into more attempts explicitly via maxTries.
327
- const maxAttempts = configuredTries && configuredTries > 0 ? configuredTries : 3;
519
+ // Total attempt count. Default is a single attempt: only tools marked
520
+ // idempotent opt into a bounded retry default (3 attempts). An explicit
521
+ // maxTries overrides both defaults. An unbounded retry loop on a
522
+ // persistently failing dependency (e.g. steady 503/429) would hang the
523
+ // agent loop forever, so retries are always bounded.
524
+ // Attempts run serially in the while loop below — never concurrently —
525
+ // so a non-idempotent tool cannot execute twice at the same time.
526
+ const isIdempotent = (toolDef as any).idempotent === true;
527
+ const maxAttempts = configuredTries && configuredTries > 0 ? configuredTries : (isIdempotent ? 3 : 1);
328
528
  const timeoutMs = numericOption(toolDef.timeoutMs);
329
529
  // Telemetry starts when the tool body is about to run, excluding queue
330
530
  // wait and schema validation. Retries/backoff remain part of this call.
@@ -339,26 +539,59 @@ export async function executeToolCalls(
339
539
  } catch (err: any) {
340
540
  if (signal?.aborted) throw new Error("Tool execution aborted");
341
541
  if (!isTransientError(err) || attempt >= maxAttempts) throw err;
342
- // A timeout is already a deliberate per-attempt deadline. Retrying
343
- // it forever by default would make timeoutMs ineffective; callers
344
- // can opt into timeout retries with an explicit maxTries value.
542
+ // A timeout is a deliberate per-attempt deadline, not a parent
543
+ // cancellation (which rejects via the abort signal instead and is
544
+ // never retried). Retrying timeouts by default would make timeoutMs
545
+ // ineffective; callers opt into timeout retries with explicit
546
+ // maxTries. Note ctx.signal is cooperative: the timeout races the
547
+ // tool body, but a tool that ignores the signal still resolves.
345
548
  if (err?.code === "ETIMEDOUT" && (!configuredTries || configuredTries <= 0)) throw err;
346
549
  await waitForRetry(Math.min(30_000, 250 * 2 ** Math.min(attempt - 1, 7)), signal);
347
550
  }
348
551
  }
349
552
 
553
+ // Undefined means "no value", serialized null-safe as "null" (changed
554
+ // from the legacy "Success" marker so model context stays valid JSON).
555
+ if (rawResult === undefined) {
556
+ return {
557
+ id: call.id,
558
+ name: resolved.name,
559
+ result: "null",
560
+ isError: false,
561
+ durationMs: elapsedMs(executionStartTime),
562
+ };
563
+ }
564
+ // Stack traces stay in server logs, not model context: keep
565
+ // { name, message } plus custom keys, drop stack.
566
+ let safeResult = stripTopLevelErrorStack(toJsonSafe(rawResult));
567
+ if (typeof safeResult === "string") {
568
+ safeResult = truncateResultString(safeResult, resultCharLimit(toolDef));
569
+ }
350
570
  return {
351
571
  id: call.id,
352
572
  name: resolved.name,
353
- result: rawResult !== undefined ? toJsonSafe(rawResult) : "Success",
573
+ result: safeResult,
354
574
  isError: false,
355
575
  durationMs: elapsedMs(executionStartTime),
356
576
  };
357
577
  } catch (err: any) {
578
+ // Never leak secrets/paths to the model. A per-tool redactError hook
579
+ // wins; otherwise sanitize the message (credentials, file paths).
580
+ let message: string;
581
+ const redactError = (toolDef as any)?.redactError;
582
+ if (typeof redactError === "function") {
583
+ try {
584
+ message = String(redactError(err));
585
+ } catch {
586
+ message = sanitizeErrorMessage(err?.message || String(err));
587
+ }
588
+ } else {
589
+ message = sanitizeErrorMessage(err?.message || String(err));
590
+ }
358
591
  return {
359
592
  id: call.id,
360
593
  name: call.name,
361
- result: `Error executing ${call.name}: ${err?.message || String(err)}`,
594
+ result: `Error executing ${call.name}: ${message}`,
362
595
  isError: true,
363
596
  durationMs: elapsedMs(executionStartTime),
364
597
  };