@yolk-sdk/codemode 0.1.0-canary.96

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/dist/catalog.d.mts +60 -0
  4. package/dist/catalog.d.mts.map +1 -0
  5. package/dist/catalog.mjs +143 -0
  6. package/dist/catalog.mjs.map +1 -0
  7. package/dist/classifier-tool.d.mts +78 -0
  8. package/dist/classifier-tool.d.mts.map +1 -0
  9. package/dist/classifier-tool.mjs +125 -0
  10. package/dist/classifier-tool.mjs.map +1 -0
  11. package/dist/executor.d.mts +89 -0
  12. package/dist/executor.d.mts.map +1 -0
  13. package/dist/executor.mjs +1 -0
  14. package/dist/index.d.mts +8 -0
  15. package/dist/index.mjs +7 -0
  16. package/dist/node.d.mts +34 -0
  17. package/dist/node.d.mts.map +1 -0
  18. package/dist/node.mjs +215 -0
  19. package/dist/node.mjs.map +1 -0
  20. package/dist/output.d.mts +42 -0
  21. package/dist/output.d.mts.map +1 -0
  22. package/dist/output.mjs +184 -0
  23. package/dist/output.mjs.map +1 -0
  24. package/dist/search.d.mts +23 -0
  25. package/dist/search.d.mts.map +1 -0
  26. package/dist/search.mjs +58 -0
  27. package/dist/search.mjs.map +1 -0
  28. package/dist/store.d.mts +40 -0
  29. package/dist/store.d.mts.map +1 -0
  30. package/dist/store.mjs +61 -0
  31. package/dist/store.mjs.map +1 -0
  32. package/dist/tool.d.mts +75 -0
  33. package/dist/tool.d.mts.map +1 -0
  34. package/dist/tool.mjs +282 -0
  35. package/dist/tool.mjs.map +1 -0
  36. package/package.json +63 -0
  37. package/src/catalog.ts +274 -0
  38. package/src/classifier-tool.ts +227 -0
  39. package/src/executor.ts +103 -0
  40. package/src/index.ts +76 -0
  41. package/src/node.ts +334 -0
  42. package/src/output.ts +287 -0
  43. package/src/search.ts +113 -0
  44. package/src/store.ts +93 -0
  45. package/src/tool.ts +547 -0
package/src/tool.ts ADDED
@@ -0,0 +1,547 @@
1
+ import {
2
+ Cause,
3
+ Clock,
4
+ Data,
5
+ Duration,
6
+ Effect,
7
+ Exit,
8
+ Fiber,
9
+ FiberSet,
10
+ Option,
11
+ Predicate,
12
+ Scope
13
+ } from 'effect'
14
+ import * as Schema from 'effect/Schema'
15
+ import { ToolError } from '@yolk-sdk/agent/loop'
16
+ import {
17
+ emptyNestedToolCallRecorder,
18
+ nestedToolCallResultFields,
19
+ recordNestedToolCall,
20
+ contentPartText,
21
+ ToolCall,
22
+ ToolResult,
23
+ type AgentUsage,
24
+ type Content,
25
+ type NestedToolCallInput,
26
+ type NestedToolCallStatus
27
+ } from '@yolk-sdk/agent/protocol'
28
+ import {
29
+ makeTool,
30
+ type NestedToolExecutor,
31
+ type ToolExecutionInput,
32
+ type ToolRegistration
33
+ } from '@yolk-sdk/agent/tools'
34
+ import {
35
+ codeModeCatalog,
36
+ defaultCodeModeInlineBudget,
37
+ describeCodeModeTool,
38
+ findCodeModeTool,
39
+ renderCodeModeDescription,
40
+ type CodeModeCatalogTool
41
+ } from './catalog.ts'
42
+ import type {
43
+ CodeModeExecutionResult,
44
+ CodeModeExecutor,
45
+ CodeModeExecutorGlobal,
46
+ CodeModeExecutorTool,
47
+ CodeModeStore
48
+ } from './executor.ts'
49
+ import {
50
+ codeModeResultSegments,
51
+ codeModeSegmentsContent,
52
+ defaultCodeModeMaxImageBytes,
53
+ defaultCodeModeMaxImages
54
+ } from './output.ts'
55
+ import { searchCodeModeTools } from './search.ts'
56
+ import type { CodeModeStructuredContent } from './store.ts'
57
+
58
+ /** Default name of the code mode tool. */
59
+ export const codeModeToolName = 'codemode'
60
+
61
+ /** Host-owned limits of one script. */
62
+ export type CodeModeLimits = {
63
+ /** Overall deadline including nested calls. Default 120000 (clamped by `deadline`). */
64
+ readonly timeoutMs?: number
65
+ /** VM heap cap. Default 64 MiB. */
66
+ readonly memoryLimitBytes?: number
67
+ /** Nested tool calls per script; further calls reject with an Error. Default 256. */
68
+ readonly maxNestedCalls?: number
69
+ /** Model-visible result characters, cut head and tail with an omission marker. Default 40000. */
70
+ readonly maxOutputChars?: number
71
+ /** Images kept in the result, in order; later images are dropped with a note. Default 8. */
72
+ readonly maxImages?: number
73
+ /** Base64 characters of images kept in the result in total; an image that would exceed it is
74
+ * dropped with a note. Default 4 MiB.
75
+ */
76
+ readonly maxImageBytes?: number
77
+ }
78
+
79
+ export const defaultCodeModeLimits = {
80
+ timeoutMs: 120_000,
81
+ memoryLimitBytes: 64 * 1024 * 1024,
82
+ maxNestedCalls: 256,
83
+ maxOutputChars: 40_000,
84
+ maxImages: defaultCodeModeMaxImages,
85
+ maxImageBytes: defaultCodeModeMaxImageBytes
86
+ } as const satisfies Required<CodeModeLimits>
87
+
88
+ /** Kept between a host deadline and the script deadline, for result handling. */
89
+ export const codeModeDeadlineMarginMs = 5_000
90
+
91
+ /** A deadline never clamps a script below this timeout. */
92
+ export const codeModeMinimumTimeoutMs = 1_000
93
+
94
+ export type MakeCodeModeToolOptions<Context> = {
95
+ readonly executor: CodeModeExecutor
96
+ /** Default `codemode`. */
97
+ readonly name?: string
98
+ /** Estimated tokens (four characters each) for listing nested tools. Default 3000. */
99
+ readonly inlineBudget?: number
100
+ readonly limits?: CodeModeLimits
101
+ /**
102
+ * Epoch milliseconds by which the tool call must end, for example the remaining function budget
103
+ * of a Workflow step. The script timeout is clamped to it minus 5 s, but never below 1 s.
104
+ */
105
+ readonly deadline?: (context: Context) => number | undefined
106
+ /**
107
+ * The store scripts read with `load(key)`, usually rebuilt from the transcript with
108
+ * `codeModeStoreFromToolResults`. Without it every script starts with an empty store.
109
+ */
110
+ readonly loadStore?: (context: Context) => Effect.Effect<CodeModeStore, ToolError>
111
+ /**
112
+ * Runs before each nested call executes, with the nested call (id `<toolCallId>/<seq>`) and the
113
+ * host context. A failure rejects that call in the script with the message and records it as an
114
+ * `error` without executing it. Host decorators around the `ToolExecutor` (outside
115
+ * `ResolvedToolSet.execute`) never see nested calls: put per-call run-authority checks here or in
116
+ * registration-level wrappers.
117
+ */
118
+ readonly beforeNestedCall?: (input: {
119
+ readonly call: ToolCall
120
+ readonly context: Context
121
+ }) => Effect.Effect<void, string>
122
+ }
123
+
124
+ const CodeModeParams = Schema.Struct({
125
+ code: Schema.String.annotate({
126
+ description:
127
+ 'Raw JavaScript: the body of an async function. Top-level await and return work. Not a function declaration and not a module.'
128
+ })
129
+ })
130
+
131
+ type CodeModeParams = typeof CodeModeParams.Type
132
+
133
+ class CodeModeExecutorRejected extends Data.TaggedError('CodeModeExecutorRejected')<{
134
+ readonly message: string
135
+ }> {}
136
+
137
+ const errorMessage = (error: unknown) =>
138
+ error instanceof Error ? error.message : Predicate.isString(error) ? error : 'unknown error'
139
+
140
+ const nestedText = (content: Content) =>
141
+ Predicate.isString(content)
142
+ ? content
143
+ : content
144
+ .map(contentPartText)
145
+ .filter(text => text.length > 0)
146
+ .join('\n')
147
+
148
+ type NestedResolution =
149
+ | { readonly ok: true; readonly value: unknown }
150
+ | { readonly ok: false; readonly message: string }
151
+
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.
154
+ */
155
+ const resolveNestedResult = (tool: CodeModeCatalogTool, result: ToolResult): NestedResolution => {
156
+ const text = nestedText(result.content)
157
+
158
+ if (result.isError === true) {
159
+ return { ok: false, message: text.length > 0 ? text : `Tool ${tool.name} failed.` }
160
+ }
161
+
162
+ return tool.structured && result.structuredContent !== undefined
163
+ ? { ok: true, value: result.structuredContent }
164
+ : { ok: true, value: text }
165
+ }
166
+
167
+ type CallOutcome = {
168
+ status: NestedToolCallStatus
169
+ durationMs: number
170
+ error?: string
171
+ usage?: AgentUsage
172
+ }
173
+
174
+ type CallRecord = {
175
+ readonly name: string
176
+ readonly args: unknown
177
+ readonly id: string
178
+ outcome?: CallOutcome
179
+ }
180
+
181
+ const recordedOutcome = (exit: Exit.Exit<ToolResult>, durationMs: number): CallOutcome => {
182
+ if (Exit.isFailure(exit)) {
183
+ return Cause.hasInterruptsOnly(exit.cause)
184
+ ? { status: 'cancelled', durationMs }
185
+ : { status: 'error', durationMs, error: 'The tool call failed unexpectedly.' }
186
+ }
187
+
188
+ const result = exit.value
189
+
190
+ const outcome: CallOutcome =
191
+ result.isError === true
192
+ ? { status: 'error', durationMs, error: nestedText(result.content) }
193
+ : { status: 'ok', durationMs }
194
+
195
+ if (result.usage !== undefined) {
196
+ outcome.usage = result.usage
197
+ }
198
+
199
+ return outcome
200
+ }
201
+
202
+ /** What `describeNamespace(name)` resolves to. */
203
+ type CodeModeNamespaceDescription = {
204
+ readonly name: string
205
+ description?: string
206
+ readonly tools: ReadonlyArray<{ readonly name: string; readonly description: string }>
207
+ }
208
+
209
+ const SearchOptions = Schema.Struct({
210
+ limit: Schema.optionalKey(Schema.Finite),
211
+ namespace: Schema.optionalKey(Schema.String)
212
+ })
213
+
214
+ const decodeSearchOptions = Schema.decodeUnknownOption(SearchOptions)
215
+
216
+ /** `searchTools`, `describeTool`, and `describeNamespace` over every nested tool. */
217
+ const discoveryGlobals = (
218
+ catalog: ReadonlyArray<CodeModeCatalogTool>
219
+ ): ReadonlyArray<CodeModeExecutorGlobal> => [
220
+ {
221
+ name: 'searchTools',
222
+ spread: true,
223
+ execute: args => {
224
+ const [query, options] = Array.isArray(args) ? args : []
225
+
226
+ return Promise.resolve(
227
+ searchCodeModeTools(
228
+ catalog,
229
+ Predicate.isString(query) ? query : '',
230
+ Option.getOrElse(decodeSearchOptions(options ?? {}), () => ({}))
231
+ )
232
+ )
233
+ }
234
+ },
235
+ {
236
+ name: 'describeTool',
237
+ execute: name => {
238
+ const tool = Predicate.isString(name) ? findCodeModeTool(catalog, name) : undefined
239
+
240
+ return Promise.resolve(tool === undefined ? undefined : describeCodeModeTool(tool))
241
+ }
242
+ },
243
+ {
244
+ name: 'describeNamespace',
245
+ execute: name => {
246
+ const tools = catalog.filter(tool => tool.namespace === name)
247
+
248
+ if (!Predicate.isString(name) || tools.length === 0) return Promise.resolve(undefined)
249
+
250
+ const description = tools.find(
251
+ tool => tool.namespaceDescription !== undefined
252
+ )?.namespaceDescription
253
+
254
+ const namespace: CodeModeNamespaceDescription = {
255
+ name,
256
+ tools: tools.map(tool => ({ name: tool.identifier, description: tool.description }))
257
+ }
258
+
259
+ if (description !== undefined) namespace.description = description
260
+
261
+ return Promise.resolve(namespace)
262
+ }
263
+ }
264
+ ]
265
+
266
+ const hasStoreWrites = (result: CodeModeExecutionResult) =>
267
+ result.storeWrites !== undefined &&
268
+ (Object.keys(result.storeWrites.set).length > 0 || result.storeWrites.delete.length > 0)
269
+
270
+ const timeoutFor = (limit: number, deadline: number | undefined, now: number) =>
271
+ deadline === undefined
272
+ ? limit
273
+ : Math.max(codeModeMinimumTimeoutMs, Math.min(limit, deadline - now - codeModeDeadlineMarginMs))
274
+
275
+ /**
276
+ * Waits at most `codeModeDeadlineMarginMs` for an effect (interrupting nested fibers) to finish; it
277
+ * keeps running detached when a nested call does not respond to interruption in time.
278
+ */
279
+ const boundedWait = (effect: Effect.Effect<void>) =>
280
+ Effect.forkDetach(effect).pipe(
281
+ Effect.flatMap(fiber =>
282
+ Fiber.await(fiber).pipe(
283
+ Effect.timeoutOption(Duration.millis(codeModeDeadlineMarginMs)),
284
+ Effect.interruptible
285
+ )
286
+ ),
287
+ Effect.asVoid
288
+ )
289
+
290
+ type RunInput<Context> = {
291
+ readonly options: MakeCodeModeToolOptions<Context>
292
+ readonly limits: Required<CodeModeLimits>
293
+ readonly call: ToolCall
294
+ readonly context: Context
295
+ readonly code: string
296
+ readonly nested: NestedToolExecutor
297
+ }
298
+
299
+ const runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult, ToolError> =>
300
+ Effect.gen(function* () {
301
+ const { call, limits, nested, options } = input
302
+ const store = options.loadStore === undefined ? {} : yield* options.loadStore(input.context)
303
+ const startedAt = yield* Clock.currentTimeMillis
304
+ const timeoutMs = timeoutFor(limits.timeoutMs, options.deadline?.(input.context), startedAt)
305
+ const catalog = codeModeCatalog(nested.tools)
306
+
307
+ return yield* Effect.acquireUseRelease(
308
+ Scope.make(),
309
+ scope =>
310
+ Effect.gen(function* () {
311
+ // Nested calls run as fibers of this tool call (same services, interrupted with it).
312
+ const fibers = yield* FiberSet.make<ToolResult>().pipe(Scope.provide(scope))
313
+ const runFork = yield* FiberSet.runtime(fibers)<never>()
314
+ const records: Array<CallRecord> = []
315
+
316
+ const callTool =
317
+ (tool: CodeModeCatalogTool): CodeModeExecutorTool['execute'] =>
318
+ (args, { signal }) => {
319
+ if (signal.aborted) {
320
+ return Promise.reject(new Error(`tools.${tool.identifier} was cancelled.`))
321
+ }
322
+
323
+ if (records.length >= limits.maxNestedCalls) {
324
+ return Promise.reject(
325
+ new Error(
326
+ `Nested call limit reached: a script may make at most ${limits.maxNestedCalls} tool calls. Batch the work or return partial results.`
327
+ )
328
+ )
329
+ }
330
+
331
+ const params = args === undefined ? {} : args
332
+
333
+ const record: CallRecord = {
334
+ id: `${call.id}/${records.length + 1}`,
335
+ name: tool.name,
336
+ args: params
337
+ }
338
+
339
+ records.push(record)
340
+
341
+ const nestedCall = ToolCall.make({ id: record.id, name: tool.name, params })
342
+
343
+ const admitted =
344
+ options.beforeNestedCall === undefined
345
+ ? nested.execute(nestedCall)
346
+ : options.beforeNestedCall({ call: nestedCall, context: input.context }).pipe(
347
+ Effect.matchEffect({
348
+ onFailure: message =>
349
+ Effect.succeed(
350
+ ToolResult.make({
351
+ toolCallId: nestedCall.id,
352
+ content: message,
353
+ isError: true
354
+ })
355
+ ),
356
+ onSuccess: () => nested.execute(nestedCall)
357
+ })
358
+ )
359
+
360
+ const execution = Effect.gen(function* () {
361
+ const started = yield* Clock.currentTimeMillis
362
+
363
+ return yield* admitted.pipe(
364
+ Effect.onExit(exit =>
365
+ Effect.map(Clock.currentTimeMillis, finished => {
366
+ record.outcome = recordedOutcome(exit, finished - started)
367
+ })
368
+ )
369
+ )
370
+ })
371
+
372
+ return new Promise((resolve, reject) => {
373
+ runFork(execution, { signal }).addObserver(exit => {
374
+ if (Exit.isFailure(exit)) {
375
+ reject(
376
+ new Error(
377
+ Cause.hasInterruptsOnly(exit.cause)
378
+ ? `tools.${tool.identifier} was cancelled.`
379
+ : `tools.${tool.identifier} failed unexpectedly.`
380
+ )
381
+ )
382
+
383
+ return
384
+ }
385
+
386
+ const resolution = resolveNestedResult(tool, exit.value)
387
+
388
+ if (resolution.ok) {
389
+ resolve(resolution.value)
390
+ } else {
391
+ reject(new Error(resolution.message))
392
+ }
393
+ })
394
+ })
395
+ }
396
+
397
+ const tools: ReadonlyArray<CodeModeExecutorTool> = catalog.map(tool => ({
398
+ name: tool.name,
399
+ description: tool.description,
400
+ inputSchema: tool.inputSchema,
401
+ outputSchema: tool.outputSchema,
402
+ execute: callTool(tool)
403
+ }))
404
+
405
+ const result = yield* Effect.tryPromise({
406
+ try: signal =>
407
+ options.executor.execute(input.code, {
408
+ tools,
409
+ globals: discoveryGlobals(catalog),
410
+ timeoutMs,
411
+ memoryLimitBytes: limits.memoryLimitBytes,
412
+ store,
413
+ signal
414
+ }),
415
+ catch: error => new CodeModeExecutorRejected({ message: errorMessage(error) })
416
+ }).pipe(
417
+ Effect.catch(error =>
418
+ Effect.succeed<CodeModeExecutionResult>({
419
+ ok: false,
420
+ error: {
421
+ kind: 'sandbox',
422
+ message: `The code mode executor failed: ${error.message}`
423
+ },
424
+ output: []
425
+ })
426
+ ),
427
+ // Backstop for executors that miss their own deadline: interrupting aborts the signal.
428
+ Effect.timeoutOption(Duration.millis(timeoutMs + codeModeDeadlineMarginMs)),
429
+ Effect.map(
430
+ Option.getOrElse((): CodeModeExecutionResult => ({
431
+ ok: false,
432
+ error: {
433
+ kind: 'timeout',
434
+ message: `Execution timed out after ${timeoutMs} ms (the executor did not stop in time)`
435
+ },
436
+ output: []
437
+ }))
438
+ )
439
+ )
440
+
441
+ // Calls still running when the script ended are cancelled and recorded as such.
442
+ yield* boundedWait(FiberSet.clear(fibers))
443
+
444
+ const finishedAt = yield* Clock.currentTimeMillis
445
+
446
+ const recorded = records.map((record): NestedToolCallInput => ({
447
+ id: record.id,
448
+ name: record.name,
449
+ args: record.args,
450
+ // A call interrupted before it started has no outcome.
451
+ ...(record.outcome ?? { status: 'cancelled' })
452
+ }))
453
+
454
+ const recorder = recorded.reduce(recordNestedToolCall, emptyNestedToolCallRecorder)
455
+ const { nestedCalls, usage } = nestedToolCallResultFields(recorder)
456
+
457
+ const content = codeModeSegmentsContent(
458
+ codeModeResultSegments({
459
+ result,
460
+ wallTimeMs: finishedAt - startedAt,
461
+ calls: recorded,
462
+ maxChars: limits.maxOutputChars,
463
+ maxImages: limits.maxImages,
464
+ maxImageBytes: limits.maxImageBytes
465
+ })
466
+ )
467
+
468
+ const structuredContent: CodeModeStructuredContent = {
469
+ codemode:
470
+ result.ok && result.storeWrites !== undefined && hasStoreWrites(result)
471
+ ? { ok: true, storeWrites: result.storeWrites }
472
+ : { ok: result.ok }
473
+ }
474
+
475
+ type ResultFields = {
476
+ toolCallId: string
477
+ content: Content
478
+ isError?: boolean
479
+ structuredContent: CodeModeStructuredContent
480
+ nestedCalls: typeof nestedCalls
481
+ usage?: AgentUsage
482
+ }
483
+
484
+ const fields: ResultFields = {
485
+ toolCallId: call.id,
486
+ content,
487
+ structuredContent,
488
+ nestedCalls
489
+ }
490
+
491
+ if (!result.ok) {
492
+ fields.isError = true
493
+ }
494
+
495
+ if (usage !== undefined) {
496
+ fields.usage = usage
497
+ }
498
+
499
+ return ToolResult.make(fields)
500
+ }),
501
+ (scope, exit) => boundedWait(Scope.close(scope, exit))
502
+ )
503
+ })
504
+
505
+ /**
506
+ * The code mode tool: one registration (default name `codemode`, input `{ code }`) whose scripts
507
+ * call the other code-mode-callable tools of the same `resolveTools` resolution. Nested calls run
508
+ * through the resolved execute path with the same host context (input decoding, enablement,
509
+ * registration wrappers), get ids `<toolCallId>/<seq>`, and are recorded on the result's
510
+ * `nestedCalls`. The resolved description lists the callable tools (see
511
+ * `renderCodeModeDescription`).
512
+ *
513
+ * Access is `write`: scripts can call any write tool the resolution exposes to code mode; each
514
+ * nested call keeps its own access metadata and host wrappers.
515
+ */
516
+ export const makeCodeModeTool = <Context>(
517
+ options: MakeCodeModeToolOptions<Context>
518
+ ): ToolRegistration<Context> => {
519
+ const name = options.name ?? codeModeToolName
520
+ const limits: Required<CodeModeLimits> = { ...defaultCodeModeLimits, ...options.limits }
521
+ const inlineBudget = options.inlineBudget ?? defaultCodeModeInlineBudget
522
+ const store = options.loadStore !== undefined
523
+
524
+ return makeTool<Context, typeof CodeModeParams>({
525
+ name,
526
+ description: renderCodeModeDescription({ tools: [], inlineBudget, store }),
527
+ parameters: CodeModeParams,
528
+ access: 'write',
529
+ nestedToolAccess: true,
530
+ describe: ({ tools }) => renderCodeModeDescription({ tools, inlineBudget, store }),
531
+ execute: ({
532
+ call,
533
+ context,
534
+ params,
535
+ nested
536
+ }: ToolExecutionInput<Context> & { readonly params: CodeModeParams }) =>
537
+ nested === undefined
538
+ ? Effect.fail(
539
+ new ToolError({
540
+ tool: name,
541
+ cause: 'unavailable',
542
+ message: `${name} requires nested tool access; resolve it with resolveTools.`
543
+ })
544
+ )
545
+ : runScript({ options, limits, call, context, code: params.code, nested })
546
+ })
547
+ }