@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.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/catalog.d.mts +60 -0
- package/dist/catalog.d.mts.map +1 -0
- package/dist/catalog.mjs +143 -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 +89 -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 +34 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +215 -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 +40 -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 +75 -0
- package/dist/tool.d.mts.map +1 -0
- package/dist/tool.mjs +282 -0
- package/dist/tool.mjs.map +1 -0
- package/package.json +63 -0
- package/src/catalog.ts +274 -0
- package/src/classifier-tool.ts +227 -0
- package/src/executor.ts +103 -0
- package/src/index.ts +76 -0
- package/src/node.ts +334 -0
- package/src/output.ts +287 -0
- package/src/search.ts +113 -0
- package/src/store.ts +93 -0
- package/src/tool.ts +547 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool.mjs","names":[],"sources":["../src/tool.ts"],"sourcesContent":["import {\n Cause,\n Clock,\n Data,\n Duration,\n Effect,\n Exit,\n Fiber,\n FiberSet,\n Option,\n Predicate,\n Scope\n} from 'effect'\nimport * as Schema from 'effect/Schema'\nimport { ToolError } from '@yolk-sdk/agent/loop'\nimport {\n emptyNestedToolCallRecorder,\n nestedToolCallResultFields,\n recordNestedToolCall,\n contentPartText,\n ToolCall,\n ToolResult,\n type AgentUsage,\n type Content,\n type NestedToolCallInput,\n type NestedToolCallStatus\n} from '@yolk-sdk/agent/protocol'\nimport {\n makeTool,\n type NestedToolExecutor,\n type ToolExecutionInput,\n type ToolRegistration\n} from '@yolk-sdk/agent/tools'\nimport {\n codeModeCatalog,\n defaultCodeModeInlineBudget,\n describeCodeModeTool,\n findCodeModeTool,\n renderCodeModeDescription,\n type CodeModeCatalogTool\n} from './catalog.ts'\nimport type {\n CodeModeExecutionResult,\n CodeModeExecutor,\n CodeModeExecutorGlobal,\n CodeModeExecutorTool,\n CodeModeStore\n} from './executor.ts'\nimport {\n codeModeResultSegments,\n codeModeSegmentsContent,\n defaultCodeModeMaxImageBytes,\n defaultCodeModeMaxImages\n} from './output.ts'\nimport { searchCodeModeTools } from './search.ts'\nimport type { CodeModeStructuredContent } from './store.ts'\n\n/** Default name of the code mode tool. */\nexport const codeModeToolName = 'codemode'\n\n/** Host-owned limits of one script. */\nexport type CodeModeLimits = {\n /** Overall deadline including nested calls. Default 120000 (clamped by `deadline`). */\n readonly timeoutMs?: number\n /** VM heap cap. Default 64 MiB. */\n readonly memoryLimitBytes?: number\n /** Nested tool calls per script; further calls reject with an Error. Default 256. */\n readonly maxNestedCalls?: number\n /** Model-visible result characters, cut head and tail with an omission marker. Default 40000. */\n readonly maxOutputChars?: number\n /** Images kept in the result, in order; later images are dropped with a note. Default 8. */\n readonly maxImages?: number\n /** Base64 characters of images kept in the result in total; an image that would exceed it is\n * dropped with a note. Default 4 MiB.\n */\n readonly maxImageBytes?: number\n}\n\nexport const defaultCodeModeLimits = {\n timeoutMs: 120_000,\n memoryLimitBytes: 64 * 1024 * 1024,\n maxNestedCalls: 256,\n maxOutputChars: 40_000,\n maxImages: defaultCodeModeMaxImages,\n maxImageBytes: defaultCodeModeMaxImageBytes\n} as const satisfies Required<CodeModeLimits>\n\n/** Kept between a host deadline and the script deadline, for result handling. */\nexport const codeModeDeadlineMarginMs = 5_000\n\n/** A deadline never clamps a script below this timeout. */\nexport const codeModeMinimumTimeoutMs = 1_000\n\nexport type MakeCodeModeToolOptions<Context> = {\n readonly executor: CodeModeExecutor\n /** Default `codemode`. */\n readonly name?: string\n /** Estimated tokens (four characters each) for listing nested tools. Default 3000. */\n readonly inlineBudget?: number\n readonly limits?: CodeModeLimits\n /**\n * Epoch milliseconds by which the tool call must end, for example the remaining function budget\n * of a Workflow step. The script timeout is clamped to it minus 5 s, but never below 1 s.\n */\n readonly deadline?: (context: Context) => number | undefined\n /**\n * The store scripts read with `load(key)`, usually rebuilt from the transcript with\n * `codeModeStoreFromToolResults`. Without it every script starts with an empty store.\n */\n readonly loadStore?: (context: Context) => Effect.Effect<CodeModeStore, ToolError>\n /**\n * Runs before each nested call executes, with the nested call (id `<toolCallId>/<seq>`) and the\n * host context. A failure rejects that call in the script with the message and records it as an\n * `error` without executing it. Host decorators around the `ToolExecutor` (outside\n * `ResolvedToolSet.execute`) never see nested calls: put per-call run-authority checks here or in\n * registration-level wrappers.\n */\n readonly beforeNestedCall?: (input: {\n readonly call: ToolCall\n readonly context: Context\n }) => Effect.Effect<void, string>\n}\n\nconst CodeModeParams = Schema.Struct({\n code: Schema.String.annotate({\n description:\n 'Raw JavaScript: the body of an async function. Top-level await and return work. Not a function declaration and not a module.'\n })\n})\n\ntype CodeModeParams = typeof CodeModeParams.Type\n\nclass CodeModeExecutorRejected extends Data.TaggedError('CodeModeExecutorRejected')<{\n readonly message: string\n}> {}\n\nconst errorMessage = (error: unknown) =>\n error instanceof Error ? error.message : Predicate.isString(error) ? error : 'unknown error'\n\nconst nestedText = (content: Content) =>\n Predicate.isString(content)\n ? content\n : content\n .map(contentPartText)\n .filter(text => text.length > 0)\n .join('\\n')\n\ntype NestedResolution =\n | { readonly ok: true; readonly value: unknown }\n | { readonly ok: false; readonly message: string }\n\n/** What a nested call resolves to: `structuredContent` for tools with an output schema (when\n * present), text content otherwise; error results reject with their text.\n */\nconst resolveNestedResult = (tool: CodeModeCatalogTool, result: ToolResult): NestedResolution => {\n const text = nestedText(result.content)\n\n if (result.isError === true) {\n return { ok: false, message: text.length > 0 ? text : `Tool ${tool.name} failed.` }\n }\n\n return tool.structured && result.structuredContent !== undefined\n ? { ok: true, value: result.structuredContent }\n : { ok: true, value: text }\n}\n\ntype CallOutcome = {\n status: NestedToolCallStatus\n durationMs: number\n error?: string\n usage?: AgentUsage\n}\n\ntype CallRecord = {\n readonly name: string\n readonly args: unknown\n readonly id: string\n outcome?: CallOutcome\n}\n\nconst recordedOutcome = (exit: Exit.Exit<ToolResult>, durationMs: number): CallOutcome => {\n if (Exit.isFailure(exit)) {\n return Cause.hasInterruptsOnly(exit.cause)\n ? { status: 'cancelled', durationMs }\n : { status: 'error', durationMs, error: 'The tool call failed unexpectedly.' }\n }\n\n const result = exit.value\n\n const outcome: CallOutcome =\n result.isError === true\n ? { status: 'error', durationMs, error: nestedText(result.content) }\n : { status: 'ok', durationMs }\n\n if (result.usage !== undefined) {\n outcome.usage = result.usage\n }\n\n return outcome\n}\n\n/** What `describeNamespace(name)` resolves to. */\ntype CodeModeNamespaceDescription = {\n readonly name: string\n description?: string\n readonly tools: ReadonlyArray<{ readonly name: string; readonly description: string }>\n}\n\nconst SearchOptions = Schema.Struct({\n limit: Schema.optionalKey(Schema.Finite),\n namespace: Schema.optionalKey(Schema.String)\n})\n\nconst decodeSearchOptions = Schema.decodeUnknownOption(SearchOptions)\n\n/** `searchTools`, `describeTool`, and `describeNamespace` over every nested tool. */\nconst discoveryGlobals = (\n catalog: ReadonlyArray<CodeModeCatalogTool>\n): ReadonlyArray<CodeModeExecutorGlobal> => [\n {\n name: 'searchTools',\n spread: true,\n execute: args => {\n const [query, options] = Array.isArray(args) ? args : []\n\n return Promise.resolve(\n searchCodeModeTools(\n catalog,\n Predicate.isString(query) ? query : '',\n Option.getOrElse(decodeSearchOptions(options ?? {}), () => ({}))\n )\n )\n }\n },\n {\n name: 'describeTool',\n execute: name => {\n const tool = Predicate.isString(name) ? findCodeModeTool(catalog, name) : undefined\n\n return Promise.resolve(tool === undefined ? undefined : describeCodeModeTool(tool))\n }\n },\n {\n name: 'describeNamespace',\n execute: name => {\n const tools = catalog.filter(tool => tool.namespace === name)\n\n if (!Predicate.isString(name) || tools.length === 0) return Promise.resolve(undefined)\n\n const description = tools.find(\n tool => tool.namespaceDescription !== undefined\n )?.namespaceDescription\n\n const namespace: CodeModeNamespaceDescription = {\n name,\n tools: tools.map(tool => ({ name: tool.identifier, description: tool.description }))\n }\n\n if (description !== undefined) namespace.description = description\n\n return Promise.resolve(namespace)\n }\n }\n]\n\nconst hasStoreWrites = (result: CodeModeExecutionResult) =>\n result.storeWrites !== undefined &&\n (Object.keys(result.storeWrites.set).length > 0 || result.storeWrites.delete.length > 0)\n\nconst timeoutFor = (limit: number, deadline: number | undefined, now: number) =>\n deadline === undefined\n ? limit\n : Math.max(codeModeMinimumTimeoutMs, Math.min(limit, deadline - now - codeModeDeadlineMarginMs))\n\n/**\n * Waits at most `codeModeDeadlineMarginMs` for an effect (interrupting nested fibers) to finish; it\n * keeps running detached when a nested call does not respond to interruption in time.\n */\nconst boundedWait = (effect: Effect.Effect<void>) =>\n Effect.forkDetach(effect).pipe(\n Effect.flatMap(fiber =>\n Fiber.await(fiber).pipe(\n Effect.timeoutOption(Duration.millis(codeModeDeadlineMarginMs)),\n Effect.interruptible\n )\n ),\n Effect.asVoid\n )\n\ntype RunInput<Context> = {\n readonly options: MakeCodeModeToolOptions<Context>\n readonly limits: Required<CodeModeLimits>\n readonly call: ToolCall\n readonly context: Context\n readonly code: string\n readonly nested: NestedToolExecutor\n}\n\nconst runScript = <Context>(input: RunInput<Context>): Effect.Effect<ToolResult, ToolError> =>\n Effect.gen(function* () {\n const { call, limits, nested, options } = input\n const store = options.loadStore === undefined ? {} : yield* options.loadStore(input.context)\n const startedAt = yield* Clock.currentTimeMillis\n const timeoutMs = timeoutFor(limits.timeoutMs, options.deadline?.(input.context), startedAt)\n const catalog = codeModeCatalog(nested.tools)\n\n return yield* Effect.acquireUseRelease(\n Scope.make(),\n scope =>\n Effect.gen(function* () {\n // Nested calls run as fibers of this tool call (same services, interrupted with it).\n const fibers = yield* FiberSet.make<ToolResult>().pipe(Scope.provide(scope))\n const runFork = yield* FiberSet.runtime(fibers)<never>()\n const records: Array<CallRecord> = []\n\n const callTool =\n (tool: CodeModeCatalogTool): CodeModeExecutorTool['execute'] =>\n (args, { signal }) => {\n if (signal.aborted) {\n return Promise.reject(new Error(`tools.${tool.identifier} was cancelled.`))\n }\n\n if (records.length >= limits.maxNestedCalls) {\n return Promise.reject(\n new Error(\n `Nested call limit reached: a script may make at most ${limits.maxNestedCalls} tool calls. Batch the work or return partial results.`\n )\n )\n }\n\n const params = args === undefined ? {} : args\n\n const record: CallRecord = {\n id: `${call.id}/${records.length + 1}`,\n name: tool.name,\n args: params\n }\n\n records.push(record)\n\n const nestedCall = ToolCall.make({ id: record.id, name: tool.name, params })\n\n const admitted =\n options.beforeNestedCall === undefined\n ? nested.execute(nestedCall)\n : options.beforeNestedCall({ call: nestedCall, context: input.context }).pipe(\n Effect.matchEffect({\n onFailure: message =>\n Effect.succeed(\n ToolResult.make({\n toolCallId: nestedCall.id,\n content: message,\n isError: true\n })\n ),\n onSuccess: () => nested.execute(nestedCall)\n })\n )\n\n const execution = Effect.gen(function* () {\n const started = yield* Clock.currentTimeMillis\n\n return yield* admitted.pipe(\n Effect.onExit(exit =>\n Effect.map(Clock.currentTimeMillis, finished => {\n record.outcome = recordedOutcome(exit, finished - started)\n })\n )\n )\n })\n\n return new Promise((resolve, reject) => {\n runFork(execution, { signal }).addObserver(exit => {\n if (Exit.isFailure(exit)) {\n reject(\n new Error(\n Cause.hasInterruptsOnly(exit.cause)\n ? `tools.${tool.identifier} was cancelled.`\n : `tools.${tool.identifier} failed unexpectedly.`\n )\n )\n\n return\n }\n\n const resolution = resolveNestedResult(tool, exit.value)\n\n if (resolution.ok) {\n resolve(resolution.value)\n } else {\n reject(new Error(resolution.message))\n }\n })\n })\n }\n\n const tools: ReadonlyArray<CodeModeExecutorTool> = catalog.map(tool => ({\n name: tool.name,\n description: tool.description,\n inputSchema: tool.inputSchema,\n outputSchema: tool.outputSchema,\n execute: callTool(tool)\n }))\n\n const result = yield* Effect.tryPromise({\n try: signal =>\n options.executor.execute(input.code, {\n tools,\n globals: discoveryGlobals(catalog),\n timeoutMs,\n memoryLimitBytes: limits.memoryLimitBytes,\n store,\n signal\n }),\n catch: error => new CodeModeExecutorRejected({ message: errorMessage(error) })\n }).pipe(\n Effect.catch(error =>\n Effect.succeed<CodeModeExecutionResult>({\n ok: false,\n error: {\n kind: 'sandbox',\n message: `The code mode executor failed: ${error.message}`\n },\n output: []\n })\n ),\n // Backstop for executors that miss their own deadline: interrupting aborts the signal.\n Effect.timeoutOption(Duration.millis(timeoutMs + codeModeDeadlineMarginMs)),\n Effect.map(\n Option.getOrElse((): CodeModeExecutionResult => ({\n ok: false,\n error: {\n kind: 'timeout',\n message: `Execution timed out after ${timeoutMs} ms (the executor did not stop in time)`\n },\n output: []\n }))\n )\n )\n\n // Calls still running when the script ended are cancelled and recorded as such.\n yield* boundedWait(FiberSet.clear(fibers))\n\n const finishedAt = yield* Clock.currentTimeMillis\n\n const recorded = records.map((record): NestedToolCallInput => ({\n id: record.id,\n name: record.name,\n args: record.args,\n // A call interrupted before it started has no outcome.\n ...(record.outcome ?? { status: 'cancelled' })\n }))\n\n const recorder = recorded.reduce(recordNestedToolCall, emptyNestedToolCallRecorder)\n const { nestedCalls, usage } = nestedToolCallResultFields(recorder)\n\n const content = codeModeSegmentsContent(\n codeModeResultSegments({\n result,\n wallTimeMs: finishedAt - startedAt,\n calls: recorded,\n maxChars: limits.maxOutputChars,\n maxImages: limits.maxImages,\n maxImageBytes: limits.maxImageBytes\n })\n )\n\n const structuredContent: CodeModeStructuredContent = {\n codemode:\n result.ok && result.storeWrites !== undefined && hasStoreWrites(result)\n ? { ok: true, storeWrites: result.storeWrites }\n : { ok: result.ok }\n }\n\n type ResultFields = {\n toolCallId: string\n content: Content\n isError?: boolean\n structuredContent: CodeModeStructuredContent\n nestedCalls: typeof nestedCalls\n usage?: AgentUsage\n }\n\n const fields: ResultFields = {\n toolCallId: call.id,\n content,\n structuredContent,\n nestedCalls\n }\n\n if (!result.ok) {\n fields.isError = true\n }\n\n if (usage !== undefined) {\n fields.usage = usage\n }\n\n return ToolResult.make(fields)\n }),\n (scope, exit) => boundedWait(Scope.close(scope, exit))\n )\n })\n\n/**\n * The code mode tool: one registration (default name `codemode`, input `{ code }`) whose scripts\n * call the other code-mode-callable tools of the same `resolveTools` resolution. Nested calls run\n * through the resolved execute path with the same host context (input decoding, enablement,\n * registration wrappers), get ids `<toolCallId>/<seq>`, and are recorded on the result's\n * `nestedCalls`. The resolved description lists the callable tools (see\n * `renderCodeModeDescription`).\n *\n * Access is `write`: scripts can call any write tool the resolution exposes to code mode; each\n * nested call keeps its own access metadata and host wrappers.\n */\nexport const makeCodeModeTool = <Context>(\n options: MakeCodeModeToolOptions<Context>\n): ToolRegistration<Context> => {\n const name = options.name ?? codeModeToolName\n const limits: Required<CodeModeLimits> = { ...defaultCodeModeLimits, ...options.limits }\n const inlineBudget = options.inlineBudget ?? defaultCodeModeInlineBudget\n const store = options.loadStore !== undefined\n\n return makeTool<Context, typeof CodeModeParams>({\n name,\n description: renderCodeModeDescription({ tools: [], inlineBudget, store }),\n parameters: CodeModeParams,\n access: 'write',\n nestedToolAccess: true,\n describe: ({ tools }) => renderCodeModeDescription({ tools, inlineBudget, store }),\n execute: ({\n call,\n context,\n params,\n nested\n }: ToolExecutionInput<Context> & { readonly params: CodeModeParams }) =>\n nested === undefined\n ? Effect.fail(\n new ToolError({\n tool: name,\n cause: 'unavailable',\n message: `${name} requires nested tool access; resolve it with resolveTools.`\n })\n )\n : runScript({ options, limits, call, context, code: params.code, nested })\n })\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yolk-sdk/codemode",
|
|
3
|
+
"version": "0.1.0-canary.96",
|
|
4
|
+
"description": "Code mode for Yolk agents: one tool that runs model-written JavaScript calling the host's resolved tools, plus a Node executor on a confined QuickJS sandbox.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/magoz/yolk-sdk.git",
|
|
11
|
+
"directory": "packages/codemode"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/magoz/yolk-sdk/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/magoz/yolk-sdk#readme",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"codemode",
|
|
19
|
+
"agents",
|
|
20
|
+
"tools",
|
|
21
|
+
"sandbox",
|
|
22
|
+
"quickjs",
|
|
23
|
+
"effect"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.19.0"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
"./package.json": "./package.json",
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.mts",
|
|
32
|
+
"import": "./dist/index.mjs",
|
|
33
|
+
"default": "./dist/index.mjs"
|
|
34
|
+
},
|
|
35
|
+
"./node": {
|
|
36
|
+
"types": "./dist/node.d.mts",
|
|
37
|
+
"import": "./dist/node.mjs",
|
|
38
|
+
"default": "./dist/node.mjs"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"src/**/*.ts",
|
|
43
|
+
"!src/**/*.test.ts",
|
|
44
|
+
"!src/**/*.test.tsx",
|
|
45
|
+
"dist/**/*",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public",
|
|
50
|
+
"provenance": true
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@earendil-works/pi-codemode": "1.0.0",
|
|
54
|
+
"effect": "4.0.0-rc.115",
|
|
55
|
+
"@yolk-sdk/agent": "^0.1.0-canary.96"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsdown",
|
|
59
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
60
|
+
"test": "vitest run --passWithNoTests",
|
|
61
|
+
"test:run": "vitest run --passWithNoTests"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
/** The tool's `ToolModule.id`. */
|
|
24
|
+
readonly namespace: string
|
|
25
|
+
/** The module's `ToolModule.description`, when set. */
|
|
26
|
+
readonly namespaceDescription?: string
|
|
27
|
+
readonly description: string
|
|
28
|
+
readonly inputSchema: CodeModeJsonSchema
|
|
29
|
+
/** The declared output schema, or `{ type: 'string' }` for text results. */
|
|
30
|
+
readonly outputSchema: CodeModeJsonSchema
|
|
31
|
+
/** True when the tool declares an output schema (calls resolve to `structuredContent`). */
|
|
32
|
+
readonly structured: boolean
|
|
33
|
+
readonly exposure: CodeModeToolExposure
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const textOutputSchema: CodeModeJsonSchema = { type: 'string' }
|
|
37
|
+
|
|
38
|
+
/** Catalog of nested tools in resolution order. */
|
|
39
|
+
export const codeModeCatalog = (
|
|
40
|
+
tools: ReadonlyArray<NestedTool>
|
|
41
|
+
): ReadonlyArray<CodeModeCatalogTool> =>
|
|
42
|
+
tools.map(({ def, moduleId, moduleDescription }) => {
|
|
43
|
+
const tool: CodeModeCatalogTool = {
|
|
44
|
+
name: def.name,
|
|
45
|
+
identifier: toCodemodeIdentifier(def.name),
|
|
46
|
+
namespace: moduleId,
|
|
47
|
+
description: def.description,
|
|
48
|
+
inputSchema: def.parameters,
|
|
49
|
+
outputSchema: def.outputSchema ?? textOutputSchema,
|
|
50
|
+
structured: def.outputSchema !== undefined,
|
|
51
|
+
exposure: toolDiscovery(def) ?? 'all'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return moduleDescription === undefined
|
|
55
|
+
? tool
|
|
56
|
+
: { ...tool, namespaceDescription: moduleDescription }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** Default inline budget of the nested tool listing, in estimated tokens. */
|
|
60
|
+
export const defaultCodeModeInlineBudget = 3000
|
|
61
|
+
|
|
62
|
+
/** Estimated tokens of a text: four characters per token, rounded up. */
|
|
63
|
+
export const estimateCodeModeTokens = (text: string): number => Math.ceil(text.length / 4)
|
|
64
|
+
|
|
65
|
+
const maxShortTypeChars = 60
|
|
66
|
+
|
|
67
|
+
const shortType = (schema: CodeModeJsonSchema): string => {
|
|
68
|
+
const rendered = renderToolOutputType(schema)
|
|
69
|
+
|
|
70
|
+
if (rendered.length <= maxShortTypeChars) return rendered
|
|
71
|
+
|
|
72
|
+
if (!Predicate.isBoolean(schema)) {
|
|
73
|
+
if (schema.type === 'array') return 'Array<unknown>'
|
|
74
|
+
|
|
75
|
+
if (schema.type === 'object') return 'object'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return 'unknown'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Declarations only read names, descriptions, and schemas; `execute` is never called.
|
|
82
|
+
const declarationTool = (tool: CodeModeCatalogTool) => ({
|
|
83
|
+
name: tool.name,
|
|
84
|
+
description: tool.description,
|
|
85
|
+
inputSchema: tool.inputSchema,
|
|
86
|
+
outputSchema: tool.outputSchema,
|
|
87
|
+
execute: () => undefined
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const directToolLine = (tool: CodeModeCatalogTool) =>
|
|
91
|
+
`- \`tools.${tool.identifier}(args)\` takes the arguments of the \`${tool.name}\` tool and resolves to \`${shortType(tool.outputSchema)}\`.`
|
|
92
|
+
|
|
93
|
+
const declarationMembers = (tools: ReadonlyArray<CodeModeCatalogTool>) =>
|
|
94
|
+
renderDeclarations({ tools: tools.map(declarationTool) })
|
|
95
|
+
|
|
96
|
+
type Candidate = {
|
|
97
|
+
readonly tool: CodeModeCatalogTool
|
|
98
|
+
readonly index: number
|
|
99
|
+
readonly cost: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const candidateCost = (tool: CodeModeCatalogTool) =>
|
|
103
|
+
estimateCodeModeTokens(declarationMembers([tool]))
|
|
104
|
+
|
|
105
|
+
const groupByNamespace = <A extends { readonly tool: CodeModeCatalogTool }>(
|
|
106
|
+
items: ReadonlyArray<A>
|
|
107
|
+
): ReadonlyMap<string, ReadonlyArray<A>> => {
|
|
108
|
+
const groups = new Map<string, Array<A>>()
|
|
109
|
+
|
|
110
|
+
for (const item of items) {
|
|
111
|
+
const group = groups.get(item.tool.namespace)
|
|
112
|
+
|
|
113
|
+
if (group === undefined) {
|
|
114
|
+
groups.set(item.tool.namespace, [item])
|
|
115
|
+
} else {
|
|
116
|
+
group.push(item)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return groups
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type CodeModeListing = {
|
|
124
|
+
/** Tools placed in the description, in catalog order: every `all` tool, and the `listed` tools
|
|
125
|
+
* that fit the budget.
|
|
126
|
+
*/
|
|
127
|
+
readonly listed: ReadonlyArray<CodeModeCatalogTool>
|
|
128
|
+
/** Namespaces (catalog order) with callable tools the description does not list. */
|
|
129
|
+
readonly unlistedNamespaces: ReadonlyArray<string>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Chooses the tools the description lists. `all` tools are always placed, outside the budget
|
|
134
|
+
* (the model already has their declarations). `listed` tools fill `budget` estimated tokens fairly
|
|
135
|
+
* across namespaces: each round, every namespace still in play places its cheapest remaining tool;
|
|
136
|
+
* a namespace whose next tool does not fit drops out. `search` tools are never candidates.
|
|
137
|
+
*/
|
|
138
|
+
export const selectCodeModeListing = (
|
|
139
|
+
catalog: ReadonlyArray<CodeModeCatalogTool>,
|
|
140
|
+
budget: number
|
|
141
|
+
): CodeModeListing => {
|
|
142
|
+
const candidates = catalog.flatMap((tool, index): ReadonlyArray<Candidate> =>
|
|
143
|
+
tool.exposure === 'listed' ? [{ tool, index, cost: candidateCost(tool) }] : []
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const queues = new Map(
|
|
147
|
+
[...groupByNamespace(candidates)].map(([namespace, group]) => [
|
|
148
|
+
namespace,
|
|
149
|
+
[...group].sort((left, right) => left.cost - right.cost || left.index - right.index)
|
|
150
|
+
])
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const placed = new Set(catalog.flatMap((tool, index) => (tool.exposure === 'all' ? [index] : [])))
|
|
154
|
+
|
|
155
|
+
const inPlay = new Set(queues.keys())
|
|
156
|
+
let remaining = budget
|
|
157
|
+
|
|
158
|
+
while (inPlay.size > 0) {
|
|
159
|
+
for (const [namespace, queue] of queues) {
|
|
160
|
+
if (!inPlay.has(namespace)) continue
|
|
161
|
+
|
|
162
|
+
const next = queue.shift()
|
|
163
|
+
|
|
164
|
+
if (next === undefined || next.cost > remaining) {
|
|
165
|
+
inPlay.delete(namespace)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
placed.add(next.index)
|
|
170
|
+
remaining -= next.cost
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const unlisted = new Set(
|
|
175
|
+
catalog.flatMap((tool, index) => (placed.has(index) ? [] : [tool.namespace]))
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
listed: catalog.filter((_, index) => placed.has(index)),
|
|
180
|
+
unlistedNamespaces: [...new Set(catalog.map(tool => tool.namespace))].filter(namespace =>
|
|
181
|
+
unlisted.has(namespace)
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const intro = [
|
|
187
|
+
'Run a JavaScript script that calls tools and returns only what matters.',
|
|
188
|
+
'`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.',
|
|
189
|
+
'Call tools as `await tools.<id>(args)`. A call resolves to the declared result type and rejects with an Error when the tool fails. Calls still running when the script ends are cancelled, so await every call (use `Promise.all` for parallel calls).',
|
|
190
|
+
'Only the script output and its return value come back to you: filter and aggregate inside the script and return a small JSON value.'
|
|
191
|
+
].join('\n')
|
|
192
|
+
|
|
193
|
+
const globalLines = (store: boolean) => [
|
|
194
|
+
'- `text(value)` and `console.log(...values)`: append text to the output.',
|
|
195
|
+
'- `image(dataUrl)`: append a base64 image (a `data:` URL or `{ type: "image", data, mimeType }`).',
|
|
196
|
+
'- `exit()`: end the script successfully.',
|
|
197
|
+
'- `ALL_TOOLS`: `{ name, description }` of every callable tool.',
|
|
198
|
+
'- `await searchTools(query, { limit?, namespace? })`: find tools by topic; resolves to `Array<{ name: string; description: string }>` (default limit 8).',
|
|
199
|
+
'- `await describeTool(name)`: the description and TypeScript declaration of a tool, or `undefined`.',
|
|
200
|
+
'- `await describeNamespace(name)`: `{ name, description?, tools: Array<{ name, description }> }` for a namespace, or `undefined`.',
|
|
201
|
+
...(store
|
|
202
|
+
? [
|
|
203
|
+
'- `store(key, value)` and `load(key)`: keep small JSON values for later scripts; writes are saved only when the script succeeds.'
|
|
204
|
+
]
|
|
205
|
+
: [])
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
const namespaceSection = (namespace: string, tools: ReadonlyArray<CodeModeCatalogTool>) => {
|
|
209
|
+
const declared = tools.filter(tool => tool.exposure !== 'all')
|
|
210
|
+
const direct = tools.filter(tool => tool.exposure === 'all')
|
|
211
|
+
|
|
212
|
+
const description = tools.find(
|
|
213
|
+
tool => tool.namespaceDescription !== undefined
|
|
214
|
+
)?.namespaceDescription
|
|
215
|
+
|
|
216
|
+
return [
|
|
217
|
+
`### ${namespace}`,
|
|
218
|
+
...(description === undefined ? [] : [description]),
|
|
219
|
+
...(declared.length > 0 ? ['```ts', declarationMembers(declared), '```'] : []),
|
|
220
|
+
...direct.map(directToolLine)
|
|
221
|
+
].join('\n')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Fixed text: never derived from `search` tools or from which tools did not fit the budget.
|
|
225
|
+
const unlistedToolsLine =
|
|
226
|
+
'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.'
|
|
227
|
+
|
|
228
|
+
export type CodeModeDescriptionInput = {
|
|
229
|
+
readonly tools: ReadonlyArray<NestedTool>
|
|
230
|
+
readonly inlineBudget?: number
|
|
231
|
+
/** Mention `store()`/`load()` persistence. */
|
|
232
|
+
readonly store?: boolean
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The code mode tool description: intro, globals one per line, then nested tools grouped by
|
|
237
|
+
* namespace, then one fixed line pointing to `searchTools`/`describeTool`/`describeNamespace`.
|
|
238
|
+
* `all` tools get one line each outside the budget; `listed` tools are declared within the inline
|
|
239
|
+
* budget; `search` tools never contribute, so adding or removing them (even whole namespaces of
|
|
240
|
+
* them) leaves the text byte-identical.
|
|
241
|
+
*/
|
|
242
|
+
export const renderCodeModeDescription = (input: CodeModeDescriptionInput): string => {
|
|
243
|
+
const catalog = codeModeCatalog(input.tools)
|
|
244
|
+
|
|
245
|
+
const listing = selectCodeModeListing(catalog, input.inlineBudget ?? defaultCodeModeInlineBudget)
|
|
246
|
+
|
|
247
|
+
const sections = [...groupByNamespace(listing.listed.map(tool => ({ tool })))].map(
|
|
248
|
+
([namespace, items]) =>
|
|
249
|
+
namespaceSection(
|
|
250
|
+
namespace,
|
|
251
|
+
items.map(item => item.tool)
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return [
|
|
256
|
+
intro,
|
|
257
|
+
['Globals:', ...globalLines(input.store === true)].join('\n'),
|
|
258
|
+
sections.length === 0
|
|
259
|
+
? 'Nested tools: none are listed here.'
|
|
260
|
+
: ['## Nested tools by namespace', ...sections].join('\n\n'),
|
|
261
|
+
unlistedToolsLine
|
|
262
|
+
].join('\n\n')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `describeTool` text: the description and TypeScript declaration of one tool. */
|
|
266
|
+
export const describeCodeModeTool = (tool: CodeModeCatalogTool): string =>
|
|
267
|
+
renderToolSample(declarationTool(tool))
|
|
268
|
+
|
|
269
|
+
/** Finds a tool by identifier or raw name. */
|
|
270
|
+
export const findCodeModeTool = (
|
|
271
|
+
catalog: ReadonlyArray<CodeModeCatalogTool>,
|
|
272
|
+
name: string
|
|
273
|
+
): CodeModeCatalogTool | undefined =>
|
|
274
|
+
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
|
+
}
|