@yolk-sdk/codemode 0.1.0-canary.124 → 0.1.0-canary.126

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/src/node.ts CHANGED
@@ -121,7 +121,7 @@ const maxArgumentDepth = 64
121
121
  * proxy (close-match suggestions). Not a security boundary (`globalThis.tools` is unguarded):
122
122
  * arguments are still decoded by the host.
123
123
  */
124
- const toolsGuardSource = `(function (tools, labels, unbound) {
124
+ const toolsGuardSource = `(function (tools, labels, unbound, rejectionPrefix) {
125
125
  // Captured before the script runs, so a script that changes built-ins cannot change the checks.
126
126
  const { apply, getPrototypeOf, setPrototypeOf } = Reflect
127
127
  const isFinite = Number.isFinite
@@ -136,6 +136,10 @@ const toolsGuardSource = `(function (tools, labels, unbound) {
136
136
  const ObjectProto = Object.prototype
137
137
  const { trunc, min } = Math
138
138
  const stringify = JSON.stringify
139
+ const parse = JSON.parse
140
+ const ErrorCtor = Error
141
+ const startsWith = String.prototype.startsWith
142
+ const sliceString = String.prototype.slice
139
143
  const plainJson = 'pass plain JSON (objects, arrays, strings, finite numbers, booleans, null)'
140
144
  const maxValues = ${maxArgumentValues}
141
145
  const maxDepth = ${maxArgumentDepth}
@@ -236,6 +240,24 @@ const toolsGuardSource = `(function (tools, labels, unbound) {
236
240
  apply(setDelete, walk.ancestors, [value])
237
241
  return copy
238
242
  }
243
+ // The host sends every rejection of a tool call as the prefix and the JSON of { message, code? }
244
+ // (pi only carries a message): rebuild the Error with its code.
245
+ const restored = error => {
246
+ const message = error instanceof ErrorCtor ? error.message : undefined
247
+ if (typeof message !== 'string' || !apply(startsWith, message, [rejectionPrefix])) return error
248
+ let payload
249
+ try {
250
+ payload = parse(apply(sliceString, message, [rejectionPrefix.length]))
251
+ } catch {
252
+ return error
253
+ }
254
+ if (payload === null || typeof payload !== 'object') return error
255
+ const rebuilt = new ErrorCtor(typeof payload.message === 'string' ? payload.message : '')
256
+ if (typeof payload.code === 'string') {
257
+ defineProperty(rebuilt, 'code', { value: payload.code, writable: true, configurable: true })
258
+ }
259
+ return rebuilt
260
+ }
239
261
  const wrap = (label, call) => async (...args) => {
240
262
  let json
241
263
  try {
@@ -244,7 +266,11 @@ const toolsGuardSource = `(function (tools, labels, unbound) {
244
266
  if (error instanceof Rejected) throw new TypeErrorCtor(label + ': argument ' + error.message)
245
267
  throw error
246
268
  }
247
- return call(json)
269
+ try {
270
+ return await call(json)
271
+ } catch (error) {
272
+ throw restored(error)
273
+ }
248
274
  }
249
275
  // Each tool's function sits at tools[name] unless an earlier tool holds that key, in which case
250
276
  // that earlier function is already wrapped.
@@ -295,7 +321,7 @@ const guardSuffix = (
295
321
 
296
322
  const rejected = (unbound ?? []).map(entry => [entry.key, entry.message])
297
323
 
298
- return `\n})(${toolsGuardSource}(tools, ${jsonSource(pairs)}, ${jsonSource(rejected)}), console)`
324
+ return `\n})(${toolsGuardSource}(tools, ${jsonSource(pairs)}, ${jsonSource(rejected)}, ${jsonSource(rejectionPrefix)}), console)`
299
325
  }
300
326
 
301
327
  const lineTerminators = /\r\n?|[\n\u2028\u2029]/g
@@ -373,12 +399,37 @@ const stripTypes = (code: string): Stripped => {
373
399
  }
374
400
  }
375
401
 
376
- const piTool = (tool: CodeModeExecutorTool): CodemodeTool => {
377
- const converted: CodemodeTool = {
378
- name: tool.name,
379
- execute: (args, context) => tool.execute(args, context)
402
+ /**
403
+ * pi passes only the message of a rejected tool call into the VM. Every tool rejection crosses as
404
+ * this prefix and the JSON of `{ message, code? }`, which the tools guard turns back into an
405
+ * `Error` with its `code`. Calls through the unguarded `globalThis.tools` see the encoded message.
406
+ */
407
+ const rejectionPrefix = '\u0000yolk-tool-rejection:'
408
+
409
+ type EncodedRejection = { message: string; code?: string }
410
+
411
+ const RejectionCode = Schema.Struct({ code: Schema.String })
412
+
413
+ const encodedRejection = (error: unknown) => {
414
+ const payload: EncodedRejection = {
415
+ message: error instanceof Error ? error.message : String(error)
380
416
  }
381
417
 
418
+ const code = Option.getOrUndefined(
419
+ Option.map(Schema.decodeUnknownOption(RejectionCode)(error), found => found.code)
420
+ )
421
+
422
+ if (code !== undefined) payload.code = code
423
+
424
+ return new Error(`${rejectionPrefix}${JSON.stringify(payload)}`)
425
+ }
426
+
427
+ const piCallable = (
428
+ tool: CodeModeExecutorTool | CodeModeExecutorGlobal,
429
+ execute: CodemodeTool['execute']
430
+ ): CodemodeTool => {
431
+ const converted: CodemodeTool = { name: tool.name, execute }
432
+
382
433
  if (tool.description !== undefined) converted.description = tool.description
383
434
 
384
435
  if (tool.inputSchema !== undefined) converted.inputSchema = tool.inputSchema
@@ -388,8 +439,15 @@ const piTool = (tool: CodeModeExecutorTool): CodemodeTool => {
388
439
  return converted
389
440
  }
390
441
 
442
+ const piTool = (tool: CodeModeExecutorTool): CodemodeTool =>
443
+ piCallable(tool, (args, context) =>
444
+ tool.execute(args, context).catch((error: unknown) => {
445
+ throw encodedRejection(error)
446
+ })
447
+ )
448
+
391
449
  const piGlobal = (global: CodeModeExecutorGlobal): CodemodeTool => {
392
- const converted = piTool(global)
450
+ const converted = piCallable(global, (args, context) => global.execute(args, context))
393
451
 
394
452
  if (global.spread !== undefined) converted.spread = global.spread
395
453
 
package/src/tool.ts CHANGED
@@ -22,7 +22,6 @@ import {
22
22
  nestedToolCallResultFields,
23
23
  recordNestedToolCall,
24
24
  truncateCodePoints,
25
- contentPartText,
26
25
  ToolCall,
27
26
  ToolResult,
28
27
  type AgentUsage,
@@ -207,47 +206,61 @@ class CodeModeExecutorRejected extends Data.TaggedError('CodeModeExecutorRejecte
207
206
  const errorMessage = (error: unknown) =>
208
207
  error instanceof Error ? error.message : Predicate.isString(error) ? error : 'unknown error'
209
208
 
209
+ // The text a script sees of a result: its text parts only. File, image, document and audio parts
210
+ // (and the labels providers render for them) never enter scripts (ADR 0013).
210
211
  const nestedText = (content: Content) =>
211
212
  Predicate.isString(content)
212
213
  ? content
213
214
  : content
214
- .map(contentPartText)
215
+ .flatMap(part => (Predicate.isTagged(part, 'Text') ? [part.text] : []))
215
216
  .filter(text => text.length > 0)
216
217
  .join('\n')
217
218
 
218
219
  type NestedResolution =
219
220
  | { readonly ok: true; readonly value: unknown }
220
- | { readonly ok: false; readonly message: string }
221
-
222
- /** What a nested call resolves to: `structuredContent` for tools with an output schema (when
223
- * present), text content otherwise; error results reject with their text. A tool that declares an
224
- * output schema but returns no `structuredContent` resolves to its text: that is a tool bug, kept
225
- * readable rather than turned into a failure. When the result captured files (ADR 0009), the
226
- * script also gets their descriptors as `files`: merged into an object `structuredContent`,
227
- * otherwise as `{ text, files }`.
221
+ | { readonly ok: false; readonly message: string; readonly code?: string }
222
+
223
+ // The `code` of an error result: a model-visible error's (`ModelVisibleToolError.code`) or any
224
+ // structured error with a string `code` (a connector's `ProviderFailure`).
225
+ const decodeErrorCode = Schema.decodeUnknownOption(Schema.Struct({ code: Schema.String }))
226
+
227
+ const errorCode = (result: ToolResult) =>
228
+ Option.getOrUndefined(
229
+ Option.map(decodeErrorCode(result.structuredContent), envelope => envelope.code)
230
+ )
231
+
232
+ /** A typed tool's successful result without its value: the script never falls back to text. */
233
+ const isInvalidTypedResult = (tool: CodeModeCatalogTool, result: ToolResult) =>
234
+ tool.structured && result.isError !== true && result.structuredContent === undefined
235
+
236
+ /**
237
+ * What a nested call resolves to (ADR 0013). A typed tool (one with an output schema) resolves to
238
+ * its value as declared, file fields as `FileRef` or `FileUnavailable`; a typed result without a
239
+ * value rejects ("returned an invalid result"). An untyped tool resolves to its text parts joined
240
+ * with newlines. Error results reject with their text and the `code` of their structured error
241
+ * (a model-visible error's code, a connector failure's code). Files never reach scripts as labels.
228
242
  */
229
243
  const resolveNestedResult = (tool: CodeModeCatalogTool, result: ToolResult): NestedResolution => {
230
- const text = nestedText(result.content)
231
-
232
244
  if (result.isError === true) {
233
- return { ok: false, message: text.length > 0 ? text : `Tool ${tool.name} failed.` }
245
+ const text = nestedText(result.content)
246
+ const message = text.length > 0 ? text : `Tool ${tool.name} failed.`
247
+ const code = errorCode(result)
248
+
249
+ return code === undefined ? { ok: false, message } : { ok: false, message, code }
234
250
  }
235
251
 
236
- const value =
237
- tool.structured && result.structuredContent !== undefined ? result.structuredContent : text
252
+ if (!tool.structured) return { ok: true, value: nestedText(result.content) }
238
253
 
239
- if (result.files === undefined || result.files.length === 0) return { ok: true, value }
254
+ return result.structuredContent === undefined
255
+ ? { ok: false, message: 'returned an invalid result' }
256
+ : { ok: true, value: result.structuredContent }
257
+ }
240
258
 
241
- const files = result.files.map(file => ({
242
- fileId: file.fileId,
243
- name: file.name,
244
- mimeType: file.mimeType,
245
- size: file.size
246
- }))
259
+ /** The script-visible rejection of a nested call, with the tool's error `code` when it has one. */
260
+ const nestedCallError = (label: string, resolution: { message: string; code?: string }) => {
261
+ const error = new Error(`${label}: ${resolution.message}`)
247
262
 
248
- return Predicate.isObject(value) && !Array.isArray(value)
249
- ? { ok: true, value: { ...value, files } }
250
- : { ok: true, value: { text, files } }
263
+ return resolution.code === undefined ? error : Object.assign(error, { code: resolution.code })
251
264
  }
252
265
 
253
266
  type CallOutcome = {
@@ -642,6 +655,13 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
642
655
  Effect.map(Clock.currentTimeMillis, finished => {
643
656
  record.outcome = recordedOutcome(exit, finished - started)
644
657
  })
658
+ ),
659
+ Effect.tap(result =>
660
+ isInvalidTypedResult(tool, result)
661
+ ? Effect.logWarning(
662
+ `Code mode: ${tool.name} declares an output schema but returned no value (call ${nestedCall.id}).`
663
+ )
664
+ : Effect.void
645
665
  )
646
666
  )
647
667
  })
@@ -666,7 +686,7 @@ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult,
666
686
  resolve(resolution.value)
667
687
  } else {
668
688
  // The script sees which call failed; the nested-call record keeps the raw text.
669
- reject(new Error(`${tool.callLabel}: ${resolution.message}`))
689
+ reject(nestedCallError(tool.callLabel, resolution))
670
690
  }
671
691
  })
672
692
  })