@moxxy/sdk 0.14.5 → 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.
- package/dist/channel.d.ts +32 -0
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +28 -1
- package/dist/channel.js.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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- 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/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 +25 -1
- 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.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/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +13 -3
- package/dist/tool-dispatch.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/package.json +1 -1
- package/src/channel.ts +37 -0
- 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/mode/collect-stream.ts +37 -7
- package/src/mode/project-messages.test.ts +64 -2
- package/src/mode/project-messages.ts +23 -1
- package/src/mode/stable-hash.ts +69 -9
- 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/stuck-loop.test.ts +77 -1
- package/src/tool-dispatch.test.ts +215 -0
- package/src/tool-dispatch.ts +13 -3
- package/src/tunnel.ts +12 -2
package/src/elision-state.ts
CHANGED
|
@@ -71,34 +71,34 @@ const EMPTY_STATE: ElisionState = {
|
|
|
71
71
|
};
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
74
|
+
* Memo of {@link computeElisionState} results, keyed on the IDENTITY of the
|
|
75
|
+
* events array each was folded from.
|
|
76
76
|
*
|
|
77
77
|
* The event log is append-only and every held event is immutable (invariant
|
|
78
78
|
* #6: events at/below the HWM never change), so a given snapshot array is a
|
|
79
79
|
* stable, never-mutated value: the same array reference always denotes the
|
|
80
80
|
* same content and therefore the same state. A new turn produces a NEW snapshot
|
|
81
|
-
* array (the live `log.slice()` returns a fresh array each call), so
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* array (the live `log.slice()` returns a fresh array each call), so a stale
|
|
82
|
+
* array key simply falls out of use — there is no way for an entry to serve a
|
|
83
|
+
* state that is stale for the array it is keyed on.
|
|
84
84
|
*
|
|
85
85
|
* Keying on identity (not a content hash of `id`/`seq`/payload) is the only
|
|
86
|
-
* SOUND
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
86
|
+
* SOUND choice for a pure fold over an arbitrary array: two logically-different
|
|
87
|
+
* logs can share ids/seqs (e.g. a test that rebuilds the same prefix with
|
|
88
|
+
* different payload, or a re-config), and a content hash that missed a payload
|
|
89
|
+
* field would serve a stale state — a correctness bug. Array identity can never
|
|
90
|
+
* collide across distinct values.
|
|
91
91
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
92
|
+
* A `WeakMap` (rather than a single global slot) survives interleaving: the
|
|
93
|
+
* runner serves multiple sessions in ONE process, so two sessions' iterations
|
|
94
|
+
* can alternate; a single slot would evict each other's memo on every call,
|
|
95
|
+
* serving ~zero hits and re-folding O(events) each time. The WeakMap holds an
|
|
96
|
+
* entry per live snapshot array and lets it be GC'd once the array is dropped,
|
|
97
|
+
* so concurrent sessions each keep their own hot snapshot. Threading a
|
|
98
|
+
* precomputed state stays the explicit zero-cost fast path; this memo covers
|
|
99
|
+
* callers that re-pass the same array but can't thread.
|
|
99
100
|
*/
|
|
100
|
-
|
|
101
|
-
let memoState: ElisionState | null = null;
|
|
101
|
+
const memo = new WeakMap<ReadonlyArray<MoxxyEvent>, ElisionState>();
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* Derive elision state purely from the log: the active high-water mark + flags
|
|
@@ -110,10 +110,10 @@ let memoState: ElisionState | null = null;
|
|
|
110
110
|
* (and `===` on a cache hit) to a fresh fold.
|
|
111
111
|
*/
|
|
112
112
|
export function computeElisionState(events: ReadonlyArray<MoxxyEvent>): ElisionState {
|
|
113
|
-
|
|
113
|
+
const cached = memo.get(events);
|
|
114
|
+
if (cached !== undefined) return cached;
|
|
114
115
|
const state = computeElisionStateUncached(events);
|
|
115
|
-
|
|
116
|
-
memoState = state;
|
|
116
|
+
memo.set(events, state);
|
|
117
117
|
return state;
|
|
118
118
|
}
|
|
119
119
|
|
package/src/errors.test.ts
CHANGED
|
@@ -117,6 +117,18 @@ describe('classifyNetworkError', () => {
|
|
|
117
117
|
it('returns null for non-network errors so other handlers can take over', () => {
|
|
118
118
|
expect(classifyNetworkError(new Error('something else'))).toBeNull();
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
it('does not throw on a malformed/relative ctx.url — degrades to the raw string', () => {
|
|
122
|
+
// A bad base URL must not make `new URL()` throw from inside the classifier
|
|
123
|
+
// and mask the real network error.
|
|
124
|
+
expect(() =>
|
|
125
|
+
classifyNetworkError(nodeFetchError('ECONNREFUSED'), { url: 'not a url' }),
|
|
126
|
+
).not.toThrow();
|
|
127
|
+
const out = classifyNetworkError(nodeFetchError('ECONNREFUSED'), { url: '/relative/path' });
|
|
128
|
+
expect(out!.code).toBe('NETWORK_UNREACHABLE');
|
|
129
|
+
expect(out!.message).toContain('/relative/path');
|
|
130
|
+
expect(out!.context).toEqual({ url: '/relative/path' });
|
|
131
|
+
});
|
|
120
132
|
});
|
|
121
133
|
|
|
122
134
|
describe('classifyHttpStatus', () => {
|
|
@@ -148,6 +160,13 @@ describe('classifyHttpStatus', () => {
|
|
|
148
160
|
expect(classifyHttpStatus(404)).toBeNull();
|
|
149
161
|
expect(classifyHttpStatus(418)).toBeNull();
|
|
150
162
|
});
|
|
163
|
+
|
|
164
|
+
it('does not throw on a malformed ctx.url — degrades to the raw string', () => {
|
|
165
|
+
expect(() => classifyHttpStatus(401, { url: 'not a url' })).not.toThrow();
|
|
166
|
+
const out = classifyHttpStatus(401, { url: 'not a url' });
|
|
167
|
+
expect(out!.code).toBe('AUTH_INVALID');
|
|
168
|
+
expect(out!.message).toContain('not a url');
|
|
169
|
+
});
|
|
151
170
|
});
|
|
152
171
|
|
|
153
172
|
describe('toFriendlyError', () => {
|
package/src/errors.ts
CHANGED
|
@@ -126,7 +126,7 @@ export function classifyNetworkError(
|
|
|
126
126
|
|
|
127
127
|
const code = extractNodeErrorCode(err);
|
|
128
128
|
const url = ctx.url;
|
|
129
|
-
const target = url ?
|
|
129
|
+
const target = url ? hostOf(url) : (ctx.provider ?? 'the upstream service');
|
|
130
130
|
const baseContext: Record<string, string> = {};
|
|
131
131
|
if (url) baseContext.url = url;
|
|
132
132
|
if (ctx.provider) baseContext.provider = ctx.provider;
|
|
@@ -256,7 +256,7 @@ export function classifyHttpStatus(
|
|
|
256
256
|
status: number,
|
|
257
257
|
ctx: { readonly url?: string; readonly provider?: string; readonly body?: string } = {},
|
|
258
258
|
): MoxxyError | null {
|
|
259
|
-
const target = ctx.provider ?? (ctx.url ?
|
|
259
|
+
const target = ctx.provider ?? (ctx.url ? hostOf(ctx.url) : 'the upstream service');
|
|
260
260
|
const tail = ctx.body ? ` — ${truncate(ctx.body, 200)}` : '';
|
|
261
261
|
const context: Record<string, string | number> = { status };
|
|
262
262
|
if (ctx.url) context.url = ctx.url;
|
|
@@ -305,6 +305,21 @@ export function classifyHttpStatus(
|
|
|
305
305
|
return null;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Extract a URL's host for display. `url` is caller-supplied (provider base
|
|
310
|
+
* URLs, OAuth token URLs) and may be malformed/relative — `new URL()` would
|
|
311
|
+
* throw `ERR_INVALID_URL` from inside the error classifier, masking the real
|
|
312
|
+
* failure the caller is trying to report. Degrade gracefully to the raw string
|
|
313
|
+
* instead of throwing while classifying an error.
|
|
314
|
+
*/
|
|
315
|
+
function hostOf(url: string): string {
|
|
316
|
+
try {
|
|
317
|
+
return new URL(url).host;
|
|
318
|
+
} catch {
|
|
319
|
+
return url;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
308
323
|
function truncate(s: string, max: number): string {
|
|
309
324
|
if (s.length <= max) return s;
|
|
310
325
|
return `${s.slice(0, max - 1)}…`;
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type {
|
|
|
31
31
|
PluginUnregisteredEvent,
|
|
32
32
|
ModeIterationEvent,
|
|
33
33
|
CompactionEvent,
|
|
34
|
+
ElisionEvent,
|
|
34
35
|
ProviderRequestEvent,
|
|
35
36
|
ProviderResponseEvent,
|
|
36
37
|
ErrorEvent,
|
|
@@ -350,9 +351,11 @@ export type {
|
|
|
350
351
|
ResolvedPluginManifest,
|
|
351
352
|
} from './plugin.js';
|
|
352
353
|
|
|
354
|
+
export { startChannelWith } from './channel.js';
|
|
353
355
|
export type {
|
|
354
356
|
Channel,
|
|
355
357
|
ChannelHandle,
|
|
358
|
+
ChannelStartArgs,
|
|
356
359
|
ChannelStartOptsBase,
|
|
357
360
|
ChannelFactoryDeps,
|
|
358
361
|
ChannelDef,
|
|
@@ -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 }]);
|
package/src/json-file-store.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,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 =
|
|
474
|
+
text = safeStringifyOutput(e.output);
|
|
453
475
|
}
|
|
454
476
|
messages.push({
|
|
455
477
|
role: 'tool_result',
|
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.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
|
});
|