refactor(codemode): namespace public types (#35435)
This commit is contained in:
parent
f950497173
commit
f9d1d3b259
14 changed files with 423 additions and 475 deletions
|
|
@ -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<Tools extends Record<string, unknown> = {}> = {
|
|||
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Tools>>
|
||||
}
|
||||
|
||||
/** 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<string>
|
||||
}
|
||||
|
||||
/** 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<string>
|
||||
/** Present when the value or logs were truncated to fit `maxOutputBytes`. */
|
||||
readonly truncated?: boolean
|
||||
readonly toolCalls: ReadonlyArray<ToolCall>
|
||||
}
|
||||
|
||||
/** Failed execution with calls admitted before the diagnostic was produced. */
|
||||
export type ExecuteFailure = {
|
||||
readonly ok: false
|
||||
readonly error: Diagnostic
|
||||
readonly logs?: ReadonlyArray<string>
|
||||
/** Present when the logs were truncated to fit `maxOutputBytes`. */
|
||||
readonly truncated?: boolean
|
||||
readonly toolCalls: ReadonlyArray<ToolCall>
|
||||
}
|
||||
|
||||
/** 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<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code"> & {
|
||||
export type Options<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "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<R = never> = {
|
||||
export type Runtime<R = never> = {
|
||||
/** Lists schema-described tool paths provided by the host. */
|
||||
readonly catalog: () => ReadonlyArray<ToolDescription>
|
||||
/** 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<ExecuteResult, never, R>
|
||||
readonly execute: (code: string) => Effect.Effect<Result, never, R>
|
||||
}
|
||||
|
||||
type SourcePosition = {
|
||||
|
|
@ -275,19 +258,6 @@ const errorBrandName = (value: unknown): string | undefined =>
|
|||
? ((value as Record<PropertyKey, unknown>)[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 = <const Tools extends Record<string, unknown>>(
|
|||
options: ExecuteOptions<Tools>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
||||
): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
const hooks = {
|
||||
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
|
||||
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
|
||||
|
|
@ -3975,7 +3945,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
|||
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 = <const Tools extends Record<string, unknown>>(
|
|||
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies ExecuteResult),
|
||||
} satisfies Result),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -4002,7 +3972,7 @@ const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
|||
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 = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
|
||||
|
|
@ -4086,8 +4056,8 @@ export const execute = <const Tools extends Record<string, unknown>>(
|
|||
* ```
|
||||
*/
|
||||
export const make = <const Tools extends Record<string, unknown> = {}>(
|
||||
options: CodeModeOptions<Tools> = {} as CodeModeOptions<Tools>,
|
||||
): CodeModeRuntime<Services<Tools>> => {
|
||||
options: Options<Tools> = {} as Options<Tools>,
|
||||
): Runtime<Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
|
|
@ -4102,6 +4072,3 @@ export const make = <const Tools extends Record<string, unknown> = {}>(
|
|||
execute: executeProgram,
|
||||
}
|
||||
}
|
||||
|
||||
/** Constructors for one-shot and reusable CodeMode execution. */
|
||||
export const CodeMode = { Input, Result, make, execute }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
301
packages/codemode/src/tool-schema.ts
Normal file
301
packages/codemode/src/tool-schema.ts
Normal file
|
|
@ -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<unknown> & 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>): 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<Record<string, JsonSchema>>
|
||||
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
|
||||
readonly pretty: boolean
|
||||
}
|
||||
|
||||
const hasUnresolvedRef = (
|
||||
schema: JsonSchema,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
seen: ReadonlySet<string> = new Set(),
|
||||
visited: ReadonlySet<JsonSchema> = 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<string> => {
|
||||
const tags: Array<string> = []
|
||||
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<string>, 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<string> = 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<unknown>`.
|
||||
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<Record<string, JsonSchema>>
|
||||
}
|
||||
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 = <R>(definition: Definition<R>): Array<InputProperty> => {
|
||||
try {
|
||||
const document = isEffectSchema(definition.input)
|
||||
? (Schema.toJsonSchemaDocument(definition.input) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
})
|
||||
: {
|
||||
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 = <R>(definition: Definition<R>, 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 = <R>(definition: Definition<R>, 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 = <R>(definition: Definition<R>, 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 = <R>(definition: Definition<R>, value: unknown): unknown =>
|
||||
definition.output !== undefined && isEffectSchema(definition.output)
|
||||
? Schema.decodeUnknownSync(definition.output)(value)
|
||||
: value
|
||||
|
|
@ -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<unknown> | JsonSchema
|
||||
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
|
||||
|
||||
/** Schema-backed tool definition consumed by a CodeMode tool tree. */
|
||||
export type Definition<R = never> = {
|
||||
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<unknown, unknown, R>
|
||||
}
|
||||
|
||||
/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
|
||||
export type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
|
||||
type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
|
||||
|
||||
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
|
||||
export type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
||||
type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
|
||||
|
||||
/** Options for defining one CodeMode tool. */
|
||||
export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R = never> = {
|
||||
export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
|
||||
readonly description: string
|
||||
readonly input: I
|
||||
readonly output?: O
|
||||
|
|
@ -58,305 +58,6 @@ export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R =
|
|||
export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool"
|
||||
|
||||
const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & 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>): 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<Record<string, JsonSchema>>
|
||||
/** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
|
||||
readonly pretty: boolean
|
||||
}
|
||||
|
||||
const hasUnresolvedRef = (
|
||||
schema: JsonSchema,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
seen: ReadonlySet<string> = new Set(),
|
||||
visited: ReadonlySet<JsonSchema> = 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<string> => {
|
||||
const tags: Array<string> = []
|
||||
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<string>, 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<string> = 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<unknown>`.
|
||||
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<Record<string, JsonSchema>>
|
||||
}
|
||||
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 = <R>(definition: Definition<R>): Array<InputProperty> => {
|
||||
try {
|
||||
const document = isEffectSchema(definition.input)
|
||||
? (Schema.toJsonSchemaDocument(definition.input) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
})
|
||||
: {
|
||||
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 = <R>(definition: Definition<R>, 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 = <R>(definition: Definition<R>, 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 = <R>(definition: Definition<R>, 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 = <R>(definition: Definition<R>, 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 = <R>(definition: Definition<R>, value: unknown): unkn
|
|||
* })
|
||||
* ```
|
||||
*/
|
||||
export const make = <I extends ToolSchema, const O extends ToolSchema | undefined = undefined, R = never>(
|
||||
export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
|
||||
options: Options<I, O, R>,
|
||||
): Definition<R> => ({
|
||||
_tag: "CodeModeTool",
|
||||
|
|
@ -393,6 +94,3 @@ export const make = <I extends ToolSchema, const O extends ToolSchema | undefine
|
|||
output: options.output,
|
||||
run: (input) => options.run(input as InputType<I>),
|
||||
})
|
||||
|
||||
/** Constructors for schema-backed tools exposed inside CodeMode programs. */
|
||||
export const Tool = { make, isDefinition }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue