@moxxy/sdk 0.15.0 → 0.15.2
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/compactor.d.ts +1 -1
- package/dist/compactor.d.ts.map +1 -1
- package/dist/elision-state.js +23 -23
- package/dist/elision-state.js.map +1 -1
- package/dist/errors.js +17 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/json-file-store.d.ts +6 -1
- package/dist/json-file-store.d.ts.map +1 -1
- package/dist/json-file-store.js +20 -4
- package/dist/json-file-store.js.map +1 -1
- package/dist/mode/abort-backoff.d.ts +23 -0
- package/dist/mode/abort-backoff.d.ts.map +1 -0
- package/dist/mode/abort-backoff.js +53 -0
- package/dist/mode/abort-backoff.js.map +1 -0
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +34 -7
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +63 -5
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/stable-hash.d.ts +8 -0
- package/dist/mode/stable-hash.d.ts.map +1 -1
- package/dist/mode/stable-hash.js +67 -7
- package/dist/mode/stable-hash.js.map +1 -1
- package/dist/mode-helpers.d.ts +1 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +1 -0
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +7 -1
- package/dist/mode.js.map +1 -1
- package/dist/provider-utils.d.ts +1 -10
- package/dist/provider-utils.d.ts.map +1 -1
- package/dist/provider-utils.js +20 -11
- package/dist/provider-utils.js.map +1 -1
- package/dist/requirements.d.ts +18 -3
- package/dist/requirements.d.ts.map +1 -1
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +13 -3
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +23 -4
- package/dist/tool-display.js.map +1 -1
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +12 -2
- package/dist/tunnel.js.map +1 -1
- package/dist/view-renderer.d.ts +6 -0
- package/dist/view-renderer.d.ts.map +1 -1
- package/dist/view-renderer.js +17 -3
- package/dist/view-renderer.js.map +1 -1
- package/package.json +1 -1
- package/src/compactor.ts +6 -1
- package/src/elision-state.ts +22 -22
- package/src/errors.test.ts +19 -0
- package/src/errors.ts +17 -2
- package/src/index.ts +3 -0
- package/src/json-file-store.test.ts +40 -0
- package/src/json-file-store.ts +25 -4
- package/src/loop-helpers.test.ts +72 -0
- package/src/mode/abort-backoff.test.ts +64 -0
- package/src/mode/abort-backoff.ts +53 -0
- package/src/mode/collect-stream.ts +37 -7
- package/src/mode/project-messages.test.ts +64 -2
- package/src/mode/project-messages.ts +58 -5
- package/src/mode/stable-hash.ts +69 -9
- package/src/mode-helpers.ts +1 -0
- package/src/mode.test.ts +18 -0
- package/src/mode.ts +7 -1
- package/src/provider-utils.test.ts +10 -0
- package/src/provider-utils.ts +20 -11
- package/src/requirements.ts +15 -3
- package/src/stuck-loop.test.ts +77 -1
- package/src/tool-dispatch.test.ts +215 -0
- package/src/tool-dispatch.ts +13 -3
- package/src/tool-display.test.ts +24 -0
- package/src/tool-display.ts +22 -6
- package/src/tunnel.ts +12 -2
- package/src/view-renderer.test.ts +12 -0
- package/src/view-renderer.ts +16 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { nextBackoffMs, sleepWithAbort } from './abort-backoff.js';
|
|
3
|
+
|
|
4
|
+
describe('nextBackoffMs', () => {
|
|
5
|
+
it('grows exponentially from baseMs (1-based attempt)', () => {
|
|
6
|
+
expect(nextBackoffMs(1, 500)).toBe(500);
|
|
7
|
+
expect(nextBackoffMs(2, 500)).toBe(1000);
|
|
8
|
+
expect(nextBackoffMs(3, 500)).toBe(2000);
|
|
9
|
+
expect(nextBackoffMs(4, 500)).toBe(4000);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('treats attempt <= 1 as baseMs', () => {
|
|
13
|
+
expect(nextBackoffMs(0, 500)).toBe(500);
|
|
14
|
+
expect(nextBackoffMs(-3, 500)).toBe(500);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('caps at maxMs (default 30_000)', () => {
|
|
18
|
+
expect(nextBackoffMs(20, 500)).toBe(30_000);
|
|
19
|
+
expect(nextBackoffMs(20, 500, 5_000)).toBe(5_000);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('sleepWithAbort', () => {
|
|
24
|
+
it('resolves after the delay', async () => {
|
|
25
|
+
vi.useFakeTimers();
|
|
26
|
+
try {
|
|
27
|
+
const p = sleepWithAbort(1000);
|
|
28
|
+
vi.advanceTimersByTime(1000);
|
|
29
|
+
await expect(p).resolves.toBeUndefined();
|
|
30
|
+
} finally {
|
|
31
|
+
vi.useRealTimers();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects immediately when the signal is already aborted', async () => {
|
|
36
|
+
const ac = new AbortController();
|
|
37
|
+
ac.abort();
|
|
38
|
+
await expect(sleepWithAbort(1000, ac.signal)).rejects.toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects on abort mid-sleep and leaves no abort listener leaked', async () => {
|
|
42
|
+
const ac = new AbortController();
|
|
43
|
+
const add = vi.spyOn(ac.signal, 'addEventListener');
|
|
44
|
+
const p = sleepWithAbort(10_000, ac.signal);
|
|
45
|
+
ac.abort();
|
|
46
|
+
await expect(p).rejects.toBeDefined();
|
|
47
|
+
// The once-listener is consumed by the abort; nothing keeps it attached.
|
|
48
|
+
expect(add).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('removes the abort listener when it resolves normally (no leak)', async () => {
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
try {
|
|
54
|
+
const ac = new AbortController();
|
|
55
|
+
const remove = vi.spyOn(ac.signal, 'removeEventListener');
|
|
56
|
+
const p = sleepWithAbort(50, ac.signal);
|
|
57
|
+
vi.advanceTimersByTime(50);
|
|
58
|
+
await p;
|
|
59
|
+
expect(remove).toHaveBeenCalledTimes(1);
|
|
60
|
+
} finally {
|
|
61
|
+
vi.useRealTimers();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared retry primitives for loop strategies (mode-default / mode-goal).
|
|
3
|
+
*
|
|
4
|
+
* Both modes back off between retryable provider failures with an identical
|
|
5
|
+
* exponential schedule and an abort-aware sleep. The logic was duplicated in
|
|
6
|
+
* each mode's loop; these are the single source of truth they now import.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sleep `ms` milliseconds, settling early if `signal` aborts. Rejects with the
|
|
11
|
+
* signal's abort reason (an `AbortError`-style `DOMException` when none) on
|
|
12
|
+
* abort so a back-off never silently outlives a cancelled turn, and crucially
|
|
13
|
+
* NEVER leaks the abort listener or the timer in any settle path (resolve,
|
|
14
|
+
* reject, or already-aborted) — a leaked listener on a long-lived signal
|
|
15
|
+
* accumulates across a turn's many retries.
|
|
16
|
+
*/
|
|
17
|
+
export function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
18
|
+
return new Promise<void>((resolve, reject) => {
|
|
19
|
+
if (signal?.aborted) {
|
|
20
|
+
reject(abortReason(signal));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
let onAbort: (() => void) | undefined;
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
|
|
26
|
+
resolve();
|
|
27
|
+
}, Math.max(0, ms));
|
|
28
|
+
if (signal) {
|
|
29
|
+
onAbort = (): void => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
reject(abortReason(signal));
|
|
32
|
+
};
|
|
33
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
39
|
+
// Prefer the caller-supplied reason; fall back to a standard AbortError.
|
|
40
|
+
const reason = (signal as { reason?: unknown }).reason;
|
|
41
|
+
if (reason !== undefined) return reason;
|
|
42
|
+
return new DOMException('The operation was aborted', 'AbortError');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Exponential back-off for a 1-based retry `attempt`: `baseMs * 2^(attempt-1)`,
|
|
47
|
+
* clamped to `[baseMs, maxMs]` (default cap 30_000ms). `attempt <= 1` yields
|
|
48
|
+
* `baseMs`. Matches the schedule both modes used before extraction.
|
|
49
|
+
*/
|
|
50
|
+
export function nextBackoffMs(attempt: number, baseMs: number, maxMs = 30_000): number {
|
|
51
|
+
const exp = Math.max(0, Math.floor(attempt) - 1);
|
|
52
|
+
return Math.min(maxMs, baseMs * 2 ** exp);
|
|
53
|
+
}
|
|
@@ -19,6 +19,24 @@ export interface CollectedToolUse {
|
|
|
19
19
|
readonly input: unknown;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Sum two usage frames so multi-`message_end` responses don't undercount. */
|
|
23
|
+
function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|
24
|
+
const cacheRead =
|
|
25
|
+
a.cacheReadTokens !== undefined || b.cacheReadTokens !== undefined
|
|
26
|
+
? (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0)
|
|
27
|
+
: undefined;
|
|
28
|
+
const cacheCreation =
|
|
29
|
+
a.cacheCreationTokens !== undefined || b.cacheCreationTokens !== undefined
|
|
30
|
+
? (a.cacheCreationTokens ?? 0) + (b.cacheCreationTokens ?? 0)
|
|
31
|
+
: undefined;
|
|
32
|
+
return {
|
|
33
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
34
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
35
|
+
...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}),
|
|
36
|
+
...(cacheCreation !== undefined ? { cacheCreationTokens: cacheCreation } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
export interface StreamResult {
|
|
23
41
|
readonly text: string;
|
|
24
42
|
readonly toolUses: ReadonlyArray<CollectedToolUse>;
|
|
@@ -123,9 +141,12 @@ export async function collectProviderStream(
|
|
|
123
141
|
};
|
|
124
142
|
const transformed = await ctx.hooks.dispatchBeforeProviderCall(req, {
|
|
125
143
|
sessionId: ctx.sessionId,
|
|
126
|
-
cwd
|
|
144
|
+
// Thread the session's real cwd/env (mirrored on ModeContext) so path-based
|
|
145
|
+
// policy/security `onBeforeProviderCall` hooks see the true per-session
|
|
146
|
+
// values rather than blank placeholders — matching the dispatchToolCall path.
|
|
147
|
+
cwd: ctx.cwd,
|
|
127
148
|
log: ctx.log,
|
|
128
|
-
env:
|
|
149
|
+
env: ctx.env,
|
|
129
150
|
turnId: ctx.turnId,
|
|
130
151
|
iteration: opts.iteration ?? 0,
|
|
131
152
|
});
|
|
@@ -181,7 +202,11 @@ export async function collectProviderStream(
|
|
|
181
202
|
}
|
|
182
203
|
case 'message_end': {
|
|
183
204
|
stopReason = event.stopReason;
|
|
184
|
-
|
|
205
|
+
// Accumulate across frames: a provider that splits a response into
|
|
206
|
+
// multiple message segments (or emits an interim then a final usage)
|
|
207
|
+
// must not have its token counts clobbered to only the last frame —
|
|
208
|
+
// that would undercount input/output/cache tokens for billing.
|
|
209
|
+
if (event.usage) usage = usage ? addUsage(usage, event.usage) : event.usage;
|
|
185
210
|
break;
|
|
186
211
|
}
|
|
187
212
|
case 'error': {
|
|
@@ -214,10 +239,15 @@ export async function collectProviderStream(
|
|
|
214
239
|
}
|
|
215
240
|
}
|
|
216
241
|
} catch (err) {
|
|
217
|
-
error
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
242
|
+
// A stream-level `error` event is the more authoritative classification —
|
|
243
|
+
// don't let a subsequent iterator throw downgrade its `retryable: true` to
|
|
244
|
+
// false (which would stop the turn loop from retrying a transient failure).
|
|
245
|
+
if (!error) {
|
|
246
|
+
error = {
|
|
247
|
+
message: err instanceof Error ? err.message : String(err),
|
|
248
|
+
retryable: false,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
221
251
|
}
|
|
222
252
|
|
|
223
253
|
const finalToolUses: CollectedToolUse[] = [];
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { projectUserPrompt, resolvedCallIdSet } from './project-messages.js';
|
|
2
|
+
import { projectMessagesFromLog, projectUserPrompt, resolvedCallIdSet } from './project-messages.js';
|
|
3
3
|
import { computeElisionState } from '../elision-state.js';
|
|
4
4
|
import { asEventId, asSessionId, asTurnId } from '../ids.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type { EventLogReader } from '../log.js';
|
|
6
|
+
import type { MoxxyEvent, MoxxyEventOfType, MoxxyEventType, UserPromptEvent } from '../events.js';
|
|
7
|
+
import type { TurnId } from '../ids.js';
|
|
6
8
|
|
|
7
9
|
const sid = asSessionId('s1');
|
|
8
10
|
const t1 = asTurnId('t1');
|
|
9
11
|
|
|
12
|
+
function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
|
|
13
|
+
return {
|
|
14
|
+
length: events.length,
|
|
15
|
+
at: (seq) => events[seq],
|
|
16
|
+
slice: (from = 0, to = events.length) => events.slice(from, to),
|
|
17
|
+
ofType: <T extends MoxxyEventType>(type: T): ReadonlyArray<MoxxyEventOfType<T>> =>
|
|
18
|
+
events.filter((e): e is MoxxyEventOfType<T> => e.type === type),
|
|
19
|
+
byTurn: (turnId: TurnId) => events.filter((e) => e.turnId === turnId),
|
|
20
|
+
toJSON: () => events,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
// No-elision state — computeElisionState returns the empty/inactive state when
|
|
11
25
|
// the log has no elision events, which is the only state these pure sub-steps
|
|
12
26
|
// branch on for a fresh (non-stubbed) prompt.
|
|
@@ -119,3 +133,51 @@ describe('resolvedCallIdSet', () => {
|
|
|
119
133
|
expect(set.has('later')).toBe(true);
|
|
120
134
|
});
|
|
121
135
|
});
|
|
136
|
+
|
|
137
|
+
describe('projectMessagesFromLog tool_result stringify hardening', () => {
|
|
138
|
+
function logWith(output: unknown): MoxxyEvent[] {
|
|
139
|
+
return [
|
|
140
|
+
event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'go' }),
|
|
141
|
+
event(1, {
|
|
142
|
+
type: 'tool_call_requested',
|
|
143
|
+
turnId: t1,
|
|
144
|
+
source: 'model',
|
|
145
|
+
callId: 'c1',
|
|
146
|
+
name: 'weird',
|
|
147
|
+
input: {},
|
|
148
|
+
}),
|
|
149
|
+
event(2, { type: 'tool_result', turnId: t1, source: 'tool', callId: 'c1', ok: true, output }),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toolResultText(events: MoxxyEvent[]): string {
|
|
154
|
+
const msgs = projectMessagesFromLog({ log: reader(events) });
|
|
155
|
+
const tr = msgs.find((m) => m.role === 'tool_result');
|
|
156
|
+
const block = tr?.content[0];
|
|
157
|
+
return block && block.type === 'tool_result' ? (block.content as string) : '';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
it('does not throw on a circular tool_result output (would permanently wedge the turn)', () => {
|
|
161
|
+
const circular: Record<string, unknown> = { a: 1 };
|
|
162
|
+
circular.self = circular;
|
|
163
|
+
const events = logWith(circular);
|
|
164
|
+
expect(() => projectMessagesFromLog({ log: reader(events) })).not.toThrow();
|
|
165
|
+
// Re-projecting the same append-only log must also never throw.
|
|
166
|
+
expect(() => projectMessagesFromLog({ log: reader(events) })).not.toThrow();
|
|
167
|
+
expect(typeof toolResultText(events)).toBe('string');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does not throw on a BigInt-bearing tool_result output', () => {
|
|
171
|
+
const events = logWith({ n: 10n });
|
|
172
|
+
expect(() => projectMessagesFromLog({ log: reader(events) })).not.toThrow();
|
|
173
|
+
expect(typeof toolResultText(events)).toBe('string');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('still serializes a plain object output as JSON', () => {
|
|
177
|
+
expect(toolResultText(logWith({ ok: true }))).toBe('{"ok":true}');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('passes a string output through verbatim', () => {
|
|
181
|
+
expect(toolResultText(logWith('plain text'))).toBe('plain text');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -13,6 +13,50 @@ import {
|
|
|
13
13
|
} from '../elision-state.js';
|
|
14
14
|
import { isToolDisplayResult } from '../tool-display.js';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Stringify an arbitrary tool output for the model-facing tool_result text.
|
|
18
|
+
* `tool_result.output` is `unknown` and comes straight from whatever a tool
|
|
19
|
+
* returned, so it can be circular or contain a BigInt — either of which makes
|
|
20
|
+
* `JSON.stringify` THROW. Because projection runs on every request over the
|
|
21
|
+
* append-only log, an unguarded throw here permanently wedges the session
|
|
22
|
+
* (every subsequent re-projection re-throws). Mirrors the try/catch guard the
|
|
23
|
+
* sibling sizing paths (`safeJsonLen`, `toolResultBytes`) already use.
|
|
24
|
+
*/
|
|
25
|
+
function safeStringifyOutput(output: unknown): string {
|
|
26
|
+
if (typeof output === 'string') return output;
|
|
27
|
+
try {
|
|
28
|
+
return JSON.stringify(output ?? '');
|
|
29
|
+
} catch {
|
|
30
|
+
try {
|
|
31
|
+
return String(output ?? '');
|
|
32
|
+
} catch {
|
|
33
|
+
return '[unserializable tool output]';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An image ContentBlock extracted from an image-shaped tool output. A tool can
|
|
40
|
+
* return either `{ mediaType, base64 }` (the desktop screenshot/clipboard shape)
|
|
41
|
+
* or the provider-native `{ type:'image', mediaType, data }` — both normalize to
|
|
42
|
+
* the same provider `image` block so the model SEES the pixels instead of a
|
|
43
|
+
* stringified blob of base64.
|
|
44
|
+
*/
|
|
45
|
+
function imageBlockFromOutput(output: unknown): Extract<ContentBlock, { type: 'image' }> | null {
|
|
46
|
+
if (typeof output !== 'object' || output === null) return null;
|
|
47
|
+
const o = output as { type?: unknown; mediaType?: unknown; base64?: unknown; data?: unknown };
|
|
48
|
+
if (typeof o.mediaType !== 'string') return null;
|
|
49
|
+
// `{ mediaType, base64 }` (raw shape) or `{ type:'image', mediaType, data }`.
|
|
50
|
+
const data =
|
|
51
|
+
typeof o.base64 === 'string'
|
|
52
|
+
? o.base64
|
|
53
|
+
: o.type === 'image' && typeof o.data === 'string'
|
|
54
|
+
? o.data
|
|
55
|
+
: null;
|
|
56
|
+
if (data === null) return null;
|
|
57
|
+
return { type: 'image', mediaType: o.mediaType, data };
|
|
58
|
+
}
|
|
59
|
+
|
|
16
60
|
/** Appended to the system prompt while elision is active (see projection). */
|
|
17
61
|
export const ELISION_SYSTEM_NOTE =
|
|
18
62
|
'Context note: to stay within budget, older turns may appear as stubs like ' +
|
|
@@ -439,6 +483,10 @@ export function projectMessages(
|
|
|
439
483
|
// Stub bulky old tool output to a recall-able marker (decision shared
|
|
440
484
|
// with estimateContextTokens via toolResultStubbed).
|
|
441
485
|
let text: string;
|
|
486
|
+
// An image-shaped output (a screenshot/clipboard grab) is carried as a
|
|
487
|
+
// provider `image` block so the model SEES the pixels — only when the
|
|
488
|
+
// result isn't stubbed (elided) or an error and the call succeeded.
|
|
489
|
+
let image: Extract<ContentBlock, { type: 'image' }> | null = null;
|
|
442
490
|
if (toolResultStubbed(e, el)) {
|
|
443
491
|
const recalled = el.recalledCallIds.has(e.callId) || el.recalledSeqs.has(e.seq);
|
|
444
492
|
text = toolResultStub(e.callId, toolResultBytes(e.output), recalled);
|
|
@@ -448,13 +496,18 @@ export function projectMessages(
|
|
|
448
496
|
// Rich result (e.g. a file diff): the model only needs the short
|
|
449
497
|
// `forModel` summary — the structured `display` is for channels.
|
|
450
498
|
text = e.output.forModel;
|
|
499
|
+
} else if (e.ok && (image = imageBlockFromOutput(e.output))) {
|
|
500
|
+
// Short marker satisfies the tool_use→tool_result pairing; the image
|
|
501
|
+
// block (appended below) carries the actual pixels.
|
|
502
|
+
text = '[image returned by tool — see attached image]';
|
|
451
503
|
} else {
|
|
452
|
-
text =
|
|
504
|
+
text = safeStringifyOutput(e.output);
|
|
453
505
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
506
|
+
const content: ContentBlock[] = [
|
|
507
|
+
{ type: 'tool_result', toolUseId: e.callId, content: text, isError: !e.ok },
|
|
508
|
+
];
|
|
509
|
+
if (image) content.push(image);
|
|
510
|
+
messages.push({ role: 'tool_result', content });
|
|
458
511
|
recordStable(e.seq);
|
|
459
512
|
break;
|
|
460
513
|
}
|
package/src/mode/stable-hash.ts
CHANGED
|
@@ -2,19 +2,79 @@
|
|
|
2
2
|
* Stable, key-order-canonical hash of a tool call's input, so `{a:1,b:2}` and
|
|
3
3
|
* `{b:2,a:1}` produce the same key. Use for any "have I seen this call before"
|
|
4
4
|
* comparison — a raw `JSON.stringify` is NOT order-stable.
|
|
5
|
+
*
|
|
6
|
+
* MUST be total: the input is a tool call's `input`, typed `unknown` and coming
|
|
7
|
+
* straight from whatever the provider deserialized (it can carry a BigInt, a
|
|
8
|
+
* circular reference, or a deeply nested structure). This hashes in the HOT
|
|
9
|
+
* tool-dispatch path (`detector.record` in tool-dispatch.ts), where an
|
|
10
|
+
* unhandled throw or a stack-overflow from unbounded recursion would crash the
|
|
11
|
+
* whole turn. So every leaf is serialized defensively, cycles are detected and
|
|
12
|
+
* collapsed to a stable marker, and recursion is depth-bounded.
|
|
5
13
|
*/
|
|
6
14
|
export function stableHash(input: unknown): string {
|
|
7
|
-
|
|
15
|
+
try {
|
|
16
|
+
return canonicalize(input, new WeakSet(), 0);
|
|
17
|
+
} catch {
|
|
18
|
+
// Last-ditch guard: an exotic value (e.g. a Proxy whose ownKeys/get trap
|
|
19
|
+
// throws, or a getter that throws) could still surface a throw out of the
|
|
20
|
+
// recursive walk. Never let it escape into the hot tool-dispatch path —
|
|
21
|
+
// collapse to a stable opaque marker. Two such inputs hash equal, which at
|
|
22
|
+
// worst makes the stuck detector slightly more eager, never crashes.
|
|
23
|
+
return '"[unhashable]"';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Bound recursion so a hostile/pathologically-nested input can't blow the stack
|
|
28
|
+
// (the JS engine throws RangeError well before this, and a tool input that deep
|
|
29
|
+
// is already degenerate for stuck-detection purposes — treat it as opaque).
|
|
30
|
+
const MAX_DEPTH = 100;
|
|
31
|
+
|
|
32
|
+
/** Serialize a primitive leaf without ever throwing (BigInt/symbol/etc.). */
|
|
33
|
+
function leaf(value: unknown): string {
|
|
34
|
+
switch (typeof value) {
|
|
35
|
+
case 'string':
|
|
36
|
+
return JSON.stringify(value);
|
|
37
|
+
case 'number':
|
|
38
|
+
// JSON.stringify(NaN|±Infinity) is 'null'; keep that, but use a stable
|
|
39
|
+
// distinct token so two different non-finite values don't collide.
|
|
40
|
+
return Number.isFinite(value) ? String(value) : `#${String(value)}`;
|
|
41
|
+
case 'boolean':
|
|
42
|
+
return value ? 'true' : 'false';
|
|
43
|
+
case 'bigint':
|
|
44
|
+
// JSON.stringify THROWS on a BigInt — serialize it ourselves, tagged so a
|
|
45
|
+
// bigint and the equal-valued number never collide.
|
|
46
|
+
return `${value.toString()}n`;
|
|
47
|
+
case 'symbol':
|
|
48
|
+
return `@${String(value)}`;
|
|
49
|
+
case 'function':
|
|
50
|
+
return '"[fn]"';
|
|
51
|
+
default:
|
|
52
|
+
return 'null';
|
|
53
|
+
}
|
|
8
54
|
}
|
|
9
55
|
|
|
10
|
-
function canonicalize(value: unknown): string {
|
|
56
|
+
function canonicalize(value: unknown, seen: WeakSet<object>, depth: number): string {
|
|
11
57
|
if (value === null || value === undefined) return 'null';
|
|
12
|
-
if (typeof value !== 'object') return
|
|
13
|
-
|
|
14
|
-
|
|
58
|
+
if (typeof value !== 'object') return leaf(value);
|
|
59
|
+
// Cycle guard: a circular reference would recurse forever → stack overflow.
|
|
60
|
+
if (seen.has(value)) return '"[circular]"';
|
|
61
|
+
if (depth >= MAX_DEPTH) return '"[deep]"';
|
|
62
|
+
seen.add(value);
|
|
63
|
+
try {
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
return '[' + value.map((v) => canonicalize(v, seen, depth + 1)).join(',') + ']';
|
|
66
|
+
}
|
|
67
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
68
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
69
|
+
);
|
|
70
|
+
return (
|
|
71
|
+
'{' +
|
|
72
|
+
entries.map(([k, v]) => JSON.stringify(k) + ':' + canonicalize(v, seen, depth + 1)).join(',') +
|
|
73
|
+
'}'
|
|
74
|
+
);
|
|
75
|
+
} finally {
|
|
76
|
+
// Allow the same object to appear in sibling positions (DAG, not a cycle)
|
|
77
|
+
// without being flagged circular — only ancestors are forbidden.
|
|
78
|
+
seen.delete(value);
|
|
15
79
|
}
|
|
16
|
-
const entries = Object.entries(value as Record<string, unknown>).sort(
|
|
17
|
-
([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
|
|
18
|
-
);
|
|
19
|
-
return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + canonicalize(v)).join(',') + '}';
|
|
20
80
|
}
|
package/src/mode-helpers.ts
CHANGED
|
@@ -25,6 +25,7 @@ export {
|
|
|
25
25
|
type StreamResult,
|
|
26
26
|
} from './mode/collect-stream.js';
|
|
27
27
|
export { runSingleShotTurn } from './mode/single-shot.js';
|
|
28
|
+
export { sleepWithAbort, nextBackoffMs } from './mode/abort-backoff.js';
|
|
28
29
|
export {
|
|
29
30
|
createStuckLoopDetector,
|
|
30
31
|
type StuckLoopDetector,
|
package/src/mode.test.ts
CHANGED
|
@@ -17,4 +17,22 @@ describe('migrateModeName', () => {
|
|
|
17
17
|
expect(migrateModeName('some-future-mode')).toBe('some-future-mode');
|
|
18
18
|
expect(migrateModeName('')).toBe('');
|
|
19
19
|
});
|
|
20
|
+
|
|
21
|
+
it('never leaks an inherited Object.prototype member for a polluting name', () => {
|
|
22
|
+
// `name` is externally-sourced; a bare object index would resolve these to
|
|
23
|
+
// truthy Functions and break the `string` contract. Each must pass through
|
|
24
|
+
// as its own identity string instead.
|
|
25
|
+
for (const polluting of [
|
|
26
|
+
'toString',
|
|
27
|
+
'valueOf',
|
|
28
|
+
'constructor',
|
|
29
|
+
'hasOwnProperty',
|
|
30
|
+
'isPrototypeOf',
|
|
31
|
+
'__proto__',
|
|
32
|
+
]) {
|
|
33
|
+
const result = migrateModeName(polluting);
|
|
34
|
+
expect(typeof result).toBe('string');
|
|
35
|
+
expect(result).toBe(polluting);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
20
38
|
});
|
package/src/mode.ts
CHANGED
|
@@ -219,5 +219,11 @@ const LEGACY_MODE_NAMES: Readonly<Record<string, string>> = {
|
|
|
219
219
|
|
|
220
220
|
/** Map a possibly-legacy mode name to its current name (identity if unknown). */
|
|
221
221
|
export function migrateModeName(name: string): string {
|
|
222
|
-
|
|
222
|
+
// Own-property check: `name` is externally-sourced (config / preferences /
|
|
223
|
+
// setMode RPC). A bare `LEGACY_MODE_NAMES[name]` index would resolve inherited
|
|
224
|
+
// Object.prototype members (`toString`, `constructor`, `__proto__`, …) — all
|
|
225
|
+
// truthy Functions, so `?? name` would NOT fall through and the function would
|
|
226
|
+
// return a Function, breaking its `string` contract and shadowing a mode
|
|
227
|
+
// legitimately named `toString`.
|
|
228
|
+
return Object.hasOwn(LEGACY_MODE_NAMES, name) ? LEGACY_MODE_NAMES[name]! : name;
|
|
223
229
|
}
|
|
@@ -81,4 +81,14 @@ describe('zodToJsonSchema', () => {
|
|
|
81
81
|
expect(out).toHaveProperty('type', 'object');
|
|
82
82
|
expect(out).toHaveProperty('properties');
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
it('bounds recursion on a deeply nested schema instead of overflowing the stack', () => {
|
|
86
|
+
// Build a pathologically deep array nesting; the converter must degrade to
|
|
87
|
+
// the permissive {} past its depth cap rather than blow the call stack.
|
|
88
|
+
let schema: z.ZodTypeAny = z.string();
|
|
89
|
+
for (let i = 0; i < 500; i++) schema = z.array(schema);
|
|
90
|
+
expect(() => zodToJsonSchema(schema)).not.toThrow();
|
|
91
|
+
const out = zodToJsonSchema(schema);
|
|
92
|
+
expect(out).toBeDefined();
|
|
93
|
+
});
|
|
84
94
|
});
|
package/src/provider-utils.ts
CHANGED
|
@@ -72,7 +72,16 @@ export function toFriendlyError(
|
|
|
72
72
|
* (`shape`, `type`) which aren't part of zod's public typed surface but are
|
|
73
73
|
* stable enough across versions to rely on for this best-effort path.
|
|
74
74
|
*/
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Max recursion depth for {@link zodToJsonSchema}. A legitimately deep finite
|
|
77
|
+
* schema (or a pathological/adversarial tool definition) recurses one frame per
|
|
78
|
+
* level; past this cap we degrade to the permissive `{}` schema instead of
|
|
79
|
+
* blowing the call stack and taking down provider request construction.
|
|
80
|
+
*/
|
|
81
|
+
const MAX_SCHEMA_DEPTH = 32;
|
|
82
|
+
|
|
83
|
+
export function zodToJsonSchema(schema: unknown, depth = 0): unknown {
|
|
84
|
+
if (depth > MAX_SCHEMA_DEPTH) return {};
|
|
76
85
|
const s = schema as { _def?: { typeName?: string }; toJSON?: () => unknown };
|
|
77
86
|
// Only honor a pre-serialized schema's `toJSON` for NON-zod objects (a plain
|
|
78
87
|
// JSON-schema object passed through). A real zod schema (has `_def`) MUST go
|
|
@@ -89,7 +98,7 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
89
98
|
// Codex's /responses validator reject ("object schema missing properties").
|
|
90
99
|
if (typeName === 'ZodEffects') {
|
|
91
100
|
const inner = (def as unknown as { schema: unknown }).schema;
|
|
92
|
-
return zodToJsonSchema(inner);
|
|
101
|
+
return zodToJsonSchema(inner, depth + 1);
|
|
93
102
|
}
|
|
94
103
|
// ZodOptional / ZodNullable / ZodDefault / ZodBranded / ZodReadonly all
|
|
95
104
|
// wrap an inner schema we want to unwrap for JSON-schema purposes.
|
|
@@ -101,7 +110,7 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
101
110
|
typeName === 'ZodReadonly'
|
|
102
111
|
) {
|
|
103
112
|
const inner = (def as unknown as { innerType: unknown }).innerType;
|
|
104
|
-
return zodToJsonSchema(inner);
|
|
113
|
+
return zodToJsonSchema(inner, depth + 1);
|
|
105
114
|
}
|
|
106
115
|
// `.and(other)` produces a ZodIntersection. The standard JSON-schema
|
|
107
116
|
// representation is `allOf`, but strict validators (Codex again) want
|
|
@@ -110,8 +119,8 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
110
119
|
// and required lists into one object. Fall back to `allOf` otherwise.
|
|
111
120
|
if (typeName === 'ZodIntersection') {
|
|
112
121
|
const intersection = def as unknown as { left: unknown; right: unknown };
|
|
113
|
-
const left = zodToJsonSchema(intersection.left) as Record<string, unknown>;
|
|
114
|
-
const right = zodToJsonSchema(intersection.right) as Record<string, unknown>;
|
|
122
|
+
const left = zodToJsonSchema(intersection.left, depth + 1) as Record<string, unknown>;
|
|
123
|
+
const right = zodToJsonSchema(intersection.right, depth + 1) as Record<string, unknown>;
|
|
115
124
|
if (
|
|
116
125
|
left &&
|
|
117
126
|
right &&
|
|
@@ -139,7 +148,7 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
139
148
|
const properties: Record<string, unknown> = {};
|
|
140
149
|
const required: string[] = [];
|
|
141
150
|
for (const [key, value] of Object.entries(shape)) {
|
|
142
|
-
properties[key] = zodToJsonSchema(value);
|
|
151
|
+
properties[key] = zodToJsonSchema(value, depth + 1);
|
|
143
152
|
const isOptional = (value as { isOptional?: () => boolean }).isOptional?.();
|
|
144
153
|
if (!isOptional) required.push(key);
|
|
145
154
|
}
|
|
@@ -166,11 +175,11 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
166
175
|
}
|
|
167
176
|
if (typeName === 'ZodUnion' || typeName === 'ZodDiscriminatedUnion') {
|
|
168
177
|
const options = (def as unknown as { options: ReadonlyArray<unknown> }).options;
|
|
169
|
-
return { anyOf: options.map((o) => zodToJsonSchema(o)) };
|
|
178
|
+
return { anyOf: options.map((o) => zodToJsonSchema(o, depth + 1)) };
|
|
170
179
|
}
|
|
171
180
|
if (typeName === 'ZodArray') {
|
|
172
181
|
// ZodArray._def.type is the element schema.
|
|
173
|
-
const items = zodToJsonSchema((def as unknown as { type: unknown }).type);
|
|
182
|
+
const items = zodToJsonSchema((def as unknown as { type: unknown }).type, depth + 1);
|
|
174
183
|
return { type: 'array', items };
|
|
175
184
|
}
|
|
176
185
|
if (typeName === 'ZodRecord') {
|
|
@@ -178,18 +187,18 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
178
187
|
// one schema. Carry the value schema through `additionalProperties` rather
|
|
179
188
|
// than discarding it to the permissive fallback below.
|
|
180
189
|
const valueType = (def as unknown as { valueType: unknown }).valueType;
|
|
181
|
-
return { type: 'object', additionalProperties: zodToJsonSchema(valueType) };
|
|
190
|
+
return { type: 'object', additionalProperties: zodToJsonSchema(valueType, depth + 1) };
|
|
182
191
|
}
|
|
183
192
|
if (typeName === 'ZodTuple') {
|
|
184
193
|
// ZodTuple._def.items is the ordered element schemas; `rest` (if set) is the
|
|
185
194
|
// variadic tail. Emit a fixed-position `items` array + an `additionalItems`
|
|
186
195
|
// schema for the rest, mirroring JSON-schema's positional-tuple form.
|
|
187
196
|
const tupleDef = def as unknown as { items: ReadonlyArray<unknown>; rest?: unknown };
|
|
188
|
-
const items = tupleDef.items.map((i) => zodToJsonSchema(i));
|
|
197
|
+
const items = tupleDef.items.map((i) => zodToJsonSchema(i, depth + 1));
|
|
189
198
|
return {
|
|
190
199
|
type: 'array',
|
|
191
200
|
items,
|
|
192
|
-
...(tupleDef.rest != null ? { additionalItems: zodToJsonSchema(tupleDef.rest) } : {}),
|
|
201
|
+
...(tupleDef.rest != null ? { additionalItems: zodToJsonSchema(tupleDef.rest, depth + 1) } : {}),
|
|
193
202
|
};
|
|
194
203
|
}
|
|
195
204
|
if (typeName === 'ZodAny' || typeName === 'ZodUnknown') {
|
package/src/requirements.ts
CHANGED
|
@@ -13,16 +13,28 @@ export type RequirementKind =
|
|
|
13
13
|
|
|
14
14
|
export type RequirementState = 'registered' | 'active' | 'ready';
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/** Fields common to every requirement kind. */
|
|
17
|
+
interface RequirementBase {
|
|
18
18
|
readonly name: string;
|
|
19
19
|
readonly state?: RequirementState;
|
|
20
|
-
readonly version?: string;
|
|
21
20
|
readonly optional?: boolean;
|
|
22
21
|
readonly reason?: string;
|
|
23
22
|
readonly hint?: string;
|
|
24
23
|
}
|
|
25
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A declared dependency a plugin/config can require be present (and optionally
|
|
27
|
+
* active/ready). It is a discriminated union on `kind`: `version` is ONLY valid
|
|
28
|
+
* on the `plugin` kind, because that is the sole kind whose target resolves a
|
|
29
|
+
* version (see core's `RequirementRegistry.targetInfo`). A `version` on any
|
|
30
|
+
* other kind would compare against an always-undefined target version and
|
|
31
|
+
* report a permanent spurious `version_mismatch`, so the type forbids it at
|
|
32
|
+
* compile time rather than letting it silently slip through.
|
|
33
|
+
*/
|
|
34
|
+
export type MoxxyRequirement =
|
|
35
|
+
| (RequirementBase & { readonly kind: 'plugin'; readonly version?: string })
|
|
36
|
+
| (RequirementBase & { readonly kind: Exclude<RequirementKind, 'plugin'> });
|
|
37
|
+
|
|
26
38
|
export interface RequirementIssue {
|
|
27
39
|
readonly requirement: MoxxyRequirement;
|
|
28
40
|
readonly code: 'missing' | 'inactive' | 'not_ready' | 'version_mismatch';
|