mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-13 22:08:30 +00:00
feat(codemode): support promise chaining with .then/.catch/.finally (#36304)
This commit is contained in:
parent
0bb24a46c1
commit
8e76adb08f
16 changed files with 486 additions and 214 deletions
|
|
@ -64,10 +64,13 @@ path lookup, namespace browsing, deterministic ranking, and pagination.
|
|||
### Tool execution
|
||||
|
||||
Every sandbox promise starts eagerly on a run-once fiber owned by the whole CodeMode execution, including tool calls,
|
||||
async functions, `Promise.all`, `Promise.allSettled`, `Promise.race`, `Promise.resolve`, and `Promise.reject`. Nested
|
||||
functions therefore cannot end the lifetime of work they started. Independent aggregate batches overlap, and rejection
|
||||
is observed at the eventual `await`. `Promise.race` uses native non-cancelling settlement semantics: its first result
|
||||
wins while losers continue running. At normal completion CodeMode interrupts everything still running - race losers,
|
||||
async functions, chained `.then`/`.catch`/`.finally` reactions, `Promise.all`, `Promise.allSettled`, `Promise.race`,
|
||||
`Promise.resolve`, and `Promise.reject`. Nested functions therefore cannot end the lifetime of work they started.
|
||||
Independent aggregate batches overlap, and rejection is observed at the eventual `await` or chained rejection handler.
|
||||
`Promise.race` uses native non-cancelling settlement semantics: its first result wins while losers continue running.
|
||||
Reaction ordering matches what V8 makes observable - handlers and await continuations are deferred and run in attach
|
||||
order, and a combinator settles one reaction turn after its deciding member - without promising exact microtask-count
|
||||
parity beyond that. At normal completion CodeMode interrupts everything still running - race losers,
|
||||
fail-fast `Promise.all` stragglers, and fire-and-forget calls alike: the program has returned, so no future await can
|
||||
exist, and work whose completion matters must be awaited by the program. Waiting for any class of leftover instead
|
||||
would let it hold the execution open, or deadlock it when queued work needs tool-call permits the leftovers occupy.
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ ultimate source of truth.
|
|||
- [x] Optional property access and optional calls.
|
||||
- [x] Function/tool calls and spread arguments.
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for sandbox promises; awaiting a plain value is a no-op.
|
||||
- [x] `await` for sandbox promises; a plain value passes through unchanged, though every `await` still defers its
|
||||
continuation one reaction turn.
|
||||
- [x] `new` for Error types, Date, RegExp, Map, Set, URL, and URLSearchParams.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
|
|
@ -116,12 +117,21 @@ ultimate source of truth.
|
|||
- [x] `Promise.race` settles from the first result without cancelling losers at settlement time.
|
||||
- [x] Real promise values from `Promise.all`, `Promise.allSettled`, and `Promise.race`; separately constructed
|
||||
combinator batches overlap as in normal JavaScript.
|
||||
- [x] Promise chaining with `.then`, `.catch`, and `.finally`: handlers run deferred in attach order, returned
|
||||
promises are adopted, handler throws reject the derived promise, `.finally` preserves the original settlement
|
||||
unless its cleanup fails, and direct self-resolution rejects with a `TypeError`.
|
||||
- [x] Every `await` (including of plain values and already-settled promises) defers its continuation one reaction
|
||||
turn, so concurrent async functions interleave at await points as in JavaScript.
|
||||
- [x] Combinators settle one reaction turn after their deciding member (V8-observable ordering): reactions already
|
||||
attached to members run first, and an aggregate cannot beat a plain value settling in the same turn into a
|
||||
`Promise.race`. Exact microtask-count parity beyond this observable ordering is not a documented guarantee.
|
||||
- [x] All still-pending work (race losers, fail-fast `Promise.all` stragglers, and un-awaited calls alike) is
|
||||
interrupted when the program returns; rejections that settled un-awaited become `Success.warnings`
|
||||
diagnostics.
|
||||
diagnostics. A combinator abandoned inside its final settlement turn counts as pending and is interrupted
|
||||
without a warning.
|
||||
- [x] `try`/`catch` can handle awaited tool and promise failures.
|
||||
- [ ] `Promise.any`.
|
||||
- [ ] Promise chaining with `.then`, `.catch`, and `.finally`.
|
||||
- [ ] Thenable assimilation (objects with a `then` method are plain data, not promises).
|
||||
- [ ] Custom promise construction with `new Promise(...)`.
|
||||
- [ ] Async iterables, host streams, and stream consumption.
|
||||
|
||||
|
|
|
|||
|
|
@ -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}.`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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] } : {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 `{}`.",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(" & ")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -672,9 +672,10 @@ describe("CodeMode public contract", () => {
|
|||
expect(instructions).toContain("not a general-purpose runtime")
|
||||
expect(instructions).not.toContain("Standard modern JavaScript works")
|
||||
expect(instructions).not.toContain("TypeScript type annotations")
|
||||
for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) {
|
||||
for (const missing of ["Modules/imports", "classes", "generators", "fetch", "new Promise(...)"]) {
|
||||
expect(instructions).toContain(missing)
|
||||
}
|
||||
expect(instructions).not.toContain("promise chaining")
|
||||
expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
|
||||
expect(instructions).not.toContain("host globals")
|
||||
expect(instructions).toContain("Use Code Mode tools for external operations")
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ import { describe, expect, test } from "bun:test"
|
|||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
|
||||
const execute = (code: string) =>
|
||||
Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } }))
|
||||
const execute = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {}, limits: { timeoutMs: 1_000 } }))
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await execute(code)
|
||||
|
|
@ -450,7 +449,10 @@ describe("Test262 async functions and await", () => {
|
|||
const promises = [declaration(), expression(), arrow()]
|
||||
return [promises.map((item) => item instanceof Promise), await Promise.all(promises)]
|
||||
`),
|
||||
).toEqual([[true, true, true], [1, 2, 3]])
|
||||
).toEqual([
|
||||
[true, true, true],
|
||||
[1, 2, 3],
|
||||
])
|
||||
})
|
||||
|
||||
test("async bodies adopt returns and reject throws before and after await", async () => {
|
||||
|
|
@ -479,13 +481,7 @@ describe("Test262 async functions and await", () => {
|
|||
await observe(throwsAfter()),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
["body"],
|
||||
["fulfilled", 42],
|
||||
["fulfilled", 43],
|
||||
["rejected", 1],
|
||||
["rejected", 2],
|
||||
])
|
||||
).toEqual([["body"], ["fulfilled", 42], ["fulfilled", 43], ["rejected", 1], ["rejected", 2]])
|
||||
})
|
||||
|
||||
test("default-parameter throws reject instead of escaping the call", async () => {
|
||||
|
|
@ -578,7 +574,7 @@ describe("Test262 async functions and await", () => {
|
|||
|
||||
describe("Test262 expected Promise conformance", () => {
|
||||
for (const name of ["all", "allSettled", "race"] as const) {
|
||||
test.failing(`Promise.${name} rejects invalid input with TypeError`, async () => {
|
||||
test(`Promise.${name} rejects invalid input with TypeError`, async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T1.js
|
||||
// test/built-ins/Promise/all/S25.4.4.1_A3.1_T2.js
|
||||
|
|
@ -634,7 +630,7 @@ describe("Test262 expected Promise conformance", () => {
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
test.failing("Promise.all settles after reactions attached to its inputs", async () => {
|
||||
test("Promise.all settles after reactions attached to its inputs", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/all/S25.4.4.1_A7.2_T1.js
|
||||
// test/built-ins/Promise/all/S25.4.4.1_A8.1_T1.js
|
||||
|
|
@ -653,7 +649,7 @@ describe("Test262 expected Promise conformance", () => {
|
|||
).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
test.failing("Promise.allSettled settles after reactions attached to its inputs", async () => {
|
||||
test("Promise.allSettled settles after reactions attached to its inputs", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/allSettled/resolved-sequence.js
|
||||
// test/built-ins/Promise/allSettled/resolved-sequence-extra-ticks.js
|
||||
|
|
@ -674,7 +670,7 @@ describe("Test262 expected Promise conformance", () => {
|
|||
).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
test.failing("Promise.race settles in a reaction after its winning input", async () => {
|
||||
test("Promise.race settles in a reaction after its winning input", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/race/S25.4.4.3_A6.1_T1.js
|
||||
// test/built-ins/Promise/race/resolved-sequence-extra-ticks.js
|
||||
|
|
@ -692,7 +688,7 @@ describe("Test262 expected Promise conformance", () => {
|
|||
).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
test.failing("then reactions route and propagate fulfillment and rejection", async () => {
|
||||
test("then reactions route and propagate fulfillment and rejection", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/then/prfm-fulfilled.js
|
||||
// test/built-ins/Promise/prototype/then/prfm-rejected.js
|
||||
|
|
@ -726,7 +722,7 @@ describe("Test262 expected Promise conformance", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test.failing("then reactions preserve breadth-first queue order", async () => {
|
||||
test("then reactions preserve breadth-first queue order", async () => {
|
||||
// Source: test/built-ins/Promise/prototype/then/S25.4.4_A1.1_T1.js
|
||||
expect(
|
||||
await value(`
|
||||
|
|
@ -741,7 +737,7 @@ describe("Test262 expected Promise conformance", () => {
|
|||
).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
})
|
||||
|
||||
test.failing("then rejects direct self-resolution for fulfilled and rejected sources", async () => {
|
||||
test("then rejects direct self-resolution for fulfilled and rejected sources", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/then/resolve-settled-fulfilled-self.js
|
||||
// test/built-ins/Promise/prototype/then/resolve-settled-rejected-self.js
|
||||
|
|
@ -761,7 +757,7 @@ describe("Test262 expected Promise conformance", () => {
|
|||
).toEqual(["TypeError", "TypeError"])
|
||||
})
|
||||
|
||||
test.failing("catch delegates rejection handling and preserves fulfillment", async () => {
|
||||
test("catch delegates rejection handling and preserves fulfillment", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A2.1_T1.js
|
||||
// test/built-ins/Promise/prototype/catch/S25.4.5.1_A3.1_T1.js
|
||||
|
|
@ -776,7 +772,7 @@ describe("Test262 expected Promise conformance", () => {
|
|||
).toEqual([1, 4])
|
||||
})
|
||||
|
||||
test.failing("finally preserves or replaces the original settlement", async () => {
|
||||
test("finally preserves or replaces the original settlement", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/finally/resolution-value-no-override.js
|
||||
// test/built-ins/Promise/prototype/finally/rejection-reason-no-fulfill.js
|
||||
|
|
@ -799,7 +795,109 @@ describe("Test262 expected Promise conformance", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test.failing("await always resumes in a later reaction and interleaves async functions", async () => {
|
||||
test("then ignores non-callable handlers", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T1.js
|
||||
// test/built-ins/Promise/prototype/then/S25.4.5.3_A4.1_T2.js
|
||||
// test/built-ins/Promise/prototype/then/S25.4.5.3_A5.1_T1.js
|
||||
// test/built-ins/Promise/prototype/then/S25.4.5.3_A5.2_T1.js
|
||||
// (adapted: only non-callable handlers are probed; callables that are not plain
|
||||
// functions, such as tool references, intentionally throw in CodeMode)
|
||||
expect(
|
||||
await value(`
|
||||
const observe = async (promise) => {
|
||||
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||
}
|
||||
return await Promise.all([
|
||||
observe(Promise.resolve(1).then(2)),
|
||||
observe(Promise.resolve(4).then(null, null)),
|
||||
observe(Promise.resolve(5).then({}, "x")),
|
||||
observe(Promise.reject(3).then(null, "x")),
|
||||
observe(Promise.reject(6).then(7, {})),
|
||||
])
|
||||
`),
|
||||
).toEqual([
|
||||
["fulfilled", 1],
|
||||
["fulfilled", 4],
|
||||
["fulfilled", 5],
|
||||
["rejected", 3],
|
||||
["rejected", 6],
|
||||
])
|
||||
})
|
||||
|
||||
test("finally waits for a returned promise and preserves or replaces settlement", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/finally/resolved-observable-then-calls.js
|
||||
// test/built-ins/Promise/prototype/finally/rejected-observable-then-calls.js
|
||||
// test/built-ins/Promise/prototype/finally/resolution-value-no-override.js
|
||||
expect(
|
||||
await value(`
|
||||
const observe = async (promise) => {
|
||||
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||
}
|
||||
const order = []
|
||||
const cleanup = async () => {
|
||||
await Promise.resolve()
|
||||
order.push("cleanup")
|
||||
}
|
||||
const settled = await Promise.resolve("kept").finally(() => cleanup())
|
||||
order.push("settled:" + settled)
|
||||
return [
|
||||
await observe(Promise.resolve(1).finally(() => Promise.resolve(99))),
|
||||
order,
|
||||
await observe(Promise.resolve(2).finally(() => Promise.reject(3))),
|
||||
await observe(Promise.reject(4).finally(() => Promise.resolve(99))),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
["fulfilled", 1],
|
||||
["cleanup", "settled:kept"],
|
||||
["rejected", 3],
|
||||
["rejected", 4],
|
||||
])
|
||||
})
|
||||
|
||||
test("then adopts a returned rejected promise", async () => {
|
||||
// Sources:
|
||||
// test/built-ins/Promise/prototype/then/rxn-handler-fulfilled-return-abrupt.js
|
||||
// test/built-ins/Promise/resolve/resolve-promise.js
|
||||
// (adapted: the fulfillment handler returns an already-rejected promise instead of
|
||||
// throwing, and the rejection handler recovers with a fulfilled promise)
|
||||
expect(
|
||||
await value(`
|
||||
const observe = async (promise) => {
|
||||
try { return ["fulfilled", await promise] } catch (reason) { return ["rejected", reason] }
|
||||
}
|
||||
return await Promise.all([
|
||||
observe(Promise.resolve(1).then(() => Promise.reject("bad"))),
|
||||
observe(Promise.reject(2).then(undefined, () => Promise.resolve("ok"))),
|
||||
])
|
||||
`),
|
||||
).toEqual([
|
||||
["rejected", "bad"],
|
||||
["fulfilled", "ok"],
|
||||
])
|
||||
})
|
||||
|
||||
test("independent reactions on one source each observe the same settlement", async () => {
|
||||
// Source: test/built-ins/Promise/prototype/then/S25.4.4_A2.1_T1.js
|
||||
// (adapted: the multiple-reactions family is asserted through the values every
|
||||
// reaction returns instead of a shared completion counter)
|
||||
expect(
|
||||
await value(`
|
||||
const fulfilled = Promise.resolve(7)
|
||||
const rejected = Promise.reject(8)
|
||||
return await Promise.all([
|
||||
fulfilled.then((value) => "first:" + value),
|
||||
fulfilled.then((value) => "second:" + value),
|
||||
rejected.catch((reason) => "first:" + reason),
|
||||
rejected.catch((reason) => "second:" + reason),
|
||||
])
|
||||
`),
|
||||
).toEqual(["first:7", "second:7", "first:8", "second:8"])
|
||||
})
|
||||
|
||||
test("await always resumes in a later reaction and interleaves async functions", async () => {
|
||||
// Sources:
|
||||
// test/language/expressions/await/async-await-interleaved.js
|
||||
// test/language/expressions/await/await-non-promise.js
|
||||
|
|
@ -814,7 +912,7 @@ describe("Test262 expected Promise conformance", () => {
|
|||
).toEqual(["first:1", "second:1", "first:2", "second:2"])
|
||||
})
|
||||
|
||||
test.failing("an async function rejects when it resolves with its own promise", async () => {
|
||||
test("an async function rejects when it resolves with its own promise", async () => {
|
||||
// Adapted from the self-resolution requirement represented by:
|
||||
// test/built-ins/Promise/resolve-self.js
|
||||
// test/built-ins/Promise/resolve/S25.4.4.5_A4.1_T1.js
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { Effect, Schema } from "effect"
|
|||
import { CodeMode, Tool, toolError } from "../src/index.js"
|
||||
|
||||
// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on
|
||||
// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are
|
||||
// ordinary functions over arbitrary arrays mixing promises and plain values.
|
||||
// supervised fibers, `await` settles them, Promise.all/allSettled/race/resolve/reject are
|
||||
// ordinary functions over arbitrary arrays mixing promises and plain values, and
|
||||
// .then/.catch/.finally chain reactions onto any promise.
|
||||
|
||||
type Trace = {
|
||||
starts: Array<number>
|
||||
|
|
@ -184,7 +185,7 @@ describe("first-class promise values", () => {
|
|||
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
|
||||
})
|
||||
|
||||
test("await of a non-promise value is a passthrough no-op", async () => {
|
||||
test("await of a non-promise value passes it through unchanged", async () => {
|
||||
expect(await value(`return await 42`)).toBe(42)
|
||||
expect(await value(`const x = await "s"; return x`)).toBe("s")
|
||||
expect(await value(`return await null`)).toBeNull()
|
||||
|
|
@ -935,16 +936,124 @@ describe("timeout interruption of forked calls", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("unsupported promise surface", () => {
|
||||
test(".then/.catch/.finally give a clear await-instead error", async () => {
|
||||
for (const method of ["then", "catch", "finally"]) {
|
||||
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`)
|
||||
expect(diagnostic.kind).toBe("UnsupportedSyntax")
|
||||
expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`)
|
||||
expect(diagnostic.message).toContain("await")
|
||||
}
|
||||
describe("promise chaining", () => {
|
||||
test("then transforms tool results and adopts returned promises across a chain", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return await tools.host
|
||||
.sleepy({ id: 2 })
|
||||
.then((id) => tools.host.sleepy({ id: id + 1 }))
|
||||
.then((id) => id * 10)
|
||||
`),
|
||||
).toBe(30)
|
||||
})
|
||||
|
||||
test("handlers are deferred and run in attach order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const order = []
|
||||
const promise = Promise.resolve(1)
|
||||
promise.then(() => order.push("h1"))
|
||||
promise.then(() => order.push("h2"))
|
||||
order.push("sync")
|
||||
await promise
|
||||
return order
|
||||
`),
|
||||
).toEqual(["sync", "h1", "h2"])
|
||||
})
|
||||
|
||||
test("catch recovers a tool failure and preserves fulfillment", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
await tools.host.fail({}).catch((error) => error.message),
|
||||
await tools.host.sleepy({ id: 4 }).catch(() => "unused"),
|
||||
]
|
||||
`),
|
||||
).toEqual(["Lookup refused", 4])
|
||||
})
|
||||
|
||||
test("finally observes settlement without changing the value", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const events = []
|
||||
const result = await tools.host.sleepy({ id: 5 }).finally(() => events.push("cleanup"))
|
||||
return [result, events]
|
||||
`),
|
||||
).toEqual([5, ["cleanup"]])
|
||||
})
|
||||
|
||||
test("a settled, un-awaited rejected chain tail warns exactly once", async () => {
|
||||
const result = await run(`
|
||||
Promise.reject(new Error("boom")).then((value) => value)
|
||||
await Promise.resolve()
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
// The source rejection belongs to the chain (no warning); only the derived tail warns.
|
||||
expect(result.warnings).toStrictEqual([
|
||||
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
|
||||
])
|
||||
})
|
||||
|
||||
test("a catch handler silences the chain's rejection warning", async () => {
|
||||
const result = await run(`
|
||||
Promise.reject(new Error("boom")).catch(() => "handled")
|
||||
await Promise.resolve()
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
test("non-plain-function handlers fail loudly instead of being ignored", async () => {
|
||||
const diagnostic = await error(`return await tools.host.sleepy({ id: 1 }).then(tools.host.completed)`)
|
||||
expect(diagnostic.message).toContain("Promise.prototype.then handlers must be plain functions")
|
||||
})
|
||||
|
||||
test("chaining methods are opaque references until called", async () => {
|
||||
expect(await value(`return typeof tools.host.sleepy({ id: 1 }).then`)).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("combinator settlement timing", () => {
|
||||
test("a combinator settling one reaction turn after the program returns is interrupted silently", async () => {
|
||||
// The aggregate's one-turn settlement delay (V8 parity) means an immediately-returning
|
||||
// program abandons it while still pending: interrupted like any pending work, so no
|
||||
// rejection warning survives - the member itself was observed by the combinator.
|
||||
const result = await run(`
|
||||
Promise.all([Promise.reject(new Error("boom"))])
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.warnings).toBeUndefined()
|
||||
})
|
||||
|
||||
test("a combinator settles one reaction turn after its members, as in V8", async () => {
|
||||
// Regression for the race winner flip: Promise.all's settlement burns a reaction turn,
|
||||
// so a plain resolved value entered in the same race wins, and a fail-fast aggregate
|
||||
// cannot beat it into rejection.
|
||||
expect(
|
||||
await value(`
|
||||
const pending = tools.host.sleepy({ id: 9, ms: 60000 })
|
||||
const winner = await Promise.race([Promise.all([Promise.resolve(1)]), Promise.resolve(2)])
|
||||
try {
|
||||
const raced = await Promise.race([Promise.all([Promise.reject("x"), pending]), Promise.resolve("ok")])
|
||||
return [winner, "fulfilled", raced]
|
||||
} catch (reason) {
|
||||
return [winner, "rejected", reason]
|
||||
}
|
||||
`),
|
||||
).toEqual([2, "fulfilled", "ok"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("unsupported promise surface", () => {
|
||||
test("other property reads on a promise hint at the missing await", async () => {
|
||||
const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`)
|
||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue