@yolk-sdk/codemode 0.1.0-canary.96 → 0.1.0-canary.97
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/README.md +19 -7
- package/dist/catalog.d.mts +16 -2
- package/dist/catalog.d.mts.map +1 -1
- package/dist/catalog.mjs +39 -18
- package/dist/catalog.mjs.map +1 -1
- package/dist/executor.d.mts +22 -3
- package/dist/executor.d.mts.map +1 -1
- package/dist/node.d.mts +2 -1
- package/dist/node.d.mts.map +1 -1
- package/dist/node.mjs +183 -5
- package/dist/node.mjs.map +1 -1
- package/dist/tool.d.mts.map +1 -1
- package/dist/tool.mjs +8 -5
- package/dist/tool.mjs.map +1 -1
- package/package.json +3 -3
- package/src/catalog.ts +34 -3
- package/src/executor.ts +22 -3
- package/src/node.ts +217 -5
- package/src/tool.ts +10 -6
package/src/node.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type CodemodeTool,
|
|
9
9
|
type CodemodeWasmModule
|
|
10
10
|
} from '@earendil-works/pi-codemode'
|
|
11
|
+
import { codeModeCallLabels } from './catalog.ts'
|
|
11
12
|
import type {
|
|
12
13
|
CodeModeError,
|
|
13
14
|
CodeModeExecuteOptions,
|
|
@@ -96,6 +97,215 @@ const wrapperPrefix = 'async function __yolkCodeMode() {\n'
|
|
|
96
97
|
|
|
97
98
|
const wrapperSuffix = '\n}'
|
|
98
99
|
|
|
100
|
+
/** Most values one argument may hold; a larger argument is rejected. */
|
|
101
|
+
const maxArgumentValues = 100_000
|
|
102
|
+
|
|
103
|
+
/** Deepest object/array nesting one argument may have; deeper arguments are rejected. */
|
|
104
|
+
const maxArgumentDepth = 64
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Evaluated in the VM before the script runs: `(tools, labels) => guardedTools`, with `labels` the
|
|
108
|
+
* `[name, callLabel]` pairs of the tools in order (`codeModeCallLabels`). Each call walks its first
|
|
109
|
+
* argument once, the way `JSON.stringify` reads it (each own enumerable string key and each array
|
|
110
|
+
* index read once, so getters run once; a callable `toJSON` is called once with the key, `''` at
|
|
111
|
+
* the root, and its result is walked at the same path), and builds a fresh copy (null-prototype
|
|
112
|
+
* objects and arrays) that is what the call sends. It rejects with a `TypeError` naming the call
|
|
113
|
+
* label and path when JSON would change the value instead of carrying it: non-finite numbers,
|
|
114
|
+
* invalid `Date`s, `undefined`/function/symbol array items and holes, function/symbol/bigint
|
|
115
|
+
* values, cycles, and objects that are neither plain (prototype `Object.prototype` or `null`) nor
|
|
116
|
+
* arrays and have no `toJSON`. An argument with more than `maxArgumentValues` values or nested more
|
|
117
|
+
* than `maxArgumentDepth` levels is rejected too, never sent unchecked. `undefined`-valued keys are
|
|
118
|
+
* left out (absent, as JSON drops them); the same object twice (not a cycle) is copied twice. An
|
|
119
|
+
* error thrown by a getter or `toJSON` rejects the call with that error. A rejected call never
|
|
120
|
+
* reaches the host. Every member of pi's `tools` is wrapped; unknown members fall through to pi's
|
|
121
|
+
* proxy (close-match suggestions). Not a security boundary (`globalThis.tools` is unguarded):
|
|
122
|
+
* arguments are still decoded by the host.
|
|
123
|
+
*/
|
|
124
|
+
const toolsGuardSource = `(function (tools, labels) {
|
|
125
|
+
// Captured before the script runs, so a script that changes built-ins cannot change the checks.
|
|
126
|
+
const { apply, getPrototypeOf, setPrototypeOf } = Reflect
|
|
127
|
+
const isFinite = Number.isFinite
|
|
128
|
+
const isArray = Array.isArray
|
|
129
|
+
const { keys, create, defineProperty } = Object
|
|
130
|
+
const getTime = Date.prototype.getTime
|
|
131
|
+
const TypeErrorCtor = TypeError
|
|
132
|
+
const SetCtor = Set
|
|
133
|
+
const setAdd = Set.prototype.add
|
|
134
|
+
const setHas = Set.prototype.has
|
|
135
|
+
const setDelete = Set.prototype.delete
|
|
136
|
+
const ObjectProto = Object.prototype
|
|
137
|
+
const { trunc, min } = Math
|
|
138
|
+
const stringify = JSON.stringify
|
|
139
|
+
const plainJson = 'pass plain JSON (objects, arrays, strings, finite numbers, booleans, null)'
|
|
140
|
+
const maxValues = ${maxArgumentValues}
|
|
141
|
+
const maxDepth = ${maxArgumentDepth}
|
|
142
|
+
class Rejected {
|
|
143
|
+
constructor(message) {
|
|
144
|
+
defineProperty(this, 'message', { value: message })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const keyPath = (path, key) =>
|
|
148
|
+
/^[A-Za-z_$][\\w$]*$/.test(key)
|
|
149
|
+
? (path === '' ? key : path + '.' + key)
|
|
150
|
+
: path + '[' + stringify(key) + ']'
|
|
151
|
+
const typeName = value => {
|
|
152
|
+
const proto = getPrototypeOf(value)
|
|
153
|
+
const name = proto && typeof proto.constructor === 'function' ? proto.constructor.name : ''
|
|
154
|
+
return name ? (/^[AEIOU]/.test(name) ? 'an ' : 'a ') + name : 'a non-plain object'
|
|
155
|
+
}
|
|
156
|
+
// The JSON copy of a value already read from its holder at key (undefined: absent), or throws
|
|
157
|
+
// Rejected with why JSON would not carry it unchanged.
|
|
158
|
+
const snapshot = (read, key, path, slot, walk) => {
|
|
159
|
+
const at = path === '' ? 'is ' : 'at ' + path + ' is '
|
|
160
|
+
if (++walk.values > maxValues) {
|
|
161
|
+
throw new Rejected('has more than ' + maxValues + ' values; split the work across calls')
|
|
162
|
+
}
|
|
163
|
+
let value = read
|
|
164
|
+
const kind = typeof value
|
|
165
|
+
if ((kind === 'object' && value !== null) || kind === 'function' || kind === 'bigint') {
|
|
166
|
+
// The intrinsic getTime reads a Date's internal time (and throws for anything else).
|
|
167
|
+
let time
|
|
168
|
+
try {
|
|
169
|
+
time = apply(getTime, value, [])
|
|
170
|
+
} catch {
|
|
171
|
+
time = undefined
|
|
172
|
+
}
|
|
173
|
+
if (time !== undefined && !isFinite(time)) {
|
|
174
|
+
throw new Rejected(at + 'an invalid Date; pass a valid Date or an ISO string')
|
|
175
|
+
}
|
|
176
|
+
const toJSON = value.toJSON
|
|
177
|
+
if (typeof toJSON === 'function') value = apply(toJSON, value, [key])
|
|
178
|
+
}
|
|
179
|
+
switch (typeof value) {
|
|
180
|
+
case 'string':
|
|
181
|
+
case 'boolean':
|
|
182
|
+
return value
|
|
183
|
+
case 'number':
|
|
184
|
+
if (isFinite(value)) return value
|
|
185
|
+
throw new Rejected(
|
|
186
|
+
at + value + '; pass a finite number' + (slot === 'key' ? ' or omit the key' : '')
|
|
187
|
+
)
|
|
188
|
+
case 'undefined':
|
|
189
|
+
if (slot === 'item') throw new Rejected(at + 'undefined; arrays cannot hold undefined')
|
|
190
|
+
return undefined
|
|
191
|
+
case 'bigint':
|
|
192
|
+
case 'function':
|
|
193
|
+
case 'symbol':
|
|
194
|
+
throw new Rejected(at + 'a ' + typeof value + '; ' + plainJson)
|
|
195
|
+
}
|
|
196
|
+
if (value === null) return null
|
|
197
|
+
if (apply(setHas, walk.ancestors, [value])) {
|
|
198
|
+
throw new Rejected(at + 'a circular reference; ' + plainJson)
|
|
199
|
+
}
|
|
200
|
+
if (walk.depth >= maxDepth) {
|
|
201
|
+
throw new Rejected('at ' + path + ' is nested more than ' + maxDepth + ' levels deep')
|
|
202
|
+
}
|
|
203
|
+
const array = isArray(value)
|
|
204
|
+
if (!array) {
|
|
205
|
+
const proto = getPrototypeOf(value)
|
|
206
|
+
if (proto !== ObjectProto && proto !== null) {
|
|
207
|
+
throw new Rejected(at + typeName(value) + '; ' + plainJson)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
apply(setAdd, walk.ancestors, [value])
|
|
211
|
+
walk.depth++
|
|
212
|
+
let copy
|
|
213
|
+
if (array) {
|
|
214
|
+
// LengthOfArrayLike, read once, as JSON.stringify does.
|
|
215
|
+
// Unary plus is ToNumber: one coercion, and it throws for a bigint as JSON.stringify does.
|
|
216
|
+
const raw = +value.length
|
|
217
|
+
const length = raw > 0 ? min(trunc(raw), 9007199254740991) : 0
|
|
218
|
+
// No prototype: no inherited toJSON or index setter can change the checked copy when pi
|
|
219
|
+
// serializes it (still an array to JSON.stringify).
|
|
220
|
+
copy = []
|
|
221
|
+
setPrototypeOf(copy, null)
|
|
222
|
+
for (let i = 0; i < length; i++) {
|
|
223
|
+
copy[i] = snapshot(value[i], '' + i, path + '[' + i + ']', 'item', walk)
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
copy = create(null)
|
|
227
|
+
// Indexed over the fresh key array: no iterator a script could replace.
|
|
228
|
+
const names = keys(value)
|
|
229
|
+
for (let i = 0; i < names.length; i++) {
|
|
230
|
+
const name = names[i]
|
|
231
|
+
const item = snapshot(value[name], name, keyPath(path, name), 'key', walk)
|
|
232
|
+
if (item !== undefined) copy[name] = item
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
walk.depth--
|
|
236
|
+
apply(setDelete, walk.ancestors, [value])
|
|
237
|
+
return copy
|
|
238
|
+
}
|
|
239
|
+
const wrap = (label, call) => async (...args) => {
|
|
240
|
+
let json
|
|
241
|
+
try {
|
|
242
|
+
json = snapshot(args[0], '', '', 'root', { values: 0, depth: 0, ancestors: new SetCtor() })
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (error instanceof Rejected) throw new TypeErrorCtor(label + ': argument ' + error.message)
|
|
245
|
+
throw error
|
|
246
|
+
}
|
|
247
|
+
return call(json)
|
|
248
|
+
}
|
|
249
|
+
// Each tool's function sits at tools[name] unless an earlier tool holds that key, in which case
|
|
250
|
+
// that earlier function is already wrapped.
|
|
251
|
+
const wrappers = new Map()
|
|
252
|
+
for (const [name, label] of labels) {
|
|
253
|
+
const call = name in tools ? tools[name] : undefined
|
|
254
|
+
if (typeof call === 'function' && !wrappers.has(call)) wrappers.set(call, wrap(label, call))
|
|
255
|
+
}
|
|
256
|
+
const guarded = Object.create(null)
|
|
257
|
+
for (const key of Object.keys(tools)) {
|
|
258
|
+
const call = tools[key]
|
|
259
|
+
if (!wrappers.has(call)) wrappers.set(call, wrap('tools[' + JSON.stringify(key) + ']', call))
|
|
260
|
+
guarded[key] = wrappers.get(call)
|
|
261
|
+
}
|
|
262
|
+
return new Proxy(Object.freeze(guarded), {
|
|
263
|
+
get: (target, key) => (key in target ? target[key] : tools[key])
|
|
264
|
+
})
|
|
265
|
+
})`
|
|
266
|
+
|
|
267
|
+
// pi runs `(async (tools, console) => {<code>\n})`. The script runs in an inner function of the
|
|
268
|
+
// same shape whose `tools` is guarded; the prefix shares line 1 with the script (only line-1
|
|
269
|
+
// columns shift) and the guard follows the script, so its line numbers are unchanged.
|
|
270
|
+
const guardPrefix = 'return (async (tools, console) => {'
|
|
271
|
+
|
|
272
|
+
/** JSON as a JavaScript expression (U+2028/U+2029 escaped for older parsers). */
|
|
273
|
+
const jsonSource = (value: unknown) =>
|
|
274
|
+
JSON.stringify(value).replace(/[\u2028\u2029]/g, char =>
|
|
275
|
+
char === '\u2028' ? '\\u2028' : '\\u2029'
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const guardSuffix = (tools: ReadonlyArray<CodeModeExecutorTool>) => {
|
|
279
|
+
const names = tools.map(tool => tool.name)
|
|
280
|
+
const labels = codeModeCallLabels(names)
|
|
281
|
+
|
|
282
|
+
// `makeCodeModeTool` supplies each label; other callers get pi's binding rule.
|
|
283
|
+
const pairs = tools.map((tool, index) => [
|
|
284
|
+
tool.name,
|
|
285
|
+
tool.callLabel ?? labels[index] ?? `tools[${JSON.stringify(tool.name)}]`
|
|
286
|
+
])
|
|
287
|
+
|
|
288
|
+
return `\n})(${toolsGuardSource}(tools, ${jsonSource(pairs)}), console)`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const lineTerminators = /\r\n?|[\n\u2028\u2029]/g
|
|
292
|
+
|
|
293
|
+
/** Lines of the script as QuickJS counts them (at least; never fewer). */
|
|
294
|
+
const scriptLineCount = (code: string) => (code.match(lineTerminators)?.length ?? 0) + 1
|
|
295
|
+
|
|
296
|
+
const scriptFrame = /codemode\.js:(\d+):\d+/
|
|
297
|
+
|
|
298
|
+
/** Drops stack frames of the wrapper and guard, which sit below the script's last line. */
|
|
299
|
+
const withoutWrapperFrames = (stack: string, scriptLines: number) =>
|
|
300
|
+
stack
|
|
301
|
+
.split('\n')
|
|
302
|
+
.filter(line => {
|
|
303
|
+
const frame = scriptFrame.exec(line)
|
|
304
|
+
|
|
305
|
+
return frame === null || Number(frame[1]) <= scriptLines
|
|
306
|
+
})
|
|
307
|
+
.join('\n')
|
|
308
|
+
|
|
99
309
|
const ErrorWithCode = Schema.Struct({ code: Schema.String })
|
|
100
310
|
|
|
101
311
|
const errorCode = (error: unknown) =>
|
|
@@ -205,7 +415,7 @@ type MutableExecutionResult = {
|
|
|
205
415
|
storeWrites?: CodeModeStoreWrites
|
|
206
416
|
}
|
|
207
417
|
|
|
208
|
-
const fromPiResult = (result: CodemodeResult): CodeModeExecutionResult => {
|
|
418
|
+
const fromPiResult = (result: CodemodeResult, scriptLines: number): CodeModeExecutionResult => {
|
|
209
419
|
if (result.ok) {
|
|
210
420
|
const storeWrites = decodeStoreWrites(result.storeWrites)
|
|
211
421
|
|
|
@@ -235,7 +445,7 @@ const fromPiResult = (result: CodemodeResult): CodeModeExecutionResult => {
|
|
|
235
445
|
message: named ? `${name}: ${message}` : message
|
|
236
446
|
}
|
|
237
447
|
|
|
238
|
-
if (stack !== undefined) error.stack = stack
|
|
448
|
+
if (stack !== undefined) error.stack = withoutWrapperFrames(stack, scriptLines)
|
|
239
449
|
|
|
240
450
|
return { ok: false, error, output: result.output }
|
|
241
451
|
}
|
|
@@ -245,7 +455,8 @@ const describeError = (error: unknown) => (error instanceof Error ? error.messag
|
|
|
245
455
|
/**
|
|
246
456
|
* A `CodeModeExecutor` on `@earendil-works/pi-codemode`: every execution gets a fresh QuickJS VM
|
|
247
457
|
* (WebAssembly) in a fresh worker thread, closed when the execution ends. Applies the timeout,
|
|
248
|
-
* heap cap, abort signal, and store, strips TypeScript annotations first,
|
|
458
|
+
* heap cap, abort signal, and store, strips TypeScript annotations first, rejects tool arguments
|
|
459
|
+
* that JSON would silently change inside the script (see `CodeModeExecutorTool`), and caps concurrent
|
|
249
460
|
* executions per executor; create one executor per process (module scope) so the cap is
|
|
250
461
|
* per process.
|
|
251
462
|
*
|
|
@@ -299,11 +510,12 @@ export const makePiCodeModeExecutor = (
|
|
|
299
510
|
|
|
300
511
|
try {
|
|
301
512
|
return fromPiResult(
|
|
302
|
-
await sandbox.execute(stripped.code
|
|
513
|
+
await sandbox.execute(`${guardPrefix}${stripped.code}${guardSuffix(execution.tools)}`, {
|
|
303
514
|
signal: execution.signal,
|
|
304
515
|
store: execution.store,
|
|
305
516
|
timeoutMs
|
|
306
|
-
})
|
|
517
|
+
}),
|
|
518
|
+
scriptLineCount(stripped.code)
|
|
307
519
|
)
|
|
308
520
|
} finally {
|
|
309
521
|
await sandbox.close()
|
package/src/tool.ts
CHANGED
|
@@ -150,7 +150,9 @@ type NestedResolution =
|
|
|
150
150
|
| { readonly ok: false; readonly message: string }
|
|
151
151
|
|
|
152
152
|
/** What a nested call resolves to: `structuredContent` for tools with an output schema (when
|
|
153
|
-
* present), text content otherwise; error results reject with their text.
|
|
153
|
+
* present), text content otherwise; error results reject with their text. A tool that declares an
|
|
154
|
+
* output schema but returns no `structuredContent` resolves to its text: that is a tool bug, kept
|
|
155
|
+
* readable rather than turned into a failure.
|
|
154
156
|
*/
|
|
155
157
|
const resolveNestedResult = (tool: CodeModeCatalogTool, result: ToolResult): NestedResolution => {
|
|
156
158
|
const text = nestedText(result.content)
|
|
@@ -317,13 +319,13 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
|
|
|
317
319
|
(tool: CodeModeCatalogTool): CodeModeExecutorTool['execute'] =>
|
|
318
320
|
(args, { signal }) => {
|
|
319
321
|
if (signal.aborted) {
|
|
320
|
-
return Promise.reject(new Error(
|
|
322
|
+
return Promise.reject(new Error(`${tool.callLabel} was cancelled.`))
|
|
321
323
|
}
|
|
322
324
|
|
|
323
325
|
if (records.length >= limits.maxNestedCalls) {
|
|
324
326
|
return Promise.reject(
|
|
325
327
|
new Error(
|
|
326
|
-
|
|
328
|
+
`${tool.callLabel}: Nested call limit reached: a script may make at most ${limits.maxNestedCalls} tool calls. Batch the work or return partial results.`
|
|
327
329
|
)
|
|
328
330
|
)
|
|
329
331
|
}
|
|
@@ -375,8 +377,8 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
|
|
|
375
377
|
reject(
|
|
376
378
|
new Error(
|
|
377
379
|
Cause.hasInterruptsOnly(exit.cause)
|
|
378
|
-
?
|
|
379
|
-
:
|
|
380
|
+
? `${tool.callLabel} was cancelled.`
|
|
381
|
+
: `${tool.callLabel} failed unexpectedly.`
|
|
380
382
|
)
|
|
381
383
|
)
|
|
382
384
|
|
|
@@ -388,7 +390,8 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
|
|
|
388
390
|
if (resolution.ok) {
|
|
389
391
|
resolve(resolution.value)
|
|
390
392
|
} else {
|
|
391
|
-
|
|
393
|
+
// The script sees which call failed; the nested-call record keeps the raw text.
|
|
394
|
+
reject(new Error(`${tool.callLabel}: ${resolution.message}`))
|
|
392
395
|
}
|
|
393
396
|
})
|
|
394
397
|
})
|
|
@@ -399,6 +402,7 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
|
|
|
399
402
|
description: tool.description,
|
|
400
403
|
inputSchema: tool.inputSchema,
|
|
401
404
|
outputSchema: tool.outputSchema,
|
|
405
|
+
callLabel: tool.callLabel,
|
|
402
406
|
execute: callTool(tool)
|
|
403
407
|
}))
|
|
404
408
|
|