diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 54d31b4e312..0be6d769c7c 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -60,7 +60,7 @@ const result = `) ``` -`result` is always an `ExecuteResult`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. +`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well. @@ -83,6 +83,8 @@ const tool = Tool.make({ The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls. +Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`. + ### `CodeMode.execute` Use `CodeMode.execute` for a single execution: @@ -113,30 +115,32 @@ const runtime = CodeMode.make({ runtime.catalog() // structured tool descriptions runtime.instructions() // model-facing syntax and tool guide -runtime.execute(source) // ExecuteResult +runtime.execute(source) // CodeMode.Result ``` -`CodeMode.Input` and `CodeMode.Result` are Effect schemas for the execution request and result. Hosts can combine them with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool. +`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool. + +All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types. ### Results ```ts -type ExecuteResult = ExecuteSuccess | ExecuteFailure +type Result = Success | Failure -interface ExecuteSuccess { +interface Success { readonly ok: true - readonly value: Schema.Json + readonly value: CodeMode.DataValue readonly logs?: ReadonlyArray readonly truncated?: boolean - readonly toolCalls: ReadonlyArray + readonly toolCalls: ReadonlyArray } -interface ExecuteFailure { +interface Failure { readonly ok: false - readonly error: Diagnostic + readonly error: CodeMode.Diagnostic readonly logs?: ReadonlyArray readonly truncated?: boolean - readonly toolCalls: ReadonlyArray + readonly toolCalls: ReadonlyArray } ``` @@ -300,7 +304,7 @@ import { toolError } from "@opencode-ai/codemode" run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable"))) ``` -Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary. +Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary. ## Authority Boundary @@ -332,7 +336,7 @@ The public contract is guided by these equivalences: - A tool implementation is not invoked unless its input has decoded successfully. - A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully. - Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel. -- Host interruption remains interruption rather than an `ExecuteFailure`. +- Host interruption remains interruption rather than a `CodeMode.Failure`. ## Non-Goals diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 81348a7c8f5..a94f1d4eec6 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -220,9 +220,9 @@ wave; both packages typecheck clean. (render-only - no validation, values pass through; rendering handles `$defs`/`definitions` - `$ref`). `output` is **optional** -> signature renders `Promise` and the host result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from - `tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/ + `tool-schema.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/ `jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there - anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty + anymore). Types `Tool.JsonSchema`/`Tool.SchemaType` exported from the index. Note: an empty `Schema.Struct({})` renders as `{ } | Array` (effect's JSON Schema emission) - cosmetic, fixed in Wave 4. - **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output` @@ -237,7 +237,7 @@ wave; both packages typecheck clean. so failures are typed and observable). `message` is the model-safe failure message (`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls fire no end event (timeout kills the whole execution anyway). -- **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?, +- **Limits collapse**: public `CodeMode.ExecutionLimits` = `{ timeoutMs?, maxToolCalls?, maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5 later deleted that type and the internal limit system entirely. @@ -313,7 +313,7 @@ real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still packages typecheck clean. - **Budgeted catalog** (`discoveryPlan` in `tool-runtime.ts`): the all-or-nothing - inline/search modes are gone - `DiscoveryMode` deleted, `DiscoveryOptions` is just + inline/search modes are gone - `DiscoveryMode` deleted, `CodeMode.DiscoveryOptions` is just `{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes; later converted to `maxInlineCatalogTokens`, default 4,000 estimated tokens - see Post-wave fixes). Port of the old opencode @@ -340,7 +340,7 @@ packages typecheck clean. read-the-description-before-calling guidance. (The flat prose layout this wave produced was later replaced wholesale by the markdown-section restructure - see Post-wave fixes - which also deleted this wave's worked example.) -- **Cosmetic renderer fixes** (`renderSchema` in `tool.ts`): an object schema with no +- **Cosmetic renderer fixes** (`renderSchema` in `tool-schema.ts`): an object schema with no properties renders `{}` (was `{ }`), and the empty `Schema.Struct({})` emission (`anyOf: [{ type: "object" }, { type: "array" }]`, no properties/items) collapses to `{}` (was `{ } | Array`). @@ -469,7 +469,7 @@ adapter needed **no changes**. `rankTools` algorithm in `packages/opencode/src/session/code-mode.ts` at git HEAD), replacing the word-set ranker in `tool-runtime.ts`. Searchable text per tool = path + description + input-schema property names + their `description` strings - extracted by - the new `inputProperties` helper in `tool.ts` (Effect Schemas via + the new `inputProperties` helper in `tool-schema.ts` (Effect Schemas via `Schema.toJsonSchemaDocument`, the same emission signature rendering uses; JSON Schemas read `properties` directly, resolving a trivial top-level `$ref`; try/catch falls back to path + description). Queries tokenize on camelCase boundaries + non-alphanumeric @@ -542,7 +542,7 @@ budget; namespaces must always be present): - `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so the package stays dependency-free; keep in sync if the core heuristic changes. -- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000 +- `CodeMode.DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000 estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first - stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in @@ -558,7 +558,7 @@ budget; namespaces must always be present): **Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as configurable knobs; the internal limit system dies): -- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at +- `CodeMode.ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at the time; Fix 6 later removed the first two defaults. Same validation: safe integers, timeoutMs >= 1, others >= 0, RangeError otherwise) is now the ENTIRE limit surface - exactly the shape section 2's original locked spec named. @@ -602,7 +602,7 @@ configurable knobs; the internal limit system dies): enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/ maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit test - superseded by the package timeout regression test); rewrote the helpers that used - `InternalExecutionLimits` as a convenience to plain `ExecutionLimits` + `InternalExecutionLimits` as a convenience to plain `CodeMode.ExecutionLimits` (promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter suites: 34 + 16. @@ -633,7 +633,7 @@ Semantics: each described input/output field carries its schema `description` as express surface as JSDoc tags - `@deprecated`, `@default ` (unserializable defaults skipped), `@format`, `@minItems`/`@maxItems`; `*/` inside text is neutralized to `* /`; multiline descriptions become `*`-prefixed blocks with blank edges trimmed; undescribed, -untagged fields get no comment. Implementation: `renderSchema` in `tool.ts` grew a +untagged fields get no comment. Implementation: `renderSchema` in `tool-schema.ts` grew a `RenderContext` (`{ definitions, pretty }`), a `MAX_RENDER_DEPTH = 8` recursion ceiling plus a `$ref` `seen` guard (the renderer previously had neither - a cyclic `$defs` would have looped; it now degrades to the ref name/`unknown`), and try/catch totality on the public @@ -849,7 +849,7 @@ section 4 outer-truncation item the OPPOSITE way from "kill the outer one"): that relied on the old default now asserts the oversized result reaches the shared wrapper un-truncated. Suites: 210 + 50, tsgo clean both. -**Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default +**Docs polish** (post-API-review): stale `CodeMode.DiscoveryOptions` JSDoc fixed (claimed default 4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality) and the README's incorrect "`effect` as a peer dependency" line corrected (`effect` is a regular dependency; hosts depend on it themselves because the API surface is Effect-typed). @@ -949,16 +949,16 @@ child calls" gap): **Signature rendering + compound-assignment parity fixes** (externally reported, both verified real with failing tests before fixing): -- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema` +- **Non-identifier property names in rendered signatures** (`src/tool-schema.ts`): `renderSchema` emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123` rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper - bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the single `field` closure both the compact and pretty renderings share. The - `identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s + `identifierSegment` regex now lives in `tool-schema.ts` (internal) and `tool-runtime.ts`'s bracket-notation `toolExpression` imports it: one source of truth for "is this a bare identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact, pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct). -- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old +- **Numeric schema unions keep their real alternatives** (`src/tool-schema.ts`): the old `anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just `number`, dropping real JSON Schema alternatives (`string | number`, `number | null`, etc.). The collapse is now restricted to Effect's number-schema artifact @@ -1209,7 +1209,8 @@ Post-MVP (logged, not blocking an experimental flag): the workspace is the implementation source of truth for v4 behavior questions. - File map (this package): `src/codemode.ts` - types/limits/parser/Interpreter/execute/make; `src/tool-runtime.ts` - tool tree, `copyIn`/`copyOut`, search/discovery, invoke path; - `src/tool.ts` - `Tool.make` + JSON-Schema->TS rendering; `src/values.ts` - sandbox value + `src/tool.ts` - public `Tool` definitions; `src/tool-schema.ts` - schema rendering and decoding; + `src/values.ts` - sandbox value types; `src/tool-error.ts` - `ToolError`; tests in `test/{codemode,parity,stdlib}.test.ts`. - OpenCode file map (integration points): `src/tool/code-mode.ts` (the adapter, now a registry tool service - `CodeModeTool` + `catalogInstructions`; formerly diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 6aa030e5210..b207efe46a8 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -19,8 +19,7 @@ import { ToolError } from "./tool-error.js" import { isSandboxValue, SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js" /** A tool call admitted during an execution. */ -export type { ToolCall, ToolCallStarted, ToolDescription } from "./tool-runtime.js" -export { ToolError, toolError } from "./tool-error.js" +export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js" /** Resource budgets enforced independently during each CodeMode program execution. */ export type ExecutionLimits = { @@ -74,50 +73,20 @@ export type ExecuteOptions = {}> = { onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect> } -/** A normalized program diagnostic safe to return across an agent tool boundary. */ -export type Diagnostic = { - readonly kind: DiagnosticKind - readonly message: string - readonly location?: { readonly line: number; readonly column: number } - readonly suggestions?: ReadonlyArray -} - /** A JSON value that can cross the confined interpreter boundary. */ export type DataValue = Schema.Json -/** Successful execution after the result has crossed the plain-data boundary. */ -export type ExecuteSuccess = { - readonly ok: true - readonly value: DataValue - readonly logs?: ReadonlyArray - /** Present when the value or logs were truncated to fit `maxOutputBytes`. */ - readonly truncated?: boolean - readonly toolCalls: ReadonlyArray -} - -/** Failed execution with calls admitted before the diagnostic was produced. */ -export type ExecuteFailure = { - readonly ok: false - readonly error: Diagnostic - readonly logs?: ReadonlyArray - /** Present when the logs were truncated to fit `maxOutputBytes`. */ - readonly truncated?: boolean - readonly toolCalls: ReadonlyArray -} - -/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */ -export type ExecuteResult = ExecuteSuccess | ExecuteFailure - /** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */ -export type CodeModeOptions = {}> = Omit, "code"> & { +export type Options = {}> = Omit, "code"> & { /** Progressive-disclosure configuration for the agent-facing tool catalog. */ readonly discovery?: DiscoveryOptions } -/** Schema for a CodeMode execution request. */ -const Input = Schema.Struct({ code: Schema.String }) +/** Schema for a host tool input containing CodeMode source. */ +export const Input = Schema.Struct({ code: Schema.String }) +export type Input = typeof Input.Type -const DiagnosticKindSchema = Schema.Literals([ +export const DiagnosticKind = Schema.Literals([ "ParseError", "UnsupportedSyntax", "UnknownTool", @@ -129,38 +98,52 @@ const DiagnosticKindSchema = Schema.Literals([ "ToolFailure", "ExecutionFailure", ]) +/** Stable categories produced by program, schema, tool, and limit failures. */ +export type DiagnosticKind = typeof DiagnosticKind.Type + +export const Diagnostic = Schema.Struct({ + kind: DiagnosticKind, + message: Schema.String, + location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })), + suggestions: Schema.optionalKey(Schema.Array(Schema.String)), +}) +/** A normalized program diagnostic safe to return across an agent tool boundary. */ +export type Diagnostic = typeof Diagnostic.Type + +const ToolCallSchema = Schema.Struct({ name: Schema.String }) +export const Success = Schema.Struct({ + ok: Schema.Literal(true), + value: Schema.Json, + logs: Schema.optionalKey(Schema.Array(Schema.String)), + truncated: Schema.optionalKey(Schema.Boolean), + toolCalls: Schema.Array(ToolCallSchema), +}) +/** Successful execution after the result has crossed the plain-data boundary. */ +export type Success = typeof Success.Type + +export const Failure = Schema.Struct({ + ok: Schema.Literal(false), + error: Diagnostic, + logs: Schema.optionalKey(Schema.Array(Schema.String)), + truncated: Schema.optionalKey(Schema.Boolean), + toolCalls: Schema.Array(ToolCallSchema), +}) +/** Failed execution with calls admitted before the diagnostic was produced. */ +export type Failure = typeof Failure.Type /** Schema for the structured success or diagnostic returned by CodeMode execution. */ -const Result = Schema.Union([ - Schema.Struct({ - ok: Schema.Literal(true), - value: Schema.Json, - logs: Schema.optionalKey(Schema.Array(Schema.String)), - truncated: Schema.optionalKey(Schema.Boolean), - toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), - }), - Schema.Struct({ - ok: Schema.Literal(false), - error: Schema.Struct({ - kind: DiagnosticKindSchema, - message: Schema.String, - location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })), - suggestions: Schema.optionalKey(Schema.Array(Schema.String)), - }), - logs: Schema.optionalKey(Schema.Array(Schema.String)), - truncated: Schema.optionalKey(Schema.Boolean), - toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), - }), -]) +export const Result = Schema.Union([Success, Failure]) +/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */ +export type Result = typeof Result.Type /** Reusable confined runtime over one explicit tool tree. */ -export type CodeModeRuntime = { +export type Runtime = { /** Lists schema-described tool paths provided by the host. */ readonly catalog: () => ReadonlyArray /** Builds model-facing syntax guidance and visible tool signatures. */ readonly instructions: () => string /** Executes a program using this runtime's configured host tools. */ - readonly execute: (code: string) => Effect.Effect + readonly execute: (code: string) => Effect.Effect } type SourcePosition = { @@ -275,19 +258,6 @@ const errorBrandName = (value: unknown): string | undefined => ? ((value as Record)[ErrorBrand] as string | undefined) : undefined -/** Stable categories produced by program, schema, tool, and limit failures. */ -export type DiagnosticKind = - | "ParseError" - | "UnsupportedSyntax" - | "UnknownTool" - | "InvalidToolInput" - | "InvalidToolOutput" - | "InvalidDataValue" - | "ToolCallLimitExceeded" - | "TimeoutExceeded" - | "ToolFailure" - | "ExecutionFailure" - const arrayMethods = new Set([ "map", "filter", @@ -3943,7 +3913,7 @@ const executeWithLimits = >( options: ExecuteOptions, limits: ResolvedExecutionLimits, searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], -): Effect.Effect> => { +): Effect.Effect> => { const hooks = { ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), @@ -3975,7 +3945,7 @@ const executeWithLimits = >( value: result, ...logged(), toolCalls: tools.calls, - } satisfies ExecuteResult + } satisfies Result }).pipe((program) => { const timeoutMs = limits.timeoutMs if (timeoutMs === undefined) return program @@ -3988,7 +3958,7 @@ const executeWithLimits = >( error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, ...logged(), toolCalls: tools.calls, - } satisfies ExecuteResult), + } satisfies Result), }), ) }) @@ -4002,7 +3972,7 @@ const executeWithLimits = >( error: normalizeError(Cause.squash(cause)), ...logged(), toolCalls: tools.calls, - } satisfies ExecuteResult), + } satisfies Result), ), Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))), ) @@ -4026,7 +3996,7 @@ const utf8Truncate = (value: string, maxBytes: number): string => { * fails the execution; `truncated: true` marks affected results. Only runs when the host set * `maxOutputBytes` - with the limit absent, output passes through unbounded. */ -const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResult => { +const boundOutput = (result: Result, maxOutputBytes: number): Result => { let truncated = false let value: DataValue = null @@ -4068,7 +4038,7 @@ const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResu export const execute = >( options: ExecuteOptions, -): Effect.Effect> => { +): Effect.Effect> => { const tools = (options.tools ?? {}) as HostTools> ToolRuntime.assertValidTools(tools) return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) @@ -4086,8 +4056,8 @@ export const execute = >( * ``` */ export const make = = {}>( - options: CodeModeOptions = {} as CodeModeOptions, -): CodeModeRuntime> => { + options: Options = {} as Options, +): Runtime> => { const tools = (options.tools ?? {}) as HostTools> ToolRuntime.assertValidTools(tools) const limits = resolveExecutionLimits(options.limits) @@ -4102,6 +4072,3 @@ export const make = = {}>( execute: executeProgram, } } - -/** Constructors for one-shot and reusable CodeMode execution. */ -export const CodeMode = { Input, Result, make, execute } diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index 9a21e9f640a..92019825004 100644 --- a/packages/codemode/src/index.ts +++ b/packages/codemode/src/index.ts @@ -1,21 +1,4 @@ -export { ToolError, CodeMode, toolError } from "./codemode.js" -export { Tool } from "./tool.js" +export * as CodeMode from "./codemode.js" +export * as Tool from "./tool.js" export * as OpenAPI from "./openapi/index.js" -export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js" -export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js" -export type { - CodeModeOptions, - CodeModeRuntime, - DataValue, - Diagnostic, - DiagnosticKind, - DiscoveryOptions, - ExecuteFailure, - ExecuteOptions, - ExecuteResult, - ExecuteSuccess, - ExecutionLimits, - ToolCall, - ToolCallStarted, - ToolDescription, -} from "./codemode.js" +export { ToolError, toolError } from "./tool-error.js" diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts index 4ceda5eef00..7f1770ef362 100644 --- a/packages/codemode/src/openapi/index.ts +++ b/packages/codemode/src/openapi/index.ts @@ -1,5 +1,5 @@ import { HttpClient } from "effect/unstable/http" -import { Tool, type Definition } from "../tool.js" +import { make, type Definition } from "../tool.js" import { invoke } from "./runtime.js" import { componentDefinitions, @@ -102,7 +102,7 @@ export const fromSpec = (options: Options): Result => { setTool( tools, segments, - Tool.make({ + make({ description: operation.description ?? operation.summary ?? `${operation.method} ${path}`, input: inputSchema(input.fields, definitions), output: output.value, diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 339737375cc..268953b56ac 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -6,10 +6,9 @@ import { identifierSegment, inputProperties, inputTypeScript, - isDefinition as isToolDefinition, outputTypeScript, - type Definition, -} from "./tool.js" +} from "./tool-schema.js" +import { isDefinition as isToolDefinition, type Definition } from "./tool.js" import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js" const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4)) diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts new file mode 100644 index 00000000000..65c444083f0 --- /dev/null +++ b/packages/codemode/src/tool-schema.ts @@ -0,0 +1,301 @@ +import { JsonPointer, Schema } from "effect" +import type { Definition, JsonSchema, SchemaType } from "./tool.js" + +const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder & Schema.Top => Schema.isSchema(schema) + +const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" + +/** + * Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime, + * with dot access as a tool-path segment). Anything else must be quoted/bracketed. + */ +export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ +const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name)) + +const effectNumberSentinel = (schema: JsonSchema) => + schema.type === "string" && + Array.isArray(schema.enum) && + schema.enum.length === 1 && + (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity") + +const intersection = (members: ReadonlyArray): string => { + const concrete = members.filter((member) => member !== "unknown") + if (concrete.length === 0) return "unknown" + if (concrete.length === 1) return concrete[0] ?? "unknown" + return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ") +} + +/** + * Recursion ceiling for schema rendering. Object, array, and union recursion all increment + * depth, so this bounds every recursion path - pathological or structurally cyclic schemas + * degrade to `unknown` instead of overflowing the stack (rendering must never throw). + */ +const MAX_RENDER_DEPTH = 8 + +type RenderContext = { + readonly definitions: Readonly> + /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */ + readonly pretty: boolean +} + +const hasUnresolvedRef = ( + schema: JsonSchema, + definitions: Readonly>, + seen: ReadonlySet = new Set(), + visited: ReadonlySet = new Set(), +): boolean => { + if (visited.has(schema)) return false + const nextVisited = new Set([...visited, schema]) + if (schema.$ref !== undefined) { + const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] + const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) + if (name === undefined || definitions[name] === undefined || seen.has(name)) return true + if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true + } + return [ + ...(schema.anyOf ?? []), + ...(schema.oneOf ?? []), + ...(schema.allOf ?? []), + ...Object.values(schema.properties ?? {}), + ...(schema.items === undefined ? [] : [schema.items]), + ...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []), + ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited)) +} + +/** + * Schema constraints a TypeScript type cannot express natively but a model benefits from, + * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). + */ +const docTags = (schema: JsonSchema): Array => { + const tags: Array = [] + if (schema.deprecated === true) tags.push("@deprecated") + if (schema.default !== undefined) { + try { + const rendered = JSON.stringify(schema.default) + if (rendered !== undefined) tags.push(`@default ${rendered}`) + } catch { + // unserializable default: skip rather than emit a broken tag + } + } + if (typeof schema.format === "string") tags.push(`@format ${schema.format}`) + if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`) + if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`) + return tags +} + +/** + * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, + * preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a + * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and + * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so + * callers can prepend it directly to the field line. + */ +const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad: string): string => { + const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => + line.replaceAll("*/", "* /").replace(/\s+$/, ""), + ) + while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() + while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() + if (lines.length === 0) return "" + if (lines.length === 1) return `${pad}/** ${lines[0]} */\n` + const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n") + return `${pad}/**\n${body}\n${pad} */\n` +} + +const renderSchema = ( + schema: JsonSchema, + ctx: RenderContext, + depth = 0, + seen: ReadonlySet = new Set(), +): string => { + if (depth > MAX_RENDER_DEPTH) return "unknown" + const nested = + schema.definitions === undefined && schema.$defs === undefined + ? ctx + : { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } } + if (schema.$ref) { + const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] + const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) + if (!name || !nested.definitions[name] || seen.has(name)) return "unknown" + return intersection([ + renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])), + renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen), + ]) + } + if (schema.const !== undefined) return renderLiteral(schema.const) + if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") + const alternatives = schema.anyOf ?? schema.oneOf + if (alternatives) { + // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" }, + // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact; + // real JSON Schema unions such as `string | number` or `number | null` must keep + // every branch. + if ( + alternatives.some((item) => item.type === "number") && + alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) + ) + return "number" + // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` + // (no properties/items); render the bare shape as {} instead of `{} | Array`. + if ( + alternatives.length === 2 && + alternatives[0]?.type === "object" && + alternatives[0].properties === undefined && + alternatives[1]?.type === "array" && + alternatives[1].items === undefined + ) { + return "{}" + } + const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen)) + if (members.some((member) => member === "unknown")) return "unknown" + return intersection([ + members.join(" | "), + renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen), + ]) + } + if (schema.allOf) { + const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen)) + if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown" + return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members]) + } + if (Array.isArray(schema.type)) { + return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ") + } + if (schema.type === "string") return "string" + if (schema.type === "number" || schema.type === "integer") return "number" + if (schema.type === "boolean") return "boolean" + if (schema.type === "null") return "null" + if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>` + if (schema.type === "object" || schema.properties) { + const required = new Set(schema.required ?? []) + const properties = Object.entries(schema.properties ?? {}) + const additional = schema.additionalProperties + const indexType = + additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined + const field = ([name, value]: readonly [string, JsonSchema]) => + `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}` + + if (!ctx.pretty) { + const fields = properties.map(field) + if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`) + return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` + } + + // Pretty: an indented block, each described field preceded by its JSDoc comment. + if (properties.length === 0 && indexType === undefined) return "{}" + const pad = " ".repeat(depth + 1) + const lines = properties.map( + (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`, + ) + if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`) + return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` + } + return "unknown" +} + +export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => { + try { + const visible = decoded ? Schema.toType(schema) : schema + const document = Schema.toJsonSchemaDocument(visible) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + } + return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty }) + } catch { + return "unknown" + } +} + +/** Renders a raw JSON Schema document as a TypeScript type string. */ +export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => { + try { + return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty }) + } catch { + return "unknown" + } +} + +/** One input property of a tool, extracted best-effort from its input schema. */ +export type InputProperty = { + readonly name: string + readonly description: string | undefined + readonly required: boolean +} + +/** + * The property names, descriptions, and required flags of a tool's input schema - the raw + * material for search text. Best-effort: Effect Schemas go through their + * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read + * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present. + * Anything unresolvable yields `[]` (search falls back to path + description). + */ +export const inputProperties = (definition: Definition): Array => { + try { + const document = isEffectSchema(definition.input) + ? (Schema.toJsonSchemaDocument(definition.input) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + }) + : { + schema: definition.input, + definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) }, + } + const definitions = document.definitions ?? {} + let schema = document.schema + if (schema.$ref !== undefined) { + const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] + const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) + const resolved = name === undefined ? undefined : definitions[name] + if (resolved === undefined) return [] + schema = resolved + } + const required = new Set(schema.required ?? []) + return Object.entries(schema.properties ?? {}).map(([name, value]) => ({ + name, + description: typeof value.description === "string" ? value.description : undefined, + required: required.has(name), + })) + } catch { + return [] + } +} + +/** + * The model-visible TypeScript type of a tool's input. `pretty` renders an indented + * multiline block with schema descriptions and constraints as JSDoc comments on the + * fields; the default stays the compact single-line form. + */ +export const inputTypeScript = (definition: Definition, pretty = false): string => + isEffectSchema(definition.input) + ? toTypeScript(definition.input, false, pretty) + : jsonSchemaToTypeScript(definition.input, pretty) + +/** + * The model-visible TypeScript type of a tool's result; tools without an output schema + * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs. + */ +export const outputTypeScript = (definition: Definition, pretty = false): string => + definition.output === undefined + ? "unknown" + : isEffectSchema(definition.output) + ? toTypeScript(definition.output, true, pretty) + : jsonSchemaToTypeScript(definition.output, pretty) + +/** + * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); + * JSON-Schema-described inputs pass through unvalidated (render-only). + */ +export const decodeInput = (definition: Definition, value: unknown): unknown => + isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value + +/** + * Decodes a tool result before it is exposed to the program. Effect Schemas validate and + * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass + * the host value through unchanged. + */ +export const decodeOutput = (definition: Definition, value: unknown): unknown => + definition.output !== undefined && isEffectSchema(definition.output) + ? Schema.decodeUnknownSync(definition.output)(value) + : value diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index e89f3d39d6c..6c6863f99dc 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -1,4 +1,4 @@ -import { Effect, JsonPointer, Schema } from "effect" +import { Effect, Schema } from "effect" /** * JSON Schema subset accepted for render-only tool schemas. @@ -30,25 +30,25 @@ export type JsonSchema = { } /** Either a validating Effect Schema or a render-only JSON Schema document. */ -export type ToolSchema = Schema.Decoder | JsonSchema +export type SchemaType = Schema.Decoder | JsonSchema /** Schema-backed tool definition consumed by a CodeMode tool tree. */ export type Definition = { readonly _tag: "CodeModeTool" readonly description: string - readonly input: ToolSchema - readonly output: ToolSchema | undefined + readonly input: SchemaType + readonly output: SchemaType | undefined readonly run: (input: unknown) => Effect.Effect } /** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */ -export type InputType = S extends Schema.Decoder ? S["Type"] : unknown +type InputType = S extends Schema.Decoder ? S["Type"] : unknown /** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */ -export type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown +type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown /** Options for defining one CodeMode tool. */ -export type Options = { +export type Options = { readonly description: string readonly input: I readonly output?: O @@ -58,305 +58,6 @@ export type Options(value: unknown): value is Definition => typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool" -const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder & Schema.Top => Schema.isSchema(schema) - -const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" - -/** - * Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime, - * with dot access as a tool-path segment). Anything else must be quoted/bracketed. - */ -export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ - -/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ -const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name)) - -const effectNumberSentinel = (schema: JsonSchema) => - schema.type === "string" && - Array.isArray(schema.enum) && - schema.enum.length === 1 && - (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity") - -const intersection = (members: ReadonlyArray): string => { - const concrete = members.filter((member) => member !== "unknown") - if (concrete.length === 0) return "unknown" - if (concrete.length === 1) return concrete[0] ?? "unknown" - return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ") -} - -/** - * Recursion ceiling for schema rendering. Object, array, and union recursion all increment - * depth, so this bounds every recursion path - pathological or structurally cyclic schemas - * degrade to `unknown` instead of overflowing the stack (rendering must never throw). - */ -const MAX_RENDER_DEPTH = 8 - -type RenderContext = { - readonly definitions: Readonly> - /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */ - readonly pretty: boolean -} - -const hasUnresolvedRef = ( - schema: JsonSchema, - definitions: Readonly>, - seen: ReadonlySet = new Set(), - visited: ReadonlySet = new Set(), -): boolean => { - if (visited.has(schema)) return false - const nextVisited = new Set([...visited, schema]) - if (schema.$ref !== undefined) { - const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] - const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) - if (name === undefined || definitions[name] === undefined || seen.has(name)) return true - if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true - } - return [ - ...(schema.anyOf ?? []), - ...(schema.oneOf ?? []), - ...(schema.allOf ?? []), - ...Object.values(schema.properties ?? {}), - ...(schema.items === undefined ? [] : [schema.items]), - ...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []), - ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited)) -} - -/** - * Schema constraints a TypeScript type cannot express natively but a model benefits from, - * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). - */ -const docTags = (schema: JsonSchema): Array => { - const tags: Array = [] - if (schema.deprecated === true) tags.push("@deprecated") - if (schema.default !== undefined) { - try { - const rendered = JSON.stringify(schema.default) - if (rendered !== undefined) tags.push(`@default ${rendered}`) - } catch { - // unserializable default: skip rather than emit a broken tag - } - } - if (typeof schema.format === "string") tags.push(`@format ${schema.format}`) - if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`) - if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`) - return tags -} - -/** - * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, - * preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a - * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and - * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so - * callers can prepend it directly to the field line. - */ -const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad: string): string => { - const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => - line.replaceAll("*/", "* /").replace(/\s+$/, ""), - ) - while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() - while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() - if (lines.length === 0) return "" - if (lines.length === 1) return `${pad}/** ${lines[0]} */\n` - const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n") - return `${pad}/**\n${body}\n${pad} */\n` -} - -const renderSchema = ( - schema: JsonSchema, - ctx: RenderContext, - depth = 0, - seen: ReadonlySet = new Set(), -): string => { - if (depth > MAX_RENDER_DEPTH) return "unknown" - const nested = - schema.definitions === undefined && schema.$defs === undefined - ? ctx - : { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } } - if (schema.$ref) { - const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] - const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) - if (!name || !nested.definitions[name] || seen.has(name)) return "unknown" - return intersection([ - renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])), - renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen), - ]) - } - if (schema.const !== undefined) return renderLiteral(schema.const) - if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") - const alternatives = schema.anyOf ?? schema.oneOf - if (alternatives) { - // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" }, - // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact; - // real JSON Schema unions such as `string | number` or `number | null` must keep - // every branch. - if ( - alternatives.some((item) => item.type === "number") && - alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) - ) - return "number" - // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` - // (no properties/items); render the bare shape as {} instead of `{} | Array`. - if ( - alternatives.length === 2 && - alternatives[0]?.type === "object" && - alternatives[0].properties === undefined && - alternatives[1]?.type === "array" && - alternatives[1].items === undefined - ) { - return "{}" - } - const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen)) - if (members.some((member) => member === "unknown")) return "unknown" - return intersection([ - members.join(" | "), - renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen), - ]) - } - if (schema.allOf) { - const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen)) - if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown" - return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members]) - } - if (Array.isArray(schema.type)) { - return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ") - } - if (schema.type === "string") return "string" - if (schema.type === "number" || schema.type === "integer") return "number" - if (schema.type === "boolean") return "boolean" - if (schema.type === "null") return "null" - if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>` - if (schema.type === "object" || schema.properties) { - const required = new Set(schema.required ?? []) - const properties = Object.entries(schema.properties ?? {}) - const additional = schema.additionalProperties - const indexType = - additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined - const field = ([name, value]: readonly [string, JsonSchema]) => - `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}` - - if (!ctx.pretty) { - const fields = properties.map(field) - if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`) - return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` - } - - // Pretty: an indented block, each described field preceded by its JSDoc comment. - if (properties.length === 0 && indexType === undefined) return "{}" - const pad = " ".repeat(depth + 1) - const lines = properties.map( - (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`, - ) - if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`) - return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` - } - return "unknown" -} - -export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => { - try { - const visible = decoded ? Schema.toType(schema) : schema - const document = Schema.toJsonSchemaDocument(visible) as { - readonly schema: JsonSchema - readonly definitions?: Readonly> - } - return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty }) - } catch { - return "unknown" - } -} - -/** Renders a raw JSON Schema document as a TypeScript type string. */ -export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => { - try { - return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty }) - } catch { - return "unknown" - } -} - -/** One input property of a tool, extracted best-effort from its input schema. */ -export type InputProperty = { - readonly name: string - readonly description: string | undefined - readonly required: boolean -} - -/** - * The property names, descriptions, and required flags of a tool's input schema - the raw - * material for search text. Best-effort: Effect Schemas go through their - * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read - * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present. - * Anything unresolvable yields `[]` (search falls back to path + description). - */ -export const inputProperties = (definition: Definition): Array => { - try { - const document = isEffectSchema(definition.input) - ? (Schema.toJsonSchemaDocument(definition.input) as { - readonly schema: JsonSchema - readonly definitions?: Readonly> - }) - : { - schema: definition.input, - definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) }, - } - const definitions = document.definitions ?? {} - let schema = document.schema - if (schema.$ref !== undefined) { - const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1] - const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment) - const resolved = name === undefined ? undefined : definitions[name] - if (resolved === undefined) return [] - schema = resolved - } - const required = new Set(schema.required ?? []) - return Object.entries(schema.properties ?? {}).map(([name, value]) => ({ - name, - description: typeof value.description === "string" ? value.description : undefined, - required: required.has(name), - })) - } catch { - return [] - } -} - -/** - * The model-visible TypeScript type of a tool's input. `pretty` renders an indented - * multiline block with schema descriptions and constraints as JSDoc comments on the - * fields; the default stays the compact single-line form. - */ -export const inputTypeScript = (definition: Definition, pretty = false): string => - isEffectSchema(definition.input) - ? toTypeScript(definition.input, false, pretty) - : jsonSchemaToTypeScript(definition.input, pretty) - -/** - * The model-visible TypeScript type of a tool's result; tools without an output schema - * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs. - */ -export const outputTypeScript = (definition: Definition, pretty = false): string => - definition.output === undefined - ? "unknown" - : isEffectSchema(definition.output) - ? toTypeScript(definition.output, true, pretty) - : jsonSchemaToTypeScript(definition.output, pretty) - -/** - * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); - * JSON-Schema-described inputs pass through unvalidated (render-only). - */ -export const decodeInput = (definition: Definition, value: unknown): unknown => - isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value - -/** - * Decodes a tool result before it is exposed to the program. Effect Schemas validate and - * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass - * the host value through unchanged. - */ -export const decodeOutput = (definition: Definition, value: unknown): unknown => - definition.output !== undefined && isEffectSchema(definition.output) - ? Schema.decodeUnknownSync(definition.output)(value) - : value - /** * Defines one schema-described tool available to a CodeMode program through `tools.*`. * @@ -384,7 +85,7 @@ export const decodeOutput = (definition: Definition, value: unknown): unkn * }) * ``` */ -export const make = ( +export const make = ( options: Options, ): Definition => ({ _tag: "CodeModeTool", @@ -393,6 +94,3 @@ export const make = options.run(input as InputType), }) - -/** Constructors for schema-backed tools exposed inside CodeMode programs. */ -export const Tool = { make, isDefinition } diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 9db7739296c..087678c778c 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from "bun:test" import { Cause, Effect, Schema } from "effect" -import { CodeMode, Tool, toolError, type ExecutionLimits } from "../src/index.js" -import type { Definition } from "../src/tool.js" +import { CodeMode, Tool, toolError } from "../src/index.js" -const run = (tool: Definition) => +const run = (tool: Tool.Definition) => Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})")) class UnsafeHostError extends Schema.TaggedErrorClass()("UnsafeHostError", { @@ -349,7 +348,7 @@ describe("CodeMode output budget", () => { }) test("truncates an oversized result value with a marker instead of failing", async () => { - const limits: ExecutionLimits = { maxOutputBytes: 40 } + const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 } const result = await Effect.runPromise( CodeMode.execute({ code: `return { data: "${"x".repeat(200)}" }`, @@ -368,7 +367,7 @@ describe("CodeMode output budget", () => { }) test("keeps leading logs within the remaining budget and marks the cut", async () => { - const limits: ExecutionLimits = { maxOutputBytes: 40 } + const limits: CodeMode.ExecutionLimits = { maxOutputBytes: 40 } const result = await Effect.runPromise( CodeMode.execute({ code: ` @@ -502,7 +501,8 @@ describe("CodeMode public contract", () => { ]) expect(reusable).toStrictEqual(oneShot) - expect(Schema.decodeUnknownSync(CodeMode.Input)({ code: source })).toStrictEqual({ code: source }) + const input: CodeMode.Input = { code: source } + expect(Schema.decodeUnknownSync(CodeMode.Input)(input)).toStrictEqual(input) expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(reusable)))).toStrictEqual(reusable) }) diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 99a08900913..a50460e8daa 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { CodeMode, OpenAPI } from "../src/index.js" -import { inputTypeScript, outputTypeScript, Tool } from "../src/tool.js" +import { CodeMode, OpenAPI, Tool } from "../src/index.js" +import { inputTypeScript, outputTypeScript } from "../src/tool-schema.js" const baseUrl = "http://localhost:4096" type Document = OpenAPI.Document diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index e5c1d838221..dfa85831837 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { CodeMode } from "../src/index.js" import { ToolRuntime } from "../src/tool-runtime.js" -// Runs a CodeMode program with no host tools and returns the ExecuteResult. These tests pin the +// Runs a CodeMode program with no host tools and returns the CodeMode.Result. These tests pin the // JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where // a strict interpreter would throw but idiomatic JS yields undefined / succeeds. // diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 952b2fdc504..545d463abfa 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" -import { CodeMode, Tool, toolError, type ExecuteResult, type ExecutionLimits } from "../src/index.js" +import { CodeMode, Tool, toolError } from "../src/index.js" // Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on // supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are @@ -48,7 +48,10 @@ const failingTool = Tool.make({ run: () => Effect.fail(toolError("Lookup refused")), }) -const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise => { +const run = ( + code: string, + options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}, +): Promise => { const trace = options.trace ?? makeTrace() return Effect.runPromise( CodeMode.execute({ @@ -59,13 +62,13 @@ const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } ) } -const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => { +const value = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => { const result = await run(code, options) if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) return result.value } -const error = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => { +const error = async (code: string, options: { trace?: Trace; limits?: CodeMode.ExecutionLimits } = {}) => { const result = await run(code, options) if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) return result.error diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 55b8ac020a3..47345be7d77 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" -import { CodeMode } from "../src/index.js" -import { Tool, inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool.js" +import { CodeMode, Tool } from "../src/index.js" +import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js" // A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema // whose property descriptions and constraints must surface as JSDoc in pretty signatures. diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 58dc097a0fb..f5bd776e5ab 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -1,14 +1,6 @@ export * as ExecuteTool from "./execute" -import { - CodeMode, - Tool, - toolError, - type DataValue, - type ExecuteResult, - type ToolCallHooks, - type ToolDefinition, -} from "@opencode-ai/codemode" +import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" import { ToolOutput } from "@opencode-ai/llm" import { Effect, Ref, Schema } from "effect" import { definition, make, settle, type AnyTool } from "./tool" @@ -57,9 +49,9 @@ export const create = (options: { }) => { const runtime = ( invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, - hooks?: ToolCallHooks, + hooks?: CodeMode.ToolCallHooks, ) => { - const tools: Record | Record>> = {} + const tools: Record | Record>> = {} for (const [name, registration] of options.registrations) { const child = definition(name, registration.tool) const value = Tool.make({ @@ -83,7 +75,7 @@ export const create = (options: { group[path] = value continue } - const entries: Record> = {} + const entries: Record> = {} entries[path] = value tools[namespace] = entries } @@ -178,7 +170,7 @@ function displayInput(input: unknown): Record | undefined { return input as Record } -function formatResult(result: ExecuteResult) { +function formatResult(result: CodeMode.Result) { const output = result.ok ? formatValue(result.value) : [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))] @@ -189,7 +181,7 @@ function formatResult(result: ExecuteResult) { return output === "" ? logs : `${output}\n\n${logs}` } -function formatValue(value: DataValue) { +function formatValue(value: CodeMode.DataValue) { if (typeof value === "string") return value return JSON.stringify(value, null, 2) ?? String(value) }