@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.
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/catalog.d.mts +74 -0
- package/dist/catalog.d.mts.map +1 -0
- package/dist/catalog.mjs +164 -0
- package/dist/catalog.mjs.map +1 -0
- package/dist/classifier-tool.d.mts +78 -0
- package/dist/classifier-tool.d.mts.map +1 -0
- package/dist/classifier-tool.mjs +125 -0
- package/dist/classifier-tool.mjs.map +1 -0
- package/dist/executor.d.mts +108 -0
- package/dist/executor.d.mts.map +1 -0
- package/dist/executor.mjs +1 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +7 -0
- package/dist/node.d.mts +35 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +393 -0
- package/dist/node.mjs.map +1 -0
- package/dist/output.d.mts +42 -0
- package/dist/output.d.mts.map +1 -0
- package/dist/output.mjs +184 -0
- package/dist/output.mjs.map +1 -0
- package/dist/search.d.mts +23 -0
- package/dist/search.d.mts.map +1 -0
- package/dist/search.mjs +58 -0
- package/dist/search.mjs.map +1 -0
- package/dist/store.d.mts +77 -0
- package/dist/store.d.mts.map +1 -0
- package/dist/store.mjs +61 -0
- package/dist/store.mjs.map +1 -0
- package/dist/tool.d.mts +103 -0
- package/dist/tool.d.mts.map +1 -0
- package/dist/tool.mjs +392 -0
- package/dist/tool.mjs.map +1 -0
- package/package.json +63 -0
- package/src/catalog.ts +305 -0
- package/src/classifier-tool.ts +227 -0
- package/src/executor.ts +122 -0
- package/src/index.ts +87 -0
- package/src/node.ts +546 -0
- package/src/output.ts +287 -0
- package/src/search.ts +113 -0
- package/src/store.ts +135 -0
- 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
|
+
}
|
package/src/executor.ts
ADDED
|
@@ -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
|
+
}
|