feat(codemode): support promise chaining with .then/.catch/.finally (#36304)

This commit is contained in:
Aiden Cline 2026-07-10 18:22:04 -05:00 committed by GitHub
commit 8e76adb08f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 486 additions and 214 deletions

View file

@ -1,11 +1,6 @@
import { Effect, Schema } from "effect"
import { executeWithLimits } from "./interpreter/runtime.js"
import {
type HostTools,
type Services,
type ToolDescription,
ToolRuntime,
} from "./tool-runtime.js"
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import type { Definition } from "./tool.js"
/** A tool call admitted during an execution. */
@ -130,11 +125,7 @@ export type Runtime<R = never> = {
readonly execute: (code: string) => Effect.Effect<Result, never, R>
}
const validateLimit = <Value extends number | undefined>(
name: keyof ExecutionLimits,
value: Value,
minimum: number,
): Value => {
const validateLimit = (name: keyof ExecutionLimits, value: number | undefined, minimum: number): number | undefined => {
if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) {
throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`)
}

View file

@ -1,5 +1,5 @@
import type { SafeObject } from "../tool-runtime.js"
import type { SandboxURL } from "../values.js"
import type { SandboxPromise, SandboxURL } from "../values.js"
export type SourcePosition = {
line: number
@ -67,6 +67,15 @@ export class PromiseMethodReference {
constructor(readonly name: PromiseMethodName) {}
}
export type PromiseInstanceMethodName = "then" | "catch" | "finally"
export class PromiseInstanceMethodReference {
constructor(
readonly promise: SandboxPromise,
readonly name: PromiseInstanceMethodName,
) {}
}
export type GlobalNamespaceName =
| "Object"
| "Math"
@ -122,11 +131,11 @@ export type DiagnosticKind =
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export const supportedSyntaxMessage =
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls, and promise chaining with .then/.catch/.finally."
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
errorName: string = "Error"
errorName = "Error"
constructor(
message: string,

View file

@ -31,7 +31,6 @@ import {
ErrorConstructorReference,
GlobalMethodReference,
GlobalNamespace,
type GlobalNamespaceName,
formatLocation,
getArray,
getBoolean,
@ -43,6 +42,7 @@ import {
isRecord,
type MemberReference,
OptionalShortCircuit,
PromiseInstanceMethodReference,
PromiseMethodReference,
type PromiseMethodName,
PromiseNamespace,
@ -56,7 +56,7 @@ import {
} from "./model.js"
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
import { consoleMethods, MAX_CONSOLE_DEPTH } from "../stdlib/console.js"
import { dateMethods, dateStatics, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { dateMethods, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { invokeJsonMethod } from "../stdlib/json.js"
import { invokeMathMethod, mathConstants } from "../stdlib/math.js"
import {
@ -82,7 +82,6 @@ import {
urlMethods,
urlProperties,
urlSearchParamsMethods,
urlStatics,
urlWritableProperties,
invokeUriFunction,
invokeURLMethod,
@ -219,6 +218,30 @@ const normalizeError = (error: unknown): Diagnostic => {
}
}
// V8 parity: a combinator settles one reaction turn after the deciding member, never
// before reactions already attached to it.
const settleAfterTurn = <A, E, R>(body: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit))
const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node).as("TypeError")
type ReactionHandler = CodeModeFunction | CoercionFunction | UriFunction
// Non-callables are ignored as in JS: `.then(undefined, f)` relies on the passthrough.
const reactionHandler = (value: unknown, method: string, node: AstNode): ReactionHandler | undefined => {
if (value instanceof CodeModeFunction || value instanceof CoercionFunction || value instanceof UriFunction) {
return value
}
if (typeofValue(value) === "function") {
throw new InterpreterRuntimeError(
`${method} handlers must be plain functions; wrap other callables in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
return undefined
}
// Shared by catch bindings and Promise.allSettled rejection reasons.
const caughtErrorValue = (thrown: unknown): unknown => {
if (thrown instanceof ProgramThrow) return thrown.value
@ -235,6 +258,7 @@ const isRuntimeReference = (value: unknown): boolean =>
value instanceof GlobalMethodReference ||
value instanceof PromiseNamespace ||
value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference ||
value instanceof SandboxPromise ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
@ -279,6 +303,7 @@ const typeofValue = (value: unknown): string => {
value instanceof IntrinsicReference ||
value instanceof GlobalMethodReference ||
value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference ||
value instanceof PromiseNamespace ||
value instanceof ErrorConstructorReference
)
@ -391,7 +416,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
}
if (args[0] instanceof SandboxRegExp) {
result = value.split((args[0] as SandboxRegExp).regex, optNum(1))
result = value.split(args[0].regex, optNum(1))
break
}
const requestedLimit = optNum(1)
@ -419,7 +444,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
case "replace":
case "replaceAll": {
if (args[0] instanceof SandboxRegExp) {
const pattern = (args[0] as SandboxRegExp).regex
const pattern = args[0].regex
const replacement = str(1)
if (name === "replaceAll" && !pattern.global) {
throw new InterpreterRuntimeError(
@ -524,9 +549,8 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
)
}
// Map/Set materialize directly (the data checkpoint would serialize them to {}).
if (args[0] instanceof SandboxMap)
return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item])
if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values())
if (args[0] instanceof SandboxMap) return Array.from(args[0].map.entries(), ([key, item]) => [key, item])
if (args[0] instanceof SandboxSet) return Array.from(args[0].set.values())
if (args[0] instanceof SandboxURLSearchParams) {
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
}
@ -568,11 +592,7 @@ const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, no
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
if (ref.namespace === "Date") {
if (!dateStatics.has(ref.name))
throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
return invokeDateStatic(ref.name, args, node)
}
if (ref.namespace === "Date") return invokeDateStatic(ref.name, args, node)
if (
ref.namespace === "RegExp" ||
ref.namespace === "Map" ||
@ -768,7 +788,6 @@ class Interpreter<R> {
if (result.kind === "break" || result.kind === "continue") {
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
}
}
// The program body runs inside an implicit async function, so a returned promise
@ -794,16 +813,14 @@ class Interpreter<R> {
return this.promises.create(effect)
}
// `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch
// observes it exactly like a synchronous throw at the await site. Settlement is idempotent
// (fiber exits replay), so awaiting the same promise repeatedly never re-runs the call.
// Settlement is idempotent (fiber exits replay), so awaiting the same promise repeatedly
// never re-runs the call. The post-settlement yield defers the continuation one reaction
// turn, as in JS: awaiters never resume inline, so they interleave in attach order.
private settlePromise(promise: SandboxPromise): Effect.Effect<unknown, unknown, never> {
const promises = this.promises
return Effect.suspend(() => {
promises.markObserved(promise)
return Effect.flatMap(promises.await(promise), (exit) =>
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
)
return Effect.flatMap(promises.await(promise), (exit) => Effect.andThen(Effect.yieldNow, exit))
})
}
@ -980,7 +997,6 @@ class Interpreter<R> {
if (result.kind === "return") {
return result
}
}
return { kind: "none" } satisfies StatementResult
@ -1007,7 +1023,6 @@ class Interpreter<R> {
if (result.kind === "return") {
return result
}
} while (yield* self.evaluateExpression(testNode))
return { kind: "none" } satisfies StatementResult
@ -1037,16 +1052,13 @@ class Interpreter<R> {
: []
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
let iterationScope: Map<string, Binding> | undefined
if (perIterationBindings.length > 0) {
iterationScope = new Map(
perIterationBindings.map((name) => {
const binding = self.currentScope().get(name)!
return [name, { ...binding }]
}),
)
self.scopes.push(iterationScope)
}
const iterationScope =
perIterationBindings.length > 0
? new Map(
perIterationBindings.map((name): [string, Binding] => [name, { ...self.currentScope().get(name)! }]),
)
: undefined
if (iterationScope) self.scopes.push(iterationScope)
const result = yield* self.evaluateStatement(bodyNode).pipe(
Effect.ensuring(
Effect.sync(() => {
@ -1096,7 +1108,7 @@ class Interpreter<R> {
// Arrays iterate in place; strings iterate code points; Maps iterate [key, value]
// pairs and Sets iterate values over a snapshot (mutation during iteration is safe).
const iterable = Array.isArray(right) ? right : spreadItems(right)
const iterable = spreadItems(right)
if (iterable === undefined) {
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
}
@ -1550,11 +1562,10 @@ class Interpreter<R> {
case "UpdateExpression":
return this.evaluateUpdateExpression(node)
case "AwaitExpression": {
// `await` resolves a promise value; awaiting anything else is a passthrough no-op,
// matching real JS semantics for non-thenables.
// In JS every await suspends, even on a non-promise.
const self = this
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
value instanceof SandboxPromise ? self.settlePromise(value) : Effect.succeed(value),
value instanceof SandboxPromise ? self.settlePromise(value) : Effect.as(Effect.yieldNow, value),
)
}
case "NewExpression":
@ -1923,7 +1934,7 @@ class Interpreter<R> {
return self.setIdentifierValue(name, next, left)
}
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
if (operator === "=") return self.setIdentifierValue(name, rightValue, left)
return self.setIdentifierValue(name, rightValue, left)
}
if (left.type === "MemberExpression") {
return yield* self.modifyMember(left, (current) =>
@ -2025,6 +2036,9 @@ class Interpreter<R> {
if (callable instanceof PromiseMethodReference) {
return yield* self.invokePromiseMethod(callable, args, node)
}
if (callable instanceof PromiseInstanceMethodReference) {
return yield* self.invokePromiseInstanceMethod(callable, args, node)
}
if (callable instanceof CodeModeFunction) {
return yield* self.invokeFunction(callable, args)
}
@ -2241,14 +2255,14 @@ class Interpreter<R> {
return this.createPromise(Effect.fail(new ProgramThrow(args[0])))
}
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
const items = spreadItems(args[0])
if (items === undefined) {
return this.createPromise(
Effect.fail(
new InterpreterRuntimeError(
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
node,
),
).as("TypeError"),
),
)
}
@ -2261,47 +2275,42 @@ class Interpreter<R> {
switch (ref.name) {
case "all": {
// Each observation re-raises its member's failure, so Effect.all rejects on the first
// failure without waiting for the rest and preserves input order when all fulfill.
// Its failure-time interruption only unsubscribes the sibling waiters: the underlying
// fibers stay execution-owned and keep running, as in JS.
// Rejects on the first failure; sibling fibers stay execution-owned and keep running, as in JS.
const observations = items.map((item) =>
item instanceof SandboxPromise
? Effect.flatMap(this.promises.await(item), (exit) =>
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
)
: Effect.succeed(item),
item instanceof SandboxPromise ? Effect.flatten(this.promises.await(item)) : Effect.succeed(item),
)
return this.createPromise(Effect.all(observations, { concurrency: "unbounded" }))
return this.createPromise(settleAfterTurn(Effect.all(observations, { concurrency: "unbounded" })))
}
case "allSettled": {
const observations = items.map((item) =>
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)),
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item)),
)
return this.createPromise(
Effect.gen(function* () {
const outcomes: Array<unknown> = []
for (const observation of observations) {
const exit = yield* observation
if (Exit.isSuccess(exit)) {
settleAfterTurn(
Effect.gen(function* () {
const outcomes: Array<unknown> = []
for (const observation of observations) {
const exit = yield* observation
if (Exit.isSuccess(exit)) {
outcomes.push(
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
)
continue
}
if (Cause.hasInterruptsOnly(exit.cause)) {
// Execution teardown (timeout/host interruption), not a program-level rejection.
return yield* Effect.failCause(exit.cause)
}
outcomes.push(
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(Cause.squash(exit.cause)),
}),
)
continue
}
if (Cause.hasInterruptsOnly(exit.cause)) {
// Execution teardown (timeout/host interruption), not a program-level rejection.
return yield* Effect.failCause(exit.cause)
}
outcomes.push(
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(Cause.squash(exit.cause)),
}),
)
}
return outcomes
}),
return outcomes
}),
),
)
}
case "race": {
@ -2316,19 +2325,86 @@ class Interpreter<R> {
)
}
const observations = items.map((item) =>
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item as unknown)),
item instanceof SandboxPromise ? this.promises.await(item) : Effect.succeed(Exit.succeed(item)),
)
// First settlement (fulfilled OR rejected) wins; losing work stays execution-owned
// and is interrupted at normal completion (already observed) or by teardown.
return this.createPromise(
Effect.flatMap(Effect.raceAll(observations), (exit) =>
Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
),
)
return this.createPromise(settleAfterTurn(Effect.flatten(Effect.raceAll(observations))))
}
}
}
// Teardown interruption propagates without running handlers; a real settlement defers
// one reaction turn so handlers never run inline.
private reactionExit(source: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> {
const promises = this.promises
return Effect.gen(function* () {
const exit = yield* promises.await(source)
if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
yield* Effect.yieldNow
return exit
})
}
private invokePromiseInstanceMethod(
ref: PromiseInstanceMethodReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<SandboxPromise, never, R> {
const method = `Promise.prototype.${ref.name}`
this.promises.markObserved(ref.promise)
if (ref.name === "finally") {
return this.chainFinally(ref.promise, reactionHandler(args[0], method, node), method, node)
}
const onFulfilled = ref.name === "then" ? reactionHandler(args[0], method, node) : undefined
const onRejected = reactionHandler(ref.name === "then" ? args[1] : args[0], method, node)
return this.chainReaction(ref.promise, onFulfilled, onRejected, method, node)
}
private chainReaction(
source: SandboxPromise,
onFulfilled: ReactionHandler | undefined,
onRejected: ReactionHandler | undefined,
method: string,
node: AstNode,
): Effect.Effect<SandboxPromise, never, R> {
const self = this
const box: { derived?: SandboxPromise } = {}
const body = Effect.gen(function* () {
const exit = yield* self.reactionExit(source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
if (handler === undefined) return yield* exit
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(Cause.squash(exit.cause))
const result = yield* self.applyCollectionCallback(handler, method, node)([input])
if (result === box.derived) return yield* Effect.fail(selfResolutionError(node))
if (result instanceof SandboxPromise) return yield* self.settlePromise(result)
return result
})
return Effect.map(this.createPromise(body), (derived) => {
box.derived = derived
return derived
})
}
private chainFinally(
source: SandboxPromise,
cleanup: ReactionHandler | undefined,
method: string,
node: AstNode,
): Effect.Effect<SandboxPromise, never, R> {
const self = this
return this.createPromise(
Effect.gen(function* () {
const exit = yield* self.reactionExit(source)
if (cleanup !== undefined) {
const result = yield* self.applyCollectionCallback(cleanup, method, node)([])
if (result instanceof SandboxPromise) yield* self.settlePromise(result)
}
return yield* exit
}),
)
}
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits)
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
@ -2358,10 +2434,20 @@ class Interpreter<R> {
return yield* invocation.evaluateExpression(fn.body)
})
if (!fn.async) return run
return this.createPromise(
Effect.flatMap(run, (value) =>
value instanceof SandboxPromise ? invocation.settlePromise(value) : Effect.succeed(value),
// Every await yields, so `box.own` is assigned before the body can return a reference to it.
const box: { own?: SandboxPromise } = {}
return Effect.map(
this.createPromise(
Effect.flatMap(run, (value) => {
if (!(value instanceof SandboxPromise)) return Effect.succeed(value)
if (value === box.own) return Effect.fail(selfResolutionError())
return invocation.settlePromise(value)
}),
),
(promise) => {
box.own = promise
return promise
},
)
}
@ -2472,8 +2558,8 @@ class Interpreter<R> {
})
}
// Runs a collection callback accepting a user function or supported builtin callable,
// mirroring the array-method callback contract.
// Accepts a user function or supported builtin callable, so idioms such as
// `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS.
private applyCollectionCallback(
callback: unknown,
name: string,
@ -2761,24 +2847,7 @@ class Interpreter<R> {
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
}
const callback = args[0]
if (
!(callback instanceof CodeModeFunction) &&
!(callback instanceof CoercionFunction) &&
!(callback instanceof UriFunction)
) {
throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node)
}
const self = this
// Accept a user function or supported builtin callable, so idioms such as
// `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins
// are synchronous; only CodeModeFunctions can await tool calls.
const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
callback instanceof CoercionFunction
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
: callback instanceof UriFunction
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
: self.invokeFunction(callback, callbackArgs)
const apply = this.applyCollectionCallback(args[0], `Array.${name}`, node)
return Effect.gen(function* () {
// Capture the initial length, but read the receiver live so callbacks observe mutations
// without visiting elements appended after iteration begins.
@ -3079,6 +3148,7 @@ class Interpreter<R> {
| MemberReference
| ToolReference
| PromiseMethodReference
| PromiseInstanceMethodReference
| IntrinsicReference
| GlobalMethodReference
| ComputedValue
@ -3199,20 +3269,13 @@ class Interpreter<R> {
return new ComputedValue(undefined)
}
// Any property access on a promise is a confused program (`p.then(...)`, `p.value`);
// reading `undefined` here would hide the missing await, so both paths get an explicit,
// await-hinting error instead of the forgiving unknown-property fallthrough.
// Error instead of `undefined` so a missing await never hides.
if (objectValue instanceof SandboxPromise) {
if (key === "then" || key === "catch" || key === "finally") {
throw new InterpreterRuntimeError(
`Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`,
propertyNode,
"UnsupportedSyntax",
[supportedSyntaxMessage],
)
return new PromiseInstanceMethodReference(objectValue, key)
}
throw new InterpreterRuntimeError(
"This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.",
"This value is an un-awaited Promise; await it first - e.g. `const result = await tools.ns.tool(...)`.",
objectNode,
"InvalidDataValue",
)
@ -3265,6 +3328,7 @@ class Interpreter<R> {
reference === undefined ||
reference instanceof ToolReference ||
reference instanceof PromiseMethodReference ||
reference instanceof PromiseInstanceMethodReference ||
reference instanceof IntrinsicReference ||
reference instanceof GlobalMethodReference
)
@ -3302,6 +3366,7 @@ class Interpreter<R> {
reference === undefined ||
reference instanceof ToolReference ||
reference instanceof PromiseMethodReference ||
reference instanceof PromiseInstanceMethodReference ||
reference instanceof IntrinsicReference ||
reference instanceof GlobalMethodReference
) {
@ -3489,15 +3554,14 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
// and the timeout path's completed value - binds at run time: a reused Effect must start
// from a clean slate instead of observing a previous run's state.
return Effect.suspend(() => {
const hooks = {
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
}
const tools = ToolRuntime.make(
(options.tools ?? {}) as HostTools<Services<Tools>>,
limits.maxToolCalls,
searchIndex,
hooks,
{
onToolCallStart: options.onToolCallStart,
onToolCallEnd: options.onToolCallEnd,
},
)
const logs: Array<string> = []
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})

View file

@ -46,9 +46,7 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unkno
)
}
if (json && Option.isNone(decoded)) {
return yield* Effect.fail(
toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`),
)
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`))
}
return parsed
})
@ -208,8 +206,9 @@ const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string
return toolError(`Missing required path parameter '${field.inputName}'.`)
}
const fieldValue = serializeSimple(field, item, (value) =>
encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
encodeURIComponent(value).replace(
/[!'()*]/g,
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
),
)
if (fieldValue instanceof ToolError) return fieldValue
@ -271,10 +270,7 @@ const serializeQuery = (
if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
}
return value.reduce(
(current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)),
request,
)
return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request)
}
if (isRecord(value) && field.explode) {
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
@ -289,11 +285,15 @@ const serializeQuery = (
return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
}
const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: Plan): Effect.Effect<string, ToolError> =>
const readResponseBody = (
response: HttpClientResponse.HttpClientResponse,
plan: Plan,
): Effect.Effect<string, ToolError> =>
Effect.gen(function* () {
const contentLength = response.headers["content-length"]
const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
const declaredSize = parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
const declaredSize =
parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
}
@ -304,7 +304,9 @@ const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan:
return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
}
if (size + chunk.byteLength > body.byteLength) {
const grown = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
const grown = Buffer.allocUnsafe(
Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)),
)
body.copy(grown, 0, 0, size)
body = grown
}

