@yolk-sdk/codemode 0.1.0-canary.100

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 +174 -0
  3. package/dist/catalog.d.mts +74 -0
  4. package/dist/catalog.d.mts.map +1 -0
  5. package/dist/catalog.mjs +164 -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 +108 -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 +35 -0
  17. package/dist/node.d.mts.map +1 -0
  18. package/dist/node.mjs +393 -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 +77 -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 +103 -0
  33. package/dist/tool.d.mts.map +1 -0
  34. package/dist/tool.mjs +392 -0
  35. package/dist/tool.mjs.map +1 -0
  36. package/package.json +63 -0
  37. package/src/catalog.ts +305 -0
  38. package/src/classifier-tool.ts +227 -0
  39. package/src/executor.ts +122 -0
  40. package/src/index.ts +87 -0
  41. package/src/node.ts +546 -0
  42. package/src/output.ts +287 -0
  43. package/src/search.ts +113 -0
  44. package/src/store.ts +135 -0
  45. package/src/tool.ts +766 -0
package/src/catalog.ts ADDED
@@ -0,0 +1,305 @@
1
+ import { Predicate } from 'effect'
2
+ import {
3
+ renderDeclarations,
4
+ renderToolOutputType,
5
+ renderToolSample,
6
+ toCodemodeIdentifier
7
+ } from '@earendil-works/pi-codemode/declarations'
8
+ import { toolDiscovery } from '@yolk-sdk/agent/protocol'
9
+ import type { NestedTool } from '@yolk-sdk/agent/tools'
10
+ import type { CodeModeJsonSchema } from './executor.ts'
11
+
12
+ /** How a nested tool reaches scripts: `all` tools are also direct model tools; `listed` and
13
+ * `search` are the two discovery modes of `codemode`-only tools.
14
+ */
15
+ export type CodeModeToolExposure = 'all' | 'listed' | 'search'
16
+
17
+ /** One nested tool as scripts see it. */
18
+ export type CodeModeCatalogTool = {
19
+ /** Raw tool name; scripts may call `tools["<name>"](args)`. */
20
+ readonly name: string
21
+ /** Identifier for `tools.<identifier>(args)` (invalid identifier characters become `_`). */
22
+ readonly identifier: string
23
+ /**
24
+ * How scripts call this tool, used to prefix its rejections: `tools.<identifier>`, or
25
+ * `tools["<name>"]` when an earlier tool already has the identifier (see `codeModeCallLabels`;
26
+ * when the name is taken too, the tool is unreachable and the label only names it).
27
+ */
28
+ readonly callLabel: string
29
+ /** The tool's `ToolModule.id`. */
30
+ readonly namespace: string
31
+ /** The module's `ToolModule.description`, when set. */
32
+ readonly namespaceDescription?: string
33
+ readonly description: string
34
+ readonly inputSchema: CodeModeJsonSchema
35
+ /** The declared output schema, or `{ type: 'string' }` for text results. */
36
+ readonly outputSchema: CodeModeJsonSchema
37
+ /** True when the tool declares an output schema (calls resolve to `structuredContent`). */
38
+ readonly structured: boolean
39
+ readonly exposure: CodeModeToolExposure
40
+ }
41
+
42
+ const textOutputSchema: CodeModeJsonSchema = { type: 'string' }
43
+
44
+ /**
45
+ * The call label of each tool name, in order, as pi binds `tools`: each tool takes
46
+ * `tools[<identifier>]` unless an earlier tool holds that key (first tool wins), then
47
+ * `tools[<name>]` if still free. The label is `tools.<identifier>` for the tool that holds its
48
+ * identifier, otherwise `tools["<name>"]`. The executor guard and the host rejection prefix share
49
+ * it, so both name a call the same way.
50
+ */
51
+ export const codeModeCallLabels = (names: ReadonlyArray<string>): ReadonlyArray<string> => {
52
+ const bound = new Set<string>()
53
+
54
+ return names.map(name => {
55
+ const identifier = toCodemodeIdentifier(name)
56
+ const ownsIdentifier = !bound.has(identifier)
57
+
58
+ bound.add(identifier)
59
+ bound.add(name)
60
+
61
+ return ownsIdentifier ? `tools.${identifier}` : `tools[${JSON.stringify(name)}]`
62
+ })
63
+ }
64
+
65
+ /** Catalog of nested tools in resolution order. */
66
+ export const codeModeCatalog = (
67
+ tools: ReadonlyArray<NestedTool>
68
+ ): ReadonlyArray<CodeModeCatalogTool> => {
69
+ const labels = codeModeCallLabels(tools.map(({ def }) => def.name))
70
+
71
+ return tools.map(({ def, moduleId, moduleDescription }, index) => {
72
+ const tool: CodeModeCatalogTool = {
73
+ name: def.name,
74
+ identifier: toCodemodeIdentifier(def.name),
75
+ callLabel: labels[index] ?? `tools[${JSON.stringify(def.name)}]`,
76
+ namespace: moduleId,
77
+ description: def.description,
78
+ inputSchema: def.parameters,
79
+ outputSchema: def.outputSchema ?? textOutputSchema,
80
+ structured: def.outputSchema !== undefined,
81
+ exposure: toolDiscovery(def) ?? 'all'
82
+ }
83
+
84
+ return moduleDescription === undefined
85
+ ? tool
86
+ : { ...tool, namespaceDescription: moduleDescription }
87
+ })
88
+ }
89
+
90
+ /** Default inline budget of the nested tool listing, in estimated tokens. */
91
+ export const defaultCodeModeInlineBudget = 3000
92
+
93
+ /** Estimated tokens of a text: four characters per token, rounded up. */
94
+ export const estimateCodeModeTokens = (text: string): number => Math.ceil(text.length / 4)
95
+
96
+ const maxShortTypeChars = 60
97
+
98
+ const shortType = (schema: CodeModeJsonSchema): string => {
99
+ const rendered = renderToolOutputType(schema)
100
+
101
+ if (rendered.length <= maxShortTypeChars) return rendered
102
+
103
+ if (!Predicate.isBoolean(schema)) {
104
+ if (schema.type === 'array') return 'Array<unknown>'
105
+
106
+ if (schema.type === 'object') return 'object'
107
+ }
108
+
109
+ return 'unknown'
110
+ }
111
+
112
+ // Declarations only read names, descriptions, and schemas; `execute` is never called.
113
+ const declarationTool = (tool: CodeModeCatalogTool) => ({
114
+ name: tool.name,
115
+ description: tool.description,
116
+ inputSchema: tool.inputSchema,
117
+ outputSchema: tool.outputSchema,
118
+ execute: () => undefined
119
+ })
120
+
121
+ const directToolLine = (tool: CodeModeCatalogTool) =>
122
+ `- \`tools.${tool.identifier}(args)\` takes the arguments of the \`${tool.name}\` tool and resolves to \`${shortType(tool.outputSchema)}\`.`
123
+
124
+ const declarationMembers = (tools: ReadonlyArray<CodeModeCatalogTool>) =>
125
+ renderDeclarations({ tools: tools.map(declarationTool) })
126
+
127
+ type Candidate = {
128
+ readonly tool: CodeModeCatalogTool
129
+ readonly index: number
130
+ readonly cost: number
131
+ }
132
+
133
+ const candidateCost = (tool: CodeModeCatalogTool) =>
134
+ estimateCodeModeTokens(declarationMembers([tool]))
135
+
136
+ const groupByNamespace = <A extends { readonly tool: CodeModeCatalogTool }>(
137
+ items: ReadonlyArray<A>
138
+ ): ReadonlyMap<string, ReadonlyArray<A>> => {
139
+ const groups = new Map<string, Array<A>>()
140
+
141
+ for (const item of items) {
142
+ const group = groups.get(item.tool.namespace)
143
+
144
+ if (group === undefined) {
145
+ groups.set(item.tool.namespace, [item])
146
+ } else {
147
+ group.push(item)
148
+ }
149
+ }
150
+
151
+ return groups
152
+ }
153
+
154
+ export type CodeModeListing = {
155
+ /** Tools placed in the description, in catalog order: every `all` tool, and the `listed` tools
156
+ * that fit the budget.
157
+ */
158
+ readonly listed: ReadonlyArray<CodeModeCatalogTool>
159
+ /** Namespaces (catalog order) with callable tools the description does not list. */
160
+ readonly unlistedNamespaces: ReadonlyArray<string>
161
+ }
162
+
163
+ /**
164
+ * Chooses the tools the description lists. `all` tools are always placed, outside the budget
165
+ * (the model already has their declarations). `listed` tools fill `budget` estimated tokens fairly
166
+ * across namespaces: each round, every namespace still in play places its cheapest remaining tool;
167
+ * a namespace whose next tool does not fit drops out. `search` tools are never candidates.
168
+ */
169
+ export const selectCodeModeListing = (
170
+ catalog: ReadonlyArray<CodeModeCatalogTool>,
171
+ budget: number
172
+ ): CodeModeListing => {
173
+ const candidates = catalog.flatMap((tool, index): ReadonlyArray<Candidate> =>
174
+ tool.exposure === 'listed' ? [{ tool, index, cost: candidateCost(tool) }] : []
175
+ )
176
+
177
+ const queues = new Map(
178
+ [...groupByNamespace(candidates)].map(([namespace, group]) => [
179
+ namespace,
180
+ [...group].sort((left, right) => left.cost - right.cost || left.index - right.index)
181
+ ])
182
+ )
183
+
184
+ const placed = new Set(catalog.flatMap((tool, index) => (tool.exposure === 'all' ? [index] : [])))
185
+
186
+ const inPlay = new Set(queues.keys())
187
+ let remaining = budget
188
+
189
+ while (inPlay.size > 0) {
190
+ for (const [namespace, queue] of queues) {
191
+ if (!inPlay.has(namespace)) continue
192
+
193
+ const next = queue.shift()
194
+
195
+ if (next === undefined || next.cost > remaining) {
196
+ inPlay.delete(namespace)
197
+ continue
198
+ }
199
+
200
+ placed.add(next.index)
201
+ remaining -= next.cost
202
+ }
203
+ }
204
+
205
+ const unlisted = new Set(
206
+ catalog.flatMap((tool, index) => (placed.has(index) ? [] : [tool.namespace]))
207
+ )
208
+
209
+ return {
210
+ listed: catalog.filter((_, index) => placed.has(index)),
211
+ unlistedNamespaces: [...new Set(catalog.map(tool => tool.namespace))].filter(namespace =>
212
+ unlisted.has(namespace)
213
+ )
214
+ }
215
+ }
216
+
217
+ const intro = [
218
+ 'Run a JavaScript script that calls tools and returns only what matters.',
219
+ '`code` is the body of an async function: top-level `await` and `return` work. Write plain JavaScript; simple TypeScript annotations are stripped. There is no Node.js, network, filesystem, `require`, `fetch`, or timers.',
220
+ 'Call tools as `await tools.<id>(args)`. Arguments must be plain JSON (no `NaN`, `undefined` array items, `Map`, or functions). A call resolves to the declared result type and rejects with an Error whose message starts with `tools.<id>:` when the tool fails. Calls still running when the script ends are cancelled, so await every call (use `Promise.all` for parallel calls).',
221
+ 'Only the script output and its return value come back to you: filter and aggregate inside the script and return a small JSON value.'
222
+ ].join('\n')
223
+
224
+ const globalLines = (store: boolean) => [
225
+ '- `text(value)` and `console.log(...values)`: append text to the output.',
226
+ '- `image(dataUrl)`: append a base64 image (a `data:` URL or `{ type: "image", data, mimeType }`).',
227
+ '- `exit()`: end the script successfully.',
228
+ '- `ALL_TOOLS`: `{ name, description }` of every callable tool.',
229
+ '- `await searchTools(query, { limit?, namespace? })`: find tools by topic; resolves to `Array<{ name: string; description: string }>` (default limit 8).',
230
+ '- `await describeTool(name)`: the description and TypeScript declaration of a tool, or `undefined`.',
231
+ '- `await describeNamespace(name)`: `{ name, description?, tools: Array<{ name, description }> }` for a namespace, or `undefined`.',
232
+ ...(store
233
+ ? [
234
+ '- `store(key, value)` and `load(key)`: keep small JSON values for later scripts; writes are saved only when the script succeeds.'
235
+ ]
236
+ : [])
237
+ ]
238
+
239
+ const namespaceSection = (namespace: string, tools: ReadonlyArray<CodeModeCatalogTool>) => {
240
+ const declared = tools.filter(tool => tool.exposure !== 'all')
241
+ const direct = tools.filter(tool => tool.exposure === 'all')
242
+
243
+ const description = tools.find(
244
+ tool => tool.namespaceDescription !== undefined
245
+ )?.namespaceDescription
246
+
247
+ return [
248
+ `### ${namespace}`,
249
+ ...(description === undefined ? [] : [description]),
250
+ ...(declared.length > 0 ? ['```ts', declarationMembers(declared), '```'] : []),
251
+ ...direct.map(directToolLine)
252
+ ].join('\n')
253
+ }
254
+
255
+ // Fixed text: never derived from `search` tools or from which tools did not fit the budget.
256
+ const unlistedToolsLine =
257
+ 'More tools may be callable than are listed here: find them with `await searchTools(query, { namespace? })` and read one with `await describeTool(name)` or `await describeNamespace(name)` before calling it.'
258
+
259
+ export type CodeModeDescriptionInput = {
260
+ readonly tools: ReadonlyArray<NestedTool>
261
+ readonly inlineBudget?: number
262
+ /** Mention `store()`/`load()` persistence. */
263
+ readonly store?: boolean
264
+ }
265
+
266
+ /**
267
+ * The code mode tool description: intro, globals one per line, then nested tools grouped by
268
+ * namespace, then one fixed line pointing to `searchTools`/`describeTool`/`describeNamespace`.
269
+ * `all` tools get one line each outside the budget; `listed` tools are declared within the inline
270
+ * budget; `search` tools never contribute, so adding or removing them (even whole namespaces of
271
+ * them) leaves the text byte-identical.
272
+ */
273
+ export const renderCodeModeDescription = (input: CodeModeDescriptionInput): string => {
274
+ const catalog = codeModeCatalog(input.tools)
275
+
276
+ const listing = selectCodeModeListing(catalog, input.inlineBudget ?? defaultCodeModeInlineBudget)
277
+
278
+ const sections = [...groupByNamespace(listing.listed.map(tool => ({ tool })))].map(
279
+ ([namespace, items]) =>
280
+ namespaceSection(
281
+ namespace,
282
+ items.map(item => item.tool)
283
+ )
284
+ )
285
+
286
+ return [
287
+ intro,
288
+ ['Globals:', ...globalLines(input.store === true)].join('\n'),
289
+ sections.length === 0
290
+ ? 'Nested tools: none are listed here.'
291
+ : ['## Nested tools by namespace', ...sections].join('\n\n'),
292
+ unlistedToolsLine
293
+ ].join('\n\n')
294
+ }
295
+
296
+ /** `describeTool` text: the description and TypeScript declaration of one tool. */
297
+ export const describeCodeModeTool = (tool: CodeModeCatalogTool): string =>
298
+ renderToolSample(declarationTool(tool))
299
+
300
+ /** Finds a tool by identifier or raw name. */
301
+ export const findCodeModeTool = (
302
+ catalog: ReadonlyArray<CodeModeCatalogTool>,
303
+ name: string
304
+ ): CodeModeCatalogTool | undefined =>
305
+ catalog.find(tool => tool.identifier === name) ?? catalog.find(tool => tool.name === name)
@@ -0,0 +1,227 @@
1
+ import { Effect, Predicate, Semaphore } from 'effect'
2
+ import * as Schema from 'effect/Schema'
3
+ import {
4
+ ClassificationResult,
5
+ ClassifierQuestions,
6
+ ClassifierState,
7
+ type ClassificationError,
8
+ type ClassificationRequest,
9
+ type ClassificationUsage,
10
+ type ClassifierModel
11
+ } from '@yolk-sdk/agent/classification'
12
+ import {
13
+ AgentInputUsage,
14
+ AgentOutputUsage,
15
+ AgentUsage,
16
+ ToolResult,
17
+ type ToolCall
18
+ } from '@yolk-sdk/agent/protocol'
19
+ import {
20
+ makeTool,
21
+ modelVisibleToolError,
22
+ modelVisibleToolErrorStructuredContent,
23
+ type ModelVisibleToolErrorReason,
24
+ type ToolRegistration
25
+ } from '@yolk-sdk/agent/tools'
26
+
27
+ /** Default name of the classifier tool. */
28
+ export const classifierToolName = 'classify'
29
+
30
+ /** Default cap of concurrent classifications per script. */
31
+ export const defaultClassifierToolMaxConcurrency = 100
32
+
33
+ /** Default cap of concurrent classifications per process, across scripts and registrations. */
34
+ export const defaultClassifierProcessMaxConcurrency = 200
35
+
36
+ /** A concurrency cap shared by every classifier tool registration that uses it. */
37
+ export type ClassifierConcurrencyLimiter = {
38
+ /** Maximum concurrent classifications. */
39
+ readonly max: number
40
+ /** Runs `effect` holding one permit. Waiting is interruptible and never leaks a permit; the
41
+ * permit is released when `effect` ends, however it ends.
42
+ */
43
+ readonly withPermit: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
44
+ }
45
+
46
+ const permitCount = (max: number) => Math.max(1, Math.floor(max))
47
+
48
+ /** A classifier concurrency limiter allowing `max` (at least 1) classifications at once. */
49
+ export const makeClassifierConcurrencyLimiter = (max: number): ClassifierConcurrencyLimiter => {
50
+ const permits = permitCount(max)
51
+ const semaphore = Semaphore.makeUnsafe(permits)
52
+
53
+ return { max: permits, withPermit: effect => semaphore.withPermits(1)(effect) }
54
+ }
55
+
56
+ /** The process-wide limiter every classifier tool uses unless given `processLimiter`. Default
57
+ * 200 concurrent classifications (200 concurrent AI Gateway classifications finished in about
58
+ * 1.2 s without rate-limit errors in a live probe).
59
+ */
60
+ export const defaultClassifierProcessLimiter = makeClassifierConcurrencyLimiter(
61
+ defaultClassifierProcessMaxConcurrency
62
+ )
63
+
64
+ type ClassifierModelService = (typeof ClassifierModel)['Service']
65
+
66
+ type Classify = ClassifierModelService['classify']
67
+
68
+ export type MakeClassifierToolOptions = {
69
+ /** `ClassifierModel`'s `classify`, or the service value itself. */
70
+ readonly classify: Classify | ClassifierModelService
71
+ /** Default `classify`. */
72
+ readonly name?: string
73
+ /** Concurrent classifications per script (keyed by the parent tool call id of the nested call
74
+ * id `<parentToolCallId>/<seq>`, or the call id itself); default 100.
75
+ */
76
+ readonly maxConcurrency?: number
77
+ /** Limiter shared across scripts and registrations; default `defaultClassifierProcessLimiter`
78
+ * (200 per process). `false` disables the process cap (the per-script cap still applies).
79
+ */
80
+ readonly processLimiter?: ClassifierConcurrencyLimiter | false
81
+ readonly description?: string
82
+ }
83
+
84
+ /** The classifier tool's input: one state and its named questions. Provider options stay
85
+ * host-owned and are never taken from scripts.
86
+ */
87
+ export const ClassifierToolParams = Schema.Struct({
88
+ state: ClassifierState,
89
+ questions: ClassifierQuestions
90
+ })
91
+
92
+ const defaultDescription = (maxConcurrency: number) =>
93
+ [
94
+ 'Classify one item with a classifier model: answer named typed questions about one `state` (a string, JSON object, or JSON array) with probabilities.',
95
+ 'Question types: `boolean` (`probability` of true), `choice` (one of the `criteria` keys, with `probabilities`), and `score` (an ordinal level from the `criteria` list, lowest first).',
96
+ `Call it once per item; at most ${maxConcurrency} classifications of one script run at once and further calls queue.`
97
+ ].join(' ')
98
+
99
+ /** Token usage of a classification as agent usage. `costUsd` has no agent usage field; it stays
100
+ * in the result's `structuredContent.usage`, so nested-call records carry token usage only.
101
+ */
102
+ const agentUsage = (usage: ClassificationUsage): AgentUsage =>
103
+ AgentUsage.make({
104
+ input: AgentInputUsage.make({ total: usage.inputTokens }),
105
+ output: AgentOutputUsage.make({ total: usage.outputTokens })
106
+ })
107
+
108
+ const errorReason = (error: ClassificationError): ModelVisibleToolErrorReason =>
109
+ Predicate.isTagged(error, 'ClassificationRequestInvalid') ? 'invalid_input' : 'unavailable'
110
+
111
+ const billedUsage = (error: ClassificationError) =>
112
+ Predicate.isTagged(error, 'ClassificationResponseInvalid') ? error.usage : undefined
113
+
114
+ /** The parent tool call id of a nested call id `<parentToolCallId>/<seq>`, else the id itself. */
115
+ const scriptKey = (call: ToolCall) => {
116
+ const separator = call.id.lastIndexOf('/')
117
+
118
+ return separator > 0 ? call.id.slice(0, separator) : call.id
119
+ }
120
+
121
+ type ResultFields = {
122
+ toolCallId: string
123
+ content: string
124
+ isError?: boolean
125
+ structuredContent: unknown
126
+ usage?: AgentUsage
127
+ }
128
+
129
+ const withUsage = (fields: ResultFields, usage: ClassificationUsage | undefined) => {
130
+ if (usage !== undefined) {
131
+ fields.usage = agentUsage(usage)
132
+ }
133
+
134
+ return ToolResult.make(fields)
135
+ }
136
+
137
+ const successResult = (call: ToolCall, result: ClassificationResult) =>
138
+ withUsage(
139
+ { toolCallId: call.id, content: JSON.stringify(result.answers), structuredContent: result },
140
+ result.usage
141
+ )
142
+
143
+ const errorResult = (call: ToolCall, name: string, error: ClassificationError) =>
144
+ withUsage(
145
+ {
146
+ toolCallId: call.id,
147
+ content: error.message,
148
+ isError: true,
149
+ structuredContent: modelVisibleToolErrorStructuredContent(
150
+ modelVisibleToolError({ tool: name, reason: errorReason(error), message: error.message })
151
+ )
152
+ },
153
+ billedUsage(error)
154
+ )
155
+
156
+ /**
157
+ * A code-mode-only (`callableBy: 'codemode'`, `discovery: 'listed'`) read tool that classifies one
158
+ * item per call with a classifier model. Its content is the compact JSON of the answers, its
159
+ * `structuredContent` the full `ClassificationResult` (which nested calls resolve to), and its
160
+ * token usage is reported on `ToolResult.usage`. Classifier errors become model-visible error
161
+ * results; usage billed before a response error is kept.
162
+ *
163
+ * Each call takes a per-script permit (`maxConcurrency`) and then a process permit
164
+ * (`processLimiter`), so one script's queue never holds process permits it cannot use.
165
+ */
166
+ export const makeClassifierTool = <Context>(
167
+ options: MakeClassifierToolOptions
168
+ ): ToolRegistration<Context> => {
169
+ const name = options.name ?? classifierToolName
170
+
171
+ const maxConcurrency = permitCount(options.maxConcurrency ?? defaultClassifierToolMaxConcurrency)
172
+
173
+ const processLimiter =
174
+ options.processLimiter === undefined ? defaultClassifierProcessLimiter : options.processLimiter
175
+
176
+ const withProcessPermit = <A, E>(effect: Effect.Effect<A, E>) =>
177
+ processLimiter === false ? effect : processLimiter.withPermit(effect)
178
+
179
+ const classify = Predicate.isFunction(options.classify)
180
+ ? options.classify
181
+ : options.classify.classify
182
+
183
+ // One semaphore per script (parent tool call), removed when its last classification ends.
184
+ const scripts = new Map<string, { readonly semaphore: Semaphore.Semaphore; users: number }>()
185
+
186
+ const withScriptPermit = <A, E>(call: ToolCall, effect: Effect.Effect<A, E>) =>
187
+ Effect.acquireUseRelease(
188
+ Effect.sync(() => {
189
+ const key = scriptKey(call)
190
+
191
+ const entry = scripts.get(key) ?? {
192
+ semaphore: Semaphore.makeUnsafe(maxConcurrency),
193
+ users: 0
194
+ }
195
+
196
+ entry.users++
197
+ scripts.set(key, entry)
198
+
199
+ return { key, entry }
200
+ }),
201
+ ({ entry }) => entry.semaphore.withPermits(1)(withProcessPermit(effect)),
202
+ ({ key, entry }) =>
203
+ Effect.sync(() => {
204
+ entry.users--
205
+
206
+ if (entry.users === 0 && scripts.get(key) === entry) scripts.delete(key)
207
+ })
208
+ )
209
+
210
+ return makeTool<Context, typeof ClassifierToolParams>({
211
+ name,
212
+ description: options.description ?? defaultDescription(maxConcurrency),
213
+ parameters: ClassifierToolParams,
214
+ output: ClassificationResult,
215
+ access: 'read',
216
+ callableBy: 'codemode',
217
+ discovery: 'listed',
218
+ execute: ({ call, params }) => {
219
+ const request: ClassificationRequest = { state: params.state, questions: params.questions }
220
+
221
+ return withScriptPermit(call, classify(request)).pipe(
222
+ Effect.map(result => successResult(call, result)),
223
+ Effect.catch(error => Effect.succeed(errorResult(call, name, error)))
224
+ )
225
+ }
226
+ })
227
+ }
@@ -0,0 +1,122 @@
1
+ import type * as Schema from 'effect/Schema'
2
+ import type { ToolJsonSchema } from '@yolk-sdk/agent/protocol'
3
+
4
+ /** A JSON Schema document. Only shapes declarations; values are never validated against it. */
5
+ export type CodeModeJsonSchema = ToolJsonSchema
6
+
7
+ /** Store contents: small JSON values by key. */
8
+ export type CodeModeStore = Readonly<Record<string, Schema.Json>>
9
+
10
+ export type CodeModeToolContext = {
11
+ /** Aborted when the script ends (unawaited calls), times out, or the execution is aborted. */
12
+ readonly signal: AbortSignal
13
+ }
14
+
15
+ /**
16
+ * A function a script can call. Tools are called as `tools.<identifier>(args)` and
17
+ * `tools["<name>"](args)`; the identifier replaces characters that are not valid in a JavaScript
18
+ * identifier with `_`. Arguments and results make a JSON round trip; a rejection surfaces in the
19
+ * script as an `Error` with the same message (`makeCodeModeTool` rejects with
20
+ * `<call label>: <message>`, the label being `tools.<identifier>` or `tools["<name>"]`).
21
+ *
22
+ * `args` is the JSON value of the script's first argument (`undefined` when there is none), read
23
+ * once the way `JSON.stringify` reads it (getters and `toJSON` run once); the checked value is the
24
+ * one sent. Executors reject, inside the script and without calling `execute`, values that JSON
25
+ * would silently change instead of coercing them: non-finite numbers, invalid `Date`s,
26
+ * `undefined`/function/symbol array items and holes, function/symbol/bigint values, cycles, and
27
+ * non-plain objects without `toJSON` (`Map`, `Set`, `RegExp`, `Error`, class instances), including
28
+ * inside `toJSON` results. `undefined`-valued object keys count as absent, as JSON drops them; a
29
+ * valid `Date` passes as its ISO string. An argument too large or deep to check completely is
30
+ * rejected, never sent unchecked (the pi executor: more than 100000 values or 64 levels). A
31
+ * rejection names the call the way scripts reach it, as `makeCodeModeTool` does: the first tool
32
+ * with an identifier is `tools.<identifier>`, a later one `tools["<name>"]`.
33
+ */
34
+ export type CodeModeExecutorTool = {
35
+ readonly name: string
36
+ readonly description?: string
37
+ readonly inputSchema?: CodeModeJsonSchema
38
+ readonly outputSchema?: CodeModeJsonSchema
39
+ /** Diagnostic call label for rejection messages: `tools.<identifier>`, or `tools["<name>"]`
40
+ * when an earlier tool holds the identifier (a tool whose name is taken too is unreachable and
41
+ * the label only names it). `makeCodeModeTool` sets it; executors fall back to pi's binding rule.
42
+ */
43
+ readonly callLabel?: string
44
+ readonly execute: (args: unknown, context: CodeModeToolContext) => Promise<unknown>
45
+ }
46
+
47
+ /**
48
+ * A top-level function (`name`) or namespace member (`namespace.member`) for scripts. Globals are
49
+ * host helpers: they are not tools and are not recorded as nested calls.
50
+ */
51
+ export type CodeModeExecutorGlobal = Omit<CodeModeExecutorTool, 'callLabel'> & {
52
+ /** `execute` receives every call argument as an array instead of the first one. */
53
+ readonly spread?: boolean
54
+ /** TypeScript parameter list and return type for declarations. */
55
+ readonly signature?: string
56
+ }
57
+
58
+ /** One item of script output, in the order the script produced it. `data` is base64. */
59
+ export type CodeModeOutputItem =
60
+ | { readonly type: 'text'; readonly text: string }
61
+ | { readonly type: 'image'; readonly data: string; readonly mimeType: string }
62
+
63
+ /**
64
+ * - `script`: the script threw, failed to parse, or its TypeScript could not be stripped.
65
+ * - `timeout`: the deadline expired.
66
+ * - `aborted`: the caller's signal fired.
67
+ * - `sandbox`: the engine failed outside the script's control.
68
+ */
69
+ export type CodeModeErrorKind = 'script' | 'timeout' | 'aborted' | 'sandbox'
70
+
71
+ export type CodeModeError = {
72
+ readonly kind: CodeModeErrorKind
73
+ readonly message: string
74
+ readonly stack?: string
75
+ }
76
+
77
+ /** Keys a successful script changed with `store()`; `delete` lists keys stored as `undefined`. */
78
+ export type CodeModeStoreWrites = {
79
+ readonly set: CodeModeStore
80
+ readonly delete: ReadonlyArray<string>
81
+ }
82
+
83
+ export type CodeModeExecutionResult = {
84
+ readonly ok: boolean
85
+ /** The script's return value (JSON); absent when it returned nothing. */
86
+ readonly value?: unknown
87
+ readonly error?: CodeModeError
88
+ /** Kept for failed executions too, up to the failure. */
89
+ readonly output: ReadonlyArray<CodeModeOutputItem>
90
+ /** Only successful executions report writes. */
91
+ readonly storeWrites?: CodeModeStoreWrites
92
+ }
93
+
94
+ export type CodeModeExecuteOptions = {
95
+ readonly tools: ReadonlyArray<CodeModeExecutorTool>
96
+ readonly globals: ReadonlyArray<CodeModeExecutorGlobal>
97
+ /** Overall deadline, including time spent in tools and waiting for a free execution slot. */
98
+ readonly timeoutMs: number
99
+ /** Heap cap of the script's VM. */
100
+ readonly memoryLimitBytes: number
101
+ /** Values the script reads with `load(key)`. */
102
+ readonly store: CodeModeStore
103
+ readonly signal: AbortSignal
104
+ }
105
+
106
+ /**
107
+ * Runs one script. `code` is the body of an async function (top-level `await` and `return`) and
108
+ * may carry TypeScript annotations: executors strip them or report a `script` error. The promise
109
+ * resolves for every script outcome, including failures, timeouts, and aborts; a rejection is
110
+ * treated as a `sandbox` failure. Running tool calls must be cancelled through their signal when
111
+ * the script ends. Tool arguments follow the JSON rules of `CodeModeExecutorTool`: pass their JSON
112
+ * value and reject what JSON would silently change.
113
+ *
114
+ * `@yolk-sdk/codemode/node` provides `makePiCodeModeExecutor`; hosts on other runtimes supply their
115
+ * own engine behind this interface.
116
+ */
117
+ export type CodeModeExecutor = {
118
+ readonly execute: (
119
+ code: string,
120
+ options: CodeModeExecuteOptions
121
+ ) => Promise<CodeModeExecutionResult>
122
+ }