agent-accelerator 0.3.0 → 0.4.5
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/package.json +1 -1
- package/src/agent/agent.ts +845 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +785 -110
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +173 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +123 -18
- package/src/providers/google.ts +152 -43
- package/src/providers/openai-compat.ts +246 -96
- package/src/providers/openai.ts +274 -79
- package/src/providers/openrouter.ts +191 -82
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +190 -0
- package/src/providers.ts +259 -9
- package/src/session/store.ts +521 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +244 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +22 -1
- package/src/types/response.ts +133 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +23 -13
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 (
|
|
69
|
-
|
|
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
|
-
|
|
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:
|
|
148
|
+
resolver.resolve({ value: out, done: false });
|
|
86
149
|
} else {
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
190
|
-
//
|
|
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
|
|
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) => {
|
package/src/tools/executor.ts
CHANGED
|
@@ -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.
|
|
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", "
|
|
159
|
-
return
|
|
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
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
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
|
|
343
|
-
//
|
|
344
|
-
//
|
|
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:
|
|
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}: ${
|
|
594
|
+
result: `Error executing ${call.name}: ${message}`,
|
|
362
595
|
isError: true,
|
|
363
596
|
durationMs: elapsedMs(executionStartTime),
|
|
364
597
|
};
|