feat(codemode): add confined execution package (#35079)
This commit is contained in:
parent
379adee35c
commit
2409c7a3d5
20 changed files with 9109 additions and 15 deletions
3585
packages/codemode/src/codemode.ts
Normal file
3585
packages/codemode/src/codemode.ts
Normal file
File diff suppressed because it is too large
Load diff
27
packages/codemode/src/index.ts
Normal file
27
packages/codemode/src/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export {
|
||||
ToolError,
|
||||
CodeMode,
|
||||
ExecuteInputSchema,
|
||||
ExecuteResultSchema,
|
||||
toolError,
|
||||
} from "./codemode.js"
|
||||
export { Tool } from "./tool.js"
|
||||
export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
|
||||
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"
|
||||
export type {
|
||||
AgentToolDefinition,
|
||||
CodeModeOptions,
|
||||
CodeModeRuntime,
|
||||
DataValue,
|
||||
Diagnostic,
|
||||
DiagnosticKind,
|
||||
DiscoveryOptions,
|
||||
ExecuteFailure,
|
||||
ExecuteOptions,
|
||||
ExecuteResult,
|
||||
ExecuteSuccess,
|
||||
ExecutionLimits,
|
||||
ToolCall,
|
||||
ToolCallStarted,
|
||||
ToolDescription,
|
||||
} from "./codemode.js"
|
||||
10
packages/codemode/src/token.ts
Normal file
10
packages/codemode/src/token.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Token estimation for budgeting model-facing text. Copied from
|
||||
* `@opencode-ai/core/util/token` (chars / 4) so this package stays
|
||||
* dependency-free; keep the two in sync if the heuristic ever changes.
|
||||
*/
|
||||
export * as Token from "./token.js"
|
||||
|
||||
const CHARS_PER_TOKEN = 4
|
||||
|
||||
export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN))
|
||||
11
packages/codemode/src/tool-error.ts
Normal file
11
packages/codemode/src/tool-error.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Schema } from "effect"
|
||||
|
||||
/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */
|
||||
export class ToolError extends Schema.TaggedErrorClass<ToolError>()("ToolError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optionalKey(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */
|
||||
export const toolError = (message: string, cause?: unknown): ToolError =>
|
||||
new ToolError({ message, ...(cause === undefined ? {} : { cause }) })
|
||||
746
packages/codemode/src/tool-runtime.ts
Normal file
746
packages/codemode/src/tool-runtime.ts
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
import { Cause, Effect } from "effect"
|
||||
import { ToolError, toolError } from "./tool-error.js"
|
||||
import {
|
||||
decodeInput as decodeToolInput,
|
||||
decodeOutput as decodeToolOutput,
|
||||
identifierSegment,
|
||||
inputProperties,
|
||||
inputTypeScript,
|
||||
isDefinition as isToolDefinition,
|
||||
outputTypeScript,
|
||||
type Definition,
|
||||
} from "./tool.js"
|
||||
import { estimate } from "./token.js"
|
||||
import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
|
||||
|
||||
export type HostTool<R = never> = (...args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
|
||||
export type HostTools<R = never> = {
|
||||
[name: string]: HostTool<R> | Definition<R> | HostTools<R>
|
||||
}
|
||||
|
||||
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
|
||||
? R
|
||||
: Tools extends { readonly _tag: "CodeModeTool"; readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R> }
|
||||
? R
|
||||
: Tools extends object
|
||||
? string extends keyof Tools ? never : Services<Tools[keyof Tools]>
|
||||
: never
|
||||
|
||||
/** Minimal audit record retained for each admitted tool call. */
|
||||
export type ToolCall = {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** Decoded tool call observed immediately before tool execution. */
|
||||
export type ToolCallStarted = {
|
||||
readonly index: number
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
}
|
||||
|
||||
/** Completed tool call observed immediately after tool execution settles. */
|
||||
export type ToolCallEnded = {
|
||||
readonly index: number
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly durationMs: number
|
||||
readonly outcome: "success" | "failure"
|
||||
/** Model-safe failure message; present only when `outcome` is `"failure"`. */
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
/** Non-throwing observation hooks fired around each admitted tool call. */
|
||||
export type ToolCallHooks<R = never> = {
|
||||
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
|
||||
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
|
||||
}
|
||||
|
||||
/** Model-visible description of one schema-backed tool. */
|
||||
export type ToolDescription = {
|
||||
readonly path: string
|
||||
readonly description: string
|
||||
readonly signature: string
|
||||
}
|
||||
|
||||
export type SafeObject = Record<string, unknown>
|
||||
|
||||
const reservedNamespace = "$codemode"
|
||||
const defaultMaxInlineCatalogTokens = 2_000
|
||||
const defaultSearchLimit = 10
|
||||
const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>"
|
||||
const toolExpression = (path: string) =>
|
||||
"tools" + path
|
||||
.split(".")
|
||||
.map((segment) => identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`)
|
||||
.join("")
|
||||
|
||||
export class ToolReference {
|
||||
constructor(readonly path: ReadonlyArray<string>) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable
|
||||
* limit) purely because it produces a clearer diagnostic than a native stack-overflow
|
||||
* RangeError would.
|
||||
*/
|
||||
const MAX_VALUE_DEPTH = 32
|
||||
|
||||
export class ToolRuntimeError extends Error {
|
||||
constructor(
|
||||
readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded",
|
||||
message: string,
|
||||
readonly suggestions: ReadonlyArray<string> = [],
|
||||
) {
|
||||
super(message)
|
||||
this.name = "ToolRuntimeError"
|
||||
}
|
||||
}
|
||||
|
||||
const isDefinition = <R>(value: HostTool<R> | Definition<R> | HostTools<R>): value is Definition<R> =>
|
||||
isToolDefinition<R>(value)
|
||||
|
||||
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error))
|
||||
}),
|
||||
)
|
||||
|
||||
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
|
||||
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
|
||||
|
||||
/**
|
||||
* Validates and copies a value against the plain-data contract (depth, circularity, plain
|
||||
* objects only, blocked properties, data-only leaves).
|
||||
*
|
||||
* Two modes share the walk:
|
||||
* - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary -
|
||||
* final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize
|
||||
* exactly as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}.
|
||||
* - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
|
||||
* codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves,
|
||||
* contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
|
||||
* other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
|
||||
*
|
||||
* Both modes reject un-awaited promises with an await-hinting diagnostic.
|
||||
*/
|
||||
export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown =>
|
||||
copyBounded(value, label, 0, new Set(), preserveSandboxValues)
|
||||
|
||||
const copyBounded = (value: unknown, label: string, depth: number, seen: Set<object>, preserveSandboxValues: boolean): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
}
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
// NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real
|
||||
// engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are
|
||||
// normalized to `null` when the value leaves the sandbox - see copyOut - exactly as
|
||||
// JSON.stringify already does at any tool boundary.
|
||||
typeof value === "number"
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
|
||||
}
|
||||
|
||||
// An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the
|
||||
// model exactly how to fix the program instead.
|
||||
if (value instanceof SandboxPromise) {
|
||||
throw new ToolRuntimeError(
|
||||
"InvalidDataValue",
|
||||
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (preserveSandboxValues) {
|
||||
// Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents
|
||||
// are never walked here (Map/Set members are validated where mutation happens, and the
|
||||
// real boundary still serializes them below).
|
||||
if (value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet) {
|
||||
return value
|
||||
}
|
||||
// Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross
|
||||
// the boundary first), but wrap them defensively rather than degrading to JSON forms.
|
||||
if (value instanceof Date) return new SandboxDate(value.getTime())
|
||||
if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new SandboxMap()
|
||||
for (const [key, item] of value.entries()) {
|
||||
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const wrapped = new SandboxSet()
|
||||
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
|
||||
return wrapped
|
||||
}
|
||||
}
|
||||
|
||||
// Sandbox value types (and their host counterparts, which a host tool may legitimately
|
||||
// return) serialize exactly as JSON.stringify would at the data boundary: a Date is its
|
||||
// toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}.
|
||||
if (value instanceof SandboxDate) {
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isFinite(value.getTime()) ? value.toISOString() : null
|
||||
}
|
||||
if (
|
||||
value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet ||
|
||||
value instanceof RegExp || value instanceof Map || value instanceof Set
|
||||
) {
|
||||
return Object.create(null) as SafeObject
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
|
||||
}
|
||||
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues))
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
|
||||
}
|
||||
|
||||
const copied: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (isBlockedMember(key)) {
|
||||
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
|
||||
}
|
||||
copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues)
|
||||
}
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
|
||||
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
||||
if (value === undefined && undefinedAsNull) return null
|
||||
// Normalize non-finite numbers to null as the value crosses out of the sandbox (final return
|
||||
// and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity
|
||||
// have no JSON representation, so JSON.stringify would produce null anyway.
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => copyOut(item, undefinedAsNull))
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)]))
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const definitions = <R>(tools: HostTools<R>, path: ReadonlyArray<string> = []): Array<{ path: string; definition: Definition<R> }> => {
|
||||
const entries: Array<{ path: string; definition: Definition<R> }> = []
|
||||
for (const [name, value] of Object.entries(tools)) {
|
||||
const next = [...path, name]
|
||||
if (isDefinition(value)) entries.push({ path: next.join("."), definition: value })
|
||||
else if (typeof value !== "function") entries.push(...definitions(value, next))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
|
||||
path,
|
||||
description: definition.description,
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`,
|
||||
})
|
||||
|
||||
const visibleDefinitions = <R>(tools: HostTools<R>) =>
|
||||
definitions(tools).flatMap(({ path, definition }) => {
|
||||
const description = describeDefinition(path, definition)
|
||||
return [{ path, definition, description }]
|
||||
})
|
||||
|
||||
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
|
||||
visibleDefinitions(tools).map(({ description }) => description)
|
||||
|
||||
export type DiscoveryPlan = {
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
readonly instructions: string
|
||||
readonly searchIndex: ReadonlyArray<SearchEntry>
|
||||
}
|
||||
|
||||
export type SearchEntry = {
|
||||
readonly description: ToolDescription
|
||||
/**
|
||||
* JSDoc-annotated multiline signature shown on search-result items; the compact
|
||||
* single-line form (inline catalog lines) stays in `description.signature`.
|
||||
*/
|
||||
readonly signature: string
|
||||
/** Top-level namespace (first path segment), matched by the search `namespace` option. */
|
||||
readonly namespace: string
|
||||
/** Lowercased path + description + input property names/descriptions, for substring matching. */
|
||||
readonly searchText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a query into lowercased search terms. camelCase boundaries are split
|
||||
* (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a
|
||||
* separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all
|
||||
* tokenize alike. Empties and the `*` wildcard are dropped.
|
||||
*/
|
||||
const tokenize = (query: string): Array<string> =>
|
||||
query
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((term) => term.length > 0 && term !== "*")
|
||||
|
||||
/**
|
||||
* A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural
|
||||
* query term ("issues") still matches indexed text that only carries the singular
|
||||
* ("issue"). Matching is one-directional substring containment, so the variants are
|
||||
* needed only on the query side; scoring weights are unchanged - each field check
|
||||
* passes when ANY form matches.
|
||||
*/
|
||||
const termForms = (term: string): Array<string> => {
|
||||
const forms = [term]
|
||||
if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2))
|
||||
if (term.endsWith("s") && term.length > 2) forms.push(term.slice(0, -1))
|
||||
return forms
|
||||
}
|
||||
|
||||
const firstLine = (text: string) => text.split("\n", 1)[0]!.trim()
|
||||
|
||||
/** One-line description used on inline catalog lines; the full text stays in search results. */
|
||||
const brief = (text: string, max = 120) => {
|
||||
const line = firstLine(text)
|
||||
return line.length > max ? line.slice(0, max - 1) + "..." : line
|
||||
}
|
||||
|
||||
const catalogLine = (tool: ToolDescription) => {
|
||||
const description = brief(tool.description)
|
||||
return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}`
|
||||
}
|
||||
|
||||
const toSearchEntry = <R>(path: string, definition: Definition<R>, description: ToolDescription): SearchEntry => ({
|
||||
description,
|
||||
signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`,
|
||||
namespace: path.split(".", 1)[0]!,
|
||||
searchText: [
|
||||
path,
|
||||
definition.description,
|
||||
...inputProperties(definition).flatMap(({ name, description: property }) =>
|
||||
property === undefined ? [name] : [name, property]),
|
||||
].join("\n").toLowerCase(),
|
||||
})
|
||||
|
||||
/** The runtime search index over every described tool. Search is always registered. */
|
||||
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
||||
|
||||
export const assertValidTools = <R>(tools: HostTools<R>): void => {
|
||||
if (Object.hasOwn(tools, reservedNamespace)) {
|
||||
throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Budgeted catalog: every namespace is always listed with its tool count; full call
|
||||
* signatures are inlined against the `maxInlineCatalogTokens` budget (estimated tokens,
|
||||
* chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every
|
||||
* namespace still holding un-inlined tools attempts to place its next-cheapest line, and
|
||||
* a namespace whose next line does not fit is done while the others keep going - so every
|
||||
* namespace gets some representation before any namespace gets everything. The section
|
||||
* states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per
|
||||
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
|
||||
* tool count even at budget 0.
|
||||
*/
|
||||
export const discoveryPlan = <R>(
|
||||
tools: HostTools<R>,
|
||||
maxInlineCatalogTokens = defaultMaxInlineCatalogTokens,
|
||||
): DiscoveryPlan => {
|
||||
if (!Number.isSafeInteger(maxInlineCatalogTokens) || maxInlineCatalogTokens < 0) {
|
||||
throw new RangeError("discovery.maxInlineCatalogTokens must be a non-negative safe integer")
|
||||
}
|
||||
const visible = visibleDefinitions(tools)
|
||||
const described = visible.map(({ description }) => description)
|
||||
|
||||
const namespaces = new Map<string, Array<ToolDescription>>()
|
||||
for (const tool of described) {
|
||||
const [namespace = tool.path] = tool.path.split(".")
|
||||
const group = namespaces.get(namespace) ?? []
|
||||
group.push(tool)
|
||||
namespaces.set(namespace, group)
|
||||
}
|
||||
const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right))
|
||||
|
||||
// Select which signatures fit the budget before emitting, so the list can state
|
||||
// exactly how comprehensive it is. Round-robin fairness: in each round (namespaces
|
||||
// alphabetical), every namespace still holding un-inlined tools tries to place its
|
||||
// next-cheapest line against the shared budget; a namespace whose next line does not
|
||||
// fit is done - the others keep going - so every namespace gets some representation
|
||||
// before any namespace gets everything.
|
||||
const selections = ordered.map(([namespace, group]) => ({
|
||||
namespace,
|
||||
picked: new Set<ToolDescription>(),
|
||||
queue: [...group].sort(
|
||||
(left, right) => estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path),
|
||||
),
|
||||
}))
|
||||
let used = 0
|
||||
let active = selections.filter((selection) => selection.queue.length > 0)
|
||||
while (active.length > 0) {
|
||||
const stillActive: typeof active = []
|
||||
for (const selection of active) {
|
||||
const tool = selection.queue[0]!
|
||||
const cost = estimate(catalogLine(tool))
|
||||
if (used + cost > maxInlineCatalogTokens) continue
|
||||
selection.queue.shift()
|
||||
selection.picked.add(tool)
|
||||
used += cost
|
||||
if (selection.queue.length > 0) stillActive.push(selection)
|
||||
}
|
||||
active = stillActive
|
||||
}
|
||||
const shown = new Map<string, ReadonlySet<ToolDescription>>(
|
||||
selections.map(({ namespace, picked }) => [namespace, picked]),
|
||||
)
|
||||
const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0)
|
||||
const complete = totalShown === described.length
|
||||
|
||||
const empty = described.length === 0
|
||||
|
||||
// Section order is deliberate: workflow first (the top is the least likely part of a long
|
||||
// description to be truncated or skimmed away), then rules, then syntax, with the budgeted
|
||||
// catalog at the bottom. Example call forms use explicit `<namespace>.<tool>` placeholders -
|
||||
// never a real or fabricated tool name.
|
||||
const intro = [
|
||||
"Write a CodeMode program to answer the request. Return code only.",
|
||||
empty
|
||||
? "Execute JavaScript in a confined runtime."
|
||||
: complete
|
||||
? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here."
|
||||
: "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.",
|
||||
]
|
||||
|
||||
// The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE
|
||||
// catalog already shows every signature, so step 1 picks from the list instead.
|
||||
const workflow = empty
|
||||
? []
|
||||
: [
|
||||
"",
|
||||
"## Workflow",
|
||||
"",
|
||||
...(complete
|
||||
? [
|
||||
"1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.",
|
||||
"2. Call it using the exact signature shown: `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
|
||||
'3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
|
||||
"4. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
|
||||
]
|
||||
: [
|
||||
'1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "<intent + key nouns>" })` - short phrases like "list issues" work best.',
|
||||
"2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.",
|
||||
"3. Call it with the result's `path` as-is (never guess segments): `const res = await tools.<namespace>.<tool>(input)` - bracket notation may appear for names that are not JavaScript identifiers.",
|
||||
'4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.',
|
||||
"5. Return only the fields you need: `return { <field>: data.<field> }` - raw payloads get truncated and waste context.",
|
||||
]),
|
||||
]
|
||||
|
||||
const rules = empty
|
||||
? []
|
||||
: [
|
||||
"",
|
||||
"## Rules",
|
||||
"",
|
||||
complete
|
||||
? "- Only tools listed here are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed."
|
||||
: "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.",
|
||||
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
|
||||
"- A result typed `Promise<unknown>` has no guaranteed shape - verify what actually came back before relying on its fields.",
|
||||
"- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`.",
|
||||
"- `Object.keys(tools)` lists namespaces; `Object.keys(tools.<namespace>)` lists its tools; `for...in` works on both.",
|
||||
...(complete ? [] : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.']),
|
||||
]
|
||||
|
||||
const syntax = [
|
||||
"",
|
||||
"## Syntax",
|
||||
"",
|
||||
"Standard modern JavaScript works: functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date, RegExp, Map, Set, and Promise.all/allSettled/race/resolve/reject.",
|
||||
"TypeScript type annotations are allowed and stripped before execution (decorators are not supported).",
|
||||
"Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch).",
|
||||
"Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
|
||||
]
|
||||
|
||||
const toolSection: Array<string> = [""]
|
||||
if (empty) {
|
||||
toolSection.push("## Available tools", "", "No tools are currently available.")
|
||||
} else {
|
||||
toolSection.push(
|
||||
complete
|
||||
? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
|
||||
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`,
|
||||
"",
|
||||
)
|
||||
for (const [namespace, group] of ordered) {
|
||||
const picked = shown.get(namespace)!
|
||||
const count = `${group.length} tool${group.length === 1 ? "" : "s"}`
|
||||
// Annotate only when a namespace is not fully shown, so a comprehensive
|
||||
// namespace reads cleanly and a truncated one is unambiguous.
|
||||
const label = picked.size === group.length ? count : picked.size === 0 ? `${count}, none shown` : `${count}, ${picked.size} shown`
|
||||
toolSection.push(`- ${namespace} (${label})`)
|
||||
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
|
||||
}
|
||||
if (!complete) {
|
||||
toolSection.push(
|
||||
"",
|
||||
"Search returns complete callable signatures:",
|
||||
`- ${searchSignature}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
...intro,
|
||||
...workflow,
|
||||
...rules,
|
||||
...syntax,
|
||||
...toolSection,
|
||||
]
|
||||
return {
|
||||
catalog: described,
|
||||
instructions: lines.join("\n"),
|
||||
searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The enumerable names at one node of the host tool tree - namespace names at the root,
|
||||
* tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool
|
||||
* references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a
|
||||
* function in JS). An unknown path is an `UnknownTool` error pointing at the working
|
||||
* discovery idioms, mirroring how calling an unknown tool fails.
|
||||
*/
|
||||
const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): ReadonlyArray<string> => {
|
||||
// The reserved discovery namespace is virtual (never present in the host tree); enumerate
|
||||
// it explicitly so `Object.keys(tools.$codemode)` matches the callable surface.
|
||||
if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"]
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
for (const segment of path) {
|
||||
if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
|
||||
throw new ToolRuntimeError(
|
||||
"UnknownTool",
|
||||
`Unknown tool namespace '${path.join(".")}'.`,
|
||||
searchEnabled
|
||||
? ["Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools."]
|
||||
: ["Object.keys(tools) lists the available namespaces."],
|
||||
)
|
||||
}
|
||||
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
||||
}
|
||||
if (typeof value === "function" || isDefinition(value)) return []
|
||||
return Object.keys(value)
|
||||
}
|
||||
|
||||
const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>, searchEnabled: boolean): HostTool<R> | Definition<R> => {
|
||||
let value: HostTool<R> | Definition<R> | HostTools<R> = tools
|
||||
|
||||
for (const segment of path) {
|
||||
if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) {
|
||||
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : [])
|
||||
}
|
||||
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
||||
}
|
||||
|
||||
if (typeof value !== "function" && !isDefinition(value)) {
|
||||
throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export type ToolRuntime<R = never> = {
|
||||
readonly root: ToolReference
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
/** Enumerable namespace/tool names at one node of the host tool tree; see `namespaceKeys`. */
|
||||
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
}
|
||||
|
||||
const failureMessage = (error: unknown): string =>
|
||||
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
|
||||
|
||||
export const make = <R>(
|
||||
tools: HostTools<R>,
|
||||
/** Undefined means unlimited tool calls. */
|
||||
maxToolCalls: number | undefined,
|
||||
hooks?: ToolCallHooks<R>,
|
||||
searchIndex?: ReadonlyArray<SearchEntry>,
|
||||
): ToolRuntime<R> => {
|
||||
const calls: Array<ToolCall> = []
|
||||
const searchEnabled = searchIndex !== undefined
|
||||
|
||||
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
|
||||
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
|
||||
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
||||
const onEnd = hooks?.onToolCallEnd
|
||||
if (onEnd === undefined) return effect
|
||||
const startedAt = Date.now()
|
||||
return effect.pipe(
|
||||
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
|
||||
Effect.tapError((error) =>
|
||||
onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) })),
|
||||
)
|
||||
}
|
||||
|
||||
const decodeOutput = (value: unknown, name: string) =>
|
||||
Effect.try({
|
||||
try: () => copyIn(value, `Result from tool '${name}'`),
|
||||
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
||||
})
|
||||
|
||||
const recordCall = (call: ToolCall): void => {
|
||||
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
|
||||
throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`)
|
||||
}
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
return {
|
||||
root: new ToolReference([]),
|
||||
calls,
|
||||
keys: (path) => namespaceKeys(tools, path, searchEnabled),
|
||||
invoke: (path, args) =>
|
||||
Effect.gen(function*() {
|
||||
const name = path.join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
|
||||
const call = { name }
|
||||
const recordAndObserve = (input: unknown) =>
|
||||
Effect.sync(() => {
|
||||
recordCall(call)
|
||||
return calls.length - 1
|
||||
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
|
||||
if (name === "$codemode.search") {
|
||||
if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`)
|
||||
const input = externalArgs[0]
|
||||
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.")
|
||||
}
|
||||
const request = input as { query?: unknown; namespace?: unknown; limit?: unknown }
|
||||
if (request.query !== undefined && typeof request.query !== "string") {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search query must be a string when provided.")
|
||||
}
|
||||
if (request.namespace !== undefined && typeof request.namespace !== "string") {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search namespace must be a string when provided.")
|
||||
}
|
||||
if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)) {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search limit must be a positive safe integer when provided.")
|
||||
}
|
||||
const query = typeof request.query === "string" ? request.query : ""
|
||||
const namespace = typeof request.namespace === "string" ? request.namespace : undefined
|
||||
const index = yield* recordAndObserve(request)
|
||||
return yield* observeEnd(
|
||||
Effect.try({
|
||||
try: () => {
|
||||
const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit
|
||||
const scoped = namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace)
|
||||
// A query that names one tool path exactly (canonical path or rendered
|
||||
// JavaScript expression) is a lookup, not a search: return that tool alone.
|
||||
const trimmed = query.trim()
|
||||
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
||||
const exact = pathQuery === "" ? undefined : scoped.find((entry) =>
|
||||
entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed)
|
||||
const terms = tokenize(query).map(termForms)
|
||||
// Additive field-weighted scoring, summed across terms: exact path or path
|
||||
// segment (20) > path substring (8) > description substring (4) > any
|
||||
// searchable text, incl. input parameter names/descriptions (2). Each term
|
||||
// matches a field when any of its forms (the term or a singular variant)
|
||||
// does. An empty query browses everything, alphabetical by path.
|
||||
const ranked = exact !== undefined
|
||||
? [exact]
|
||||
: scoped
|
||||
.map((entry) => {
|
||||
const path = entry.description.path.toLowerCase()
|
||||
const description = entry.description.description.toLowerCase()
|
||||
const score = terms.reduce(
|
||||
(total, forms) =>
|
||||
total +
|
||||
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
|
||||
(forms.some((form) => path.includes(form)) ? 8 : 0) +
|
||||
(forms.some((form) => description.includes(form)) ? 4 : 0) +
|
||||
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
|
||||
0,
|
||||
)
|
||||
return { entry, score }
|
||||
})
|
||||
.filter(({ score }) => terms.length === 0 || score > 0)
|
||||
.sort((left, right) =>
|
||||
right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path))
|
||||
.map(({ entry }) => entry)
|
||||
// Result paths are rendered as JavaScript expressions so each `path` is
|
||||
// directly usable as the call site (`await tools.github.list({ ... })` or
|
||||
// `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty,
|
||||
// JSDoc-annotated form (schema descriptions and constraints ride along as
|
||||
// field comments).
|
||||
const items = ranked.slice(0, limit).map(({ description, signature }) => ({
|
||||
...description,
|
||||
path: toolExpression(description.path),
|
||||
signature,
|
||||
}))
|
||||
return copyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'")
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
}),
|
||||
{ index, name, input: request },
|
||||
)
|
||||
}
|
||||
|
||||
const tool = resolve(tools, path, searchEnabled)
|
||||
let describedInput: unknown
|
||||
if (isDefinition(tool)) {
|
||||
if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
describedInput = yield* Effect.try({
|
||||
try: () => decodeToolInput(tool, externalArgs[0]),
|
||||
catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
})
|
||||
}
|
||||
const input = isDefinition(tool) ? describedInput : externalArgs
|
||||
const index = yield* recordAndObserve(input)
|
||||
const currentCall = { index, name, input }
|
||||
if (isDefinition(tool)) {
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function*() {
|
||||
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
|
||||
const result = yield* Effect.try({
|
||||
try: () => decodeToolOutput(tool, raw),
|
||||
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
||||
})
|
||||
return yield* decodeOutput(result, name)
|
||||
}),
|
||||
currentCall,
|
||||
)
|
||||
}
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function*() {
|
||||
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
|
||||
}),
|
||||
currentCall,
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export * as ToolRuntime from "./tool-runtime.js"
|
||||
333
packages/codemode/src/tool.ts
Normal file
333
packages/codemode/src/tool.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import { Effect, Schema } from "effect"
|
||||
|
||||
/**
|
||||
* JSON Schema subset accepted for render-only tool schemas.
|
||||
*
|
||||
* A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript
|
||||
* signature only - CodeMode performs no validation against it. This is the natural shape for
|
||||
* adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents.
|
||||
*/
|
||||
export type JsonSchema = {
|
||||
readonly type?: string | ReadonlyArray<string>
|
||||
readonly enum?: ReadonlyArray<unknown>
|
||||
readonly const?: unknown
|
||||
readonly anyOf?: ReadonlyArray<JsonSchema>
|
||||
readonly oneOf?: ReadonlyArray<JsonSchema>
|
||||
readonly properties?: Readonly<Record<string, JsonSchema>>
|
||||
readonly required?: ReadonlyArray<string>
|
||||
readonly items?: JsonSchema
|
||||
readonly additionalProperties?: boolean | JsonSchema
|
||||
readonly description?: string
|
||||
readonly default?: unknown
|
||||
readonly format?: string
|
||||
readonly deprecated?: boolean
|
||||
readonly minItems?: number
|
||||
readonly maxItems?: number
|
||||
readonly $ref?: string
|
||||
readonly $defs?: Readonly<Record<string, JsonSchema>>
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
}
|
||||
|
||||
/** Either a validating Effect Schema or a render-only JSON Schema document. */
|
||||
export type ToolSchema = 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 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
|
||||
|
||||
/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
|
||||
export 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> = {
|
||||
readonly description: string
|
||||
readonly input: I
|
||||
readonly output?: O
|
||||
readonly run: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, 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")
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
if (schema.$ref) {
|
||||
const name = schema.$ref.split("/").pop()
|
||||
if (!name || !ctx.definitions[name]) return name ?? "unknown"
|
||||
if (seen.has(name)) return name // recursive type: reference by name rather than loop
|
||||
return renderSchema(ctx.definitions[name], ctx, depth, new Set([...seen, name]))
|
||||
}
|
||||
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 "{}"
|
||||
}
|
||||
return alternatives.map((item) => renderSchema(item, ctx, depth + 1, seen)).join(" | ")
|
||||
}
|
||||
if (Array.isArray(schema.type)) {
|
||||
return schema.type.map((item) => renderSchema({ type: item }, ctx, 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 ?? {}, ctx, 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, ctx, depth + 1, seen) : undefined
|
||||
const field = ([name, value]: readonly [string, JsonSchema]) =>
|
||||
`${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, 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 name = schema.$ref.split("/").pop()
|
||||
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.*`.
|
||||
*
|
||||
* `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema
|
||||
* document. Effect Schema input is decoded before `run` is invoked, and `run` returns the
|
||||
* encoded representation of an Effect Schema `output`, which CodeMode decodes before returning
|
||||
* it to the program. JSON Schemas only shape the model-visible signature; values pass through
|
||||
* unvalidated. `output` is optional - without it the signature advertises `unknown` and the
|
||||
* host result is exposed as-is. The host tool remains responsible for authorization and
|
||||
* durable side-effect handling.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const lookup = Tool.make({
|
||||
* description: "Look up an order",
|
||||
* input: Schema.Struct({ id: Schema.String }),
|
||||
* output: Schema.Struct({ status: Schema.String }),
|
||||
* run: ({ id }) => Effect.succeed({ status: "open" }),
|
||||
* })
|
||||
*
|
||||
* const fromJsonSchema = Tool.make({
|
||||
* description: "Call an adapter-described tool",
|
||||
* input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
|
||||
* run: (input) => callHost(input),
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const make = <I extends ToolSchema, const O extends ToolSchema | undefined = undefined, R = never>(
|
||||
options: Options<I, O, R>,
|
||||
): Definition<R> => ({
|
||||
_tag: "CodeModeTool",
|
||||
description: options.description,
|
||||
input: options.input,
|
||||
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 }
|
||||
31
packages/codemode/src/values.ts
Normal file
31
packages/codemode/src/values.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { Effect, Fiber } from "effect"
|
||||
|
||||
export class SandboxPromise {
|
||||
interrupted = false
|
||||
constructor(
|
||||
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
|
||||
readonly immediate?: Effect.Effect<unknown, unknown>,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class SandboxDate {
|
||||
constructor(readonly time: number) {}
|
||||
}
|
||||
|
||||
export class SandboxRegExp {
|
||||
readonly regex: RegExp
|
||||
constructor(pattern: string, flags: string) {
|
||||
this.regex = new RegExp(pattern, flags)
|
||||
}
|
||||
}
|
||||
|
||||
export class SandboxMap {
|
||||
readonly map = new Map<unknown, unknown>()
|
||||
}
|
||||
|
||||
export class SandboxSet {
|
||||
readonly set = new Set<unknown>()
|
||||
}
|
||||
|
||||
export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
|
||||
value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet
|
||||
Loading…
Add table
Add a link
Reference in a new issue