agent-accelerator 0.2.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>;
@@ -13,6 +14,8 @@ export interface ExecuteToolsOptions {
13
14
  parallel?: boolean;
14
15
  signal?: AbortSignal;
15
16
  sessionId?: string;
17
+ /** Forwards live sub-agent deltas (realtime worker thinking/text) to the caller. */
18
+ onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
16
19
  }
17
20
 
18
21
  const DEFAULT_TOOL_CONCURRENCY = 8;
@@ -46,6 +49,180 @@ export function normalizeToolName(name: string): string {
46
49
  .toLowerCase();
47
50
  }
48
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
+
49
226
  function levenshtein(a: string, b: string): number {
50
227
  const row = Array.from({ length: b.length + 1 }, (_, i) => i);
51
228
  for (let i = 1; i <= a.length; i++) {
@@ -147,16 +324,19 @@ class Semaphore {
147
324
  }
148
325
  }
149
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
+ */
150
332
  function isTransientError(error: any): boolean {
151
333
  if (!error) return false;
152
- if (error.transient === true || error.code === "ETIMEDOUT") return true;
334
+ if (error.transient === true || error.retryable === true) return true;
153
335
  const status = Number(error.status ?? error.statusCode ?? error.response?.status);
154
336
  if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;
155
337
  const code = String(error.code || "").toUpperCase();
156
- if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "EPIPE"].includes(code)) return true;
157
- return /(?:timed? ?out|temporar(?:y|ily)|connection reset|connection refused|service unavailable|rate limit|\b5\d\d\b)/i.test(
158
- String(error.message || error)
159
- );
338
+ if (["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ETIMEDOUT", "EPIPE", "UND_ERR_CONNECT_TIMEOUT"].includes(code)) return true;
339
+ return false;
160
340
  }
161
341
 
162
342
  function waitForRetry(ms: number, signal?: AbortSignal): Promise<void> {
@@ -233,7 +413,8 @@ async function executeAttempt(
233
413
  export async function executeToolCalls(
234
414
  options: ExecuteToolsOptions
235
415
  ): Promise<ToolResultRecord[]> {
236
- const { tools, toolCalls, agentName, parallel = true, signal, sessionId } = options;
416
+ const { tools, toolCalls, agentName, parallel = true, signal, sessionId, onSubagentEvent } = options;
417
+ assertNoToolCollisions(tools);
237
418
  const globalSemaphore = new Semaphore(DEFAULT_TOOL_CONCURRENCY);
238
419
  const perToolSemaphores = new Map<string, Semaphore>();
239
420
 
@@ -251,6 +432,23 @@ export async function executeToolCalls(
251
432
 
252
433
  const runSingle = async (call: ToolCallRecord): Promise<ToolResultRecord> => {
253
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
+
254
452
  const resolved = resolveTool(tools, call.name);
255
453
  const toolDef = resolved.definition;
256
454
 
@@ -282,6 +480,7 @@ export async function executeToolCalls(
282
480
  agentName,
283
481
  signal,
284
482
  sessionId,
483
+ onSubagentEvent,
285
484
  };
286
485
 
287
486
  // Validate input if Zod schema is provided
@@ -317,11 +516,15 @@ export async function executeToolCalls(
317
516
  }
318
517
 
319
518
  const configuredTries = integerOption(toolDef.maxTries);
320
- // A positive value is the total attempt count. 0/omitted falls back to
321
- // a bounded default: an unbounded retry loop on a persistently failing
322
- // dependency (e.g. steady 503/429) would hang the agent loop forever,
323
- // so callers opt into more attempts explicitly via maxTries.
324
- 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);
325
528
  const timeoutMs = numericOption(toolDef.timeoutMs);
326
529
  // Telemetry starts when the tool body is about to run, excluding queue
327
530
  // wait and schema validation. Retries/backoff remain part of this call.
@@ -336,26 +539,59 @@ export async function executeToolCalls(
336
539
  } catch (err: any) {
337
540
  if (signal?.aborted) throw new Error("Tool execution aborted");
338
541
  if (!isTransientError(err) || attempt >= maxAttempts) throw err;
339
- // A timeout is already a deliberate per-attempt deadline. Retrying
340
- // it forever by default would make timeoutMs ineffective; callers
341
- // 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.
342
548
  if (err?.code === "ETIMEDOUT" && (!configuredTries || configuredTries <= 0)) throw err;
343
549
  await waitForRetry(Math.min(30_000, 250 * 2 ** Math.min(attempt - 1, 7)), signal);
344
550
  }
345
551
  }
346
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
+ }
347
570
  return {
348
571
  id: call.id,
349
572
  name: resolved.name,
350
- result: rawResult !== undefined ? toJsonSafe(rawResult) : "Success",
573
+ result: safeResult,
351
574
  isError: false,
352
575
  durationMs: elapsedMs(executionStartTime),
353
576
  };
354
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
+ }
355
591
  return {
356
592
  id: call.id,
357
593
  name: call.name,
358
- result: `Error executing ${call.name}: ${err?.message || String(err)}`,
594
+ result: `Error executing ${call.name}: ${message}`,
359
595
  isError: true,
360
596
  durationMs: elapsedMs(executionStartTime),
361
597
  };