@moxxy/sdk 0.15.0 → 0.15.1

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.
Files changed (53) hide show
  1. package/dist/elision-state.js +23 -23
  2. package/dist/elision-state.js.map +1 -1
  3. package/dist/errors.js +17 -2
  4. package/dist/errors.js.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/json-file-store.d.ts +6 -1
  9. package/dist/json-file-store.d.ts.map +1 -1
  10. package/dist/json-file-store.js +20 -4
  11. package/dist/json-file-store.js.map +1 -1
  12. package/dist/mode/collect-stream.d.ts.map +1 -1
  13. package/dist/mode/collect-stream.js +34 -7
  14. package/dist/mode/collect-stream.js.map +1 -1
  15. package/dist/mode/project-messages.d.ts.map +1 -1
  16. package/dist/mode/project-messages.js +25 -1
  17. package/dist/mode/project-messages.js.map +1 -1
  18. package/dist/mode/stable-hash.d.ts +8 -0
  19. package/dist/mode/stable-hash.d.ts.map +1 -1
  20. package/dist/mode/stable-hash.js +67 -7
  21. package/dist/mode/stable-hash.js.map +1 -1
  22. package/dist/mode.d.ts.map +1 -1
  23. package/dist/mode.js +7 -1
  24. package/dist/mode.js.map +1 -1
  25. package/dist/provider-utils.d.ts +1 -10
  26. package/dist/provider-utils.d.ts.map +1 -1
  27. package/dist/provider-utils.js +20 -11
  28. package/dist/provider-utils.js.map +1 -1
  29. package/dist/tool-dispatch.d.ts.map +1 -1
  30. package/dist/tool-dispatch.js +13 -3
  31. package/dist/tool-dispatch.js.map +1 -1
  32. package/dist/tunnel.d.ts.map +1 -1
  33. package/dist/tunnel.js +12 -2
  34. package/dist/tunnel.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/elision-state.ts +22 -22
  37. package/src/errors.test.ts +19 -0
  38. package/src/errors.ts +17 -2
  39. package/src/index.ts +1 -0
  40. package/src/json-file-store.test.ts +40 -0
  41. package/src/json-file-store.ts +25 -4
  42. package/src/mode/collect-stream.ts +37 -7
  43. package/src/mode/project-messages.test.ts +64 -2
  44. package/src/mode/project-messages.ts +23 -1
  45. package/src/mode/stable-hash.ts +69 -9
  46. package/src/mode.test.ts +18 -0
  47. package/src/mode.ts +7 -1
  48. package/src/provider-utils.test.ts +10 -0
  49. package/src/provider-utils.ts +20 -11
  50. package/src/stuck-loop.test.ts +77 -1
  51. package/src/tool-dispatch.test.ts +215 -0
  52. package/src/tool-dispatch.ts +13 -3
  53. package/src/tunnel.ts +12 -2
@@ -115,6 +115,46 @@ describe('createJsonFileStore', () => {
115
115
  expect(leftovers).toEqual([]);
116
116
  });
117
117
 
118
+ it('keeps the in-memory cache consistent with disk when persist throws', async () => {
119
+ const s = store();
120
+ await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
121
+
122
+ // A persist failure (BigInt can't JSON-serialize) must NOT advance the
123
+ // cache to the un-written phantom state — otherwise the next successful
124
+ // mutate would commit the failed write's mutation.
125
+ await expect(
126
+ s.mutate(() => [{ id: 'phantom', n: 1n as unknown as number }]),
127
+ ).rejects.toThrow();
128
+
129
+ // read() must still see the last durably-written state, not the phantom.
130
+ expect(await s.read()).toEqual([{ id: 'a', n: 1 }]);
131
+ expect(await s.get('phantom')).toBeNull();
132
+
133
+ // The next good mutate serializes from the last-good baseline (no phantom).
134
+ await s.mutate((items) => [...items, { id: 'b', n: 2 }]);
135
+ expect(await s.read()).toEqual([{ id: 'a', n: 1 }, { id: 'b', n: 2 }]);
136
+ const onDisk = JSON.parse(await readFile(file, 'utf8')) as { items: Item[] };
137
+ expect(onDisk.items).toEqual([{ id: 'a', n: 1 }, { id: 'b', n: 2 }]);
138
+ });
139
+
140
+ it('coalesces concurrent cold reads into a single load (no clobbering)', async () => {
141
+ await mkdir(join(dir, 'sub'), { recursive: true });
142
+ await writeFile(file, JSON.stringify({ version: 1, items: [{ id: 'a', n: 1 }] }), 'utf8');
143
+
144
+ let loadCalls = 0;
145
+ const s = createJsonFileStore<Item>({
146
+ file,
147
+ load: (raw) => {
148
+ loadCalls++;
149
+ return lenientLoad(raw);
150
+ },
151
+ });
152
+ // A burst of cold concurrent reads must share one load, not fan out into N.
153
+ const results = await Promise.all([s.read(), s.read(), s.read(), s.read()]);
154
+ for (const r of results) expect(r).toEqual([{ id: 'a', n: 1 }]);
155
+ expect(loadCalls).toBe(1);
156
+ });
157
+
118
158
  it('leaves the prior file intact when the atomic rename fails mid-write', async () => {
119
159
  const s = store();
120
160
  await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
@@ -65,7 +65,12 @@ export interface JsonFileStoreOptions<T extends { id: string }> {
65
65
  * id/createdAt minting and bespoke methods on top of {@link mutate}.
66
66
  */
67
67
  export interface JsonFileStore<T extends { id: string }> {
68
- /** Loaded snapshot (a fresh shallow copy; safe to mutate by the caller). */
68
+ /**
69
+ * Loaded snapshot: a fresh shallow copy of the array (safe to add/remove/
70
+ * reorder). The item objects are shared with the live cache — do NOT mutate
71
+ * them in place; replace them with new objects instead, or a later unrelated
72
+ * `mutate()` will persist the drive-by change.
73
+ */
69
74
  read(): Promise<T[]>;
70
75
  /** Find a single item by id, or `null`. */
71
76
  get(id: string): Promise<T | null>;
@@ -95,10 +100,13 @@ export function createJsonFileStore<T extends { id: string }>(
95
100
  } = opts;
96
101
 
97
102
  let cache: T[] | null = null;
103
+ // In-flight load so a burst of concurrent cold reads coalesces into one
104
+ // filesystem read + one parse pass, and the second loader can't clobber the
105
+ // first's cache assignment with a stale/half-applied snapshot.
106
+ let loading: Promise<void> | null = null;
98
107
  const mutex: Mutex = createMutex();
99
108
 
100
- async function ensureLoaded(): Promise<void> {
101
- if (cache) return;
109
+ async function loadIntoCache(): Promise<void> {
102
110
  let raw: string | null;
103
111
  try {
104
112
  raw = await readFile(file, 'utf8');
@@ -115,6 +123,15 @@ export function createJsonFileStore<T extends { id: string }>(
115
123
  cache = await load(raw);
116
124
  }
117
125
 
126
+ function ensureLoaded(): Promise<void> {
127
+ if (cache) return Promise.resolve();
128
+ if (loading) return loading;
129
+ loading = loadIntoCache().finally(() => {
130
+ loading = null;
131
+ });
132
+ return loading;
133
+ }
134
+
118
135
  async function persist(items: T[]): Promise<void> {
119
136
  const payload = stringify({ ...fileFields, [itemsKey]: items });
120
137
  await writeFileAtomic(file, payload, writeOptions ?? {});
@@ -133,8 +150,12 @@ export function createJsonFileStore<T extends { id: string }>(
133
150
  await mutex.run(async () => {
134
151
  await ensureLoaded();
135
152
  const updated = await fn(cache!.slice());
136
- cache = updated;
153
+ // Persist first so a write failure (ENOSPC/EACCES/EIO) leaves the
154
+ // in-memory cache consistent with disk — advancing the cache before the
155
+ // durable write would commit a phantom state on the next successful
156
+ // mutate and silently defeat the crash-atomic guarantee.
137
157
  await persist(updated);
158
+ cache = updated;
138
159
  });
139
160
  },
140
161
  invalidate(): void {
@@ -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
- if (event.usage) usage = event.usage;
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
- message: err instanceof Error ? err.message : String(err),
219
- retryable: false,
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 { MoxxyEvent, UserPromptEvent } from '../events.js';
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,28 @@ 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
+
16
38
  /** Appended to the system prompt while elision is active (see projection). */
17
39
  export const ELISION_SYSTEM_NOTE =
18
40
  'Context note: to stay within budget, older turns may appear as stubs like ' +
@@ -449,7 +471,7 @@ export function projectMessages(
449
471
  // `forModel` summary — the structured `display` is for channels.
450
472
  text = e.output.forModel;
451
473
  } else {
452
- text = typeof e.output === 'string' ? e.output : JSON.stringify(e.output ?? '');
474
+ text = safeStringifyOutput(e.output);
453
475
  }
454
476
  messages.push({
455
477
  role: 'tool_result',
@@ -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
- return canonicalize(input);
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 JSON.stringify(value);
13
- if (Array.isArray(value)) {
14
- return '[' + value.map(canonicalize).join(',') + ']';
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.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
- return LEGACY_MODE_NAMES[name] ?? name;
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
  });
@@ -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
- export function zodToJsonSchema(schema: unknown): unknown {
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') {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { createStuckLoopDetector } from './mode-helpers.js';
2
+ import { createStuckLoopDetector, stableHash } from './mode-helpers.js';
3
3
 
4
4
  describe('createStuckLoopDetector', () => {
5
5
  it('trips on exact-input repeats at repeatThreshold', () => {
@@ -39,4 +39,80 @@ describe('createStuckLoopDetector', () => {
39
39
  expect(sig.stuck).toBe(false);
40
40
  }
41
41
  });
42
+
43
+ // record() hashes the (model-supplied, `unknown`) tool input in the hot
44
+ // dispatch path; a throw there crashes the whole turn. These assert it stays
45
+ // total on hostile/partial input — the worst case the provider can hand us.
46
+ it('does not throw recording a tool input with a circular reference', () => {
47
+ const d = createStuckLoopDetector();
48
+ const input: Record<string, unknown> = { a: 1 };
49
+ input.self = input;
50
+ expect(() => d.record('weird', input)).not.toThrow();
51
+ // And a repeated circular input still trips exact detection (stable key).
52
+ d.record('weird', input);
53
+ expect(d.record('weird', input).stuck).toBe(true);
54
+ });
55
+
56
+ it('does not throw recording a tool input that carries a BigInt', () => {
57
+ const d = createStuckLoopDetector();
58
+ expect(() => d.record('weird', { n: 10n })).not.toThrow();
59
+ });
60
+
61
+ it('does not throw on a pathologically deep tool input', () => {
62
+ const d = createStuckLoopDetector();
63
+ let deep: Record<string, unknown> = {};
64
+ const root = deep;
65
+ for (let i = 0; i < 5000; i++) {
66
+ const next: Record<string, unknown> = {};
67
+ deep.child = next;
68
+ deep = next;
69
+ }
70
+ expect(() => d.record('weird', root)).not.toThrow();
71
+ });
72
+ });
73
+
74
+ describe('stableHash', () => {
75
+ it('is key-order canonical', () => {
76
+ expect(stableHash({ a: 1, b: 2 })).toBe(stableHash({ b: 2, a: 1 }));
77
+ });
78
+
79
+ it('returns a string (never throws) on circular, BigInt, and non-finite input', () => {
80
+ const circular: Record<string, unknown> = {};
81
+ circular.self = circular;
82
+ expect(typeof stableHash(circular)).toBe('string');
83
+ expect(typeof stableHash({ n: 9007199254740993n })).toBe('string');
84
+ expect(typeof stableHash({ x: Number.NaN, y: Infinity })).toBe('string');
85
+ });
86
+
87
+ it('distinguishes a BigInt from the equal-valued number', () => {
88
+ expect(stableHash({ n: 1n })).not.toBe(stableHash({ n: 1 }));
89
+ });
90
+
91
+ it('does not flag a shared (non-cyclic) sub-object as circular', () => {
92
+ const shared = { k: 'v' };
93
+ // Same object in two sibling positions is a DAG, not a cycle.
94
+ expect(stableHash({ a: shared, b: shared })).toBe(
95
+ stableHash({ a: { k: 'v' }, b: { k: 'v' } }),
96
+ );
97
+ });
98
+
99
+ it('never throws even when a value trap / getter throws', () => {
100
+ const hostileProxy = new Proxy(
101
+ {},
102
+ {
103
+ ownKeys() {
104
+ throw new Error('trap throws');
105
+ },
106
+ },
107
+ );
108
+ expect(() => stableHash({ x: hostileProxy })).not.toThrow();
109
+ const throwingGetter = Object.defineProperty({}, 'boom', {
110
+ enumerable: true,
111
+ get() {
112
+ throw new Error('getter throws');
113
+ },
114
+ });
115
+ expect(() => stableHash(throwingGetter)).not.toThrow();
116
+ expect(typeof stableHash(throwingGetter)).toBe('string');
117
+ });
42
118
  });