View file

@ -78,7 +78,9 @@ const isBinaryMediaType = (document: Document, mediaType: string, value: unknown
return isRecord(schema) && schema.format === "binary"
}
const jsonContent = (content: Record<string, unknown>): { readonly mediaType: string; readonly schema: unknown } | undefined => {
const jsonContent = (
content: Record<string, unknown>,
): { readonly mediaType: string; readonly schema: unknown } | undefined => {
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
}
@ -344,7 +346,7 @@ export const operationOutput = (
if (outcomes.length === 0) return { ok: true, value: undefined }
return {
ok: true,
value: withDefinitions(outcomes.length === 1 ? outcomes[0] ?? {} : { anyOf: outcomes }, definitions),
value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions),
}
}
@ -380,7 +382,9 @@ export const operationPath = (
namespaces: ReadonlySet<string>,
): ReadonlyArray<string> => {
const raw = nonEmptyString(operation.operationId)
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(sanitizeOperationSegment)
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(
sanitizeOperationSegment,
)
if (isOperationPathAvailable(segments, used, namespaces)) return segments
const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
if (conflict >= 0 && conflict + 1 < segments.length) {

View file

@ -23,8 +23,6 @@ export const dateMethods = new Set([
"getTimezoneOffset",
])
export const dateStatics = new Set(["now", "parse", "UTC"])
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
switch (name) {
case "now":

View file

@ -6,10 +6,7 @@ import {
} from "../interpreter/model.js"
import { copyIn, copyOut } from "../tool-runtime.js"
export const jsonStatics = new Set(["stringify", "parse"])
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
switch (name) {
case "stringify": {
const replacer = args[1]

View file

@ -3,11 +3,9 @@ import { isBlockedMember } from "../tool-runtime.js"
import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"])
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
const requireObject = (): Record<string, unknown> => {
const input = args[0]
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
@ -53,13 +51,11 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
}
const out = target as Record<string, unknown>
for (const source of args.slice(1)) {
if (source === null || source === undefined) continue
const value = source
if (isSandboxValue(value)) continue
if (value === null || typeof value !== "object" || Array.isArray(value)) {
if (source === null || source === undefined || isSandboxValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const [key, item] of Object.entries(value)) guardedSet(out, key, item)
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
}
return out
}

View file

@ -60,7 +60,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
return parseFloat(coerceToString(raw))
}
const value = boundedData(args[0], `${ref.name} input`)
const value = boundedData(raw, `${ref.name} input`)
if (ref.name === "Number") return coerceToNumber(value)
if (ref.name === "Boolean") return Boolean(value)
if (ref.name === "parseInt") {
@ -73,11 +73,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
return coerceToString(value)
}
import {
type AstNode,
CoercionFunction,
InterpreterRuntimeError,
} from "../interpreter/model.js"
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
import { copyIn, type SafeObject } from "../tool-runtime.js"
import {
isSandboxValue,

View file

@ -326,15 +326,12 @@ export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
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)) {
): Array<{ path: string; definition: Definition<R> }> =>
Object.entries(tools).flatMap(([name, value]) => {
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
}
if (isDefinition(value)) return [{ path: next.join("."), definition: value }]
return typeof value === "function" ? [] : definitions(value, next)
})
const describeDefinition = <R>(path: string, definition: Definition<R>): ToolDescription => ({
path,
@ -349,9 +346,6 @@ const visibleDefinitions = <R>(tools: HostTools<R>) =>
description: describeDefinition(path, definition),
}))
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
visibleDefinitions(tools).map(({ description }) => description)
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
readonly instructions: string
@ -617,7 +611,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
"## Language",
"",
"Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and new Promise(...) are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
"Prefer explicit `return`; otherwise only the final top-level expression becomes the result.",
"Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
]

View file

@ -23,7 +23,7 @@ const effectNumberSentinel = (schema: JsonSchema) =>
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"
if (concrete.length === 1) return concrete[0]
return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
}