Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3c28008de | |||
| b2f5c07fd1 | |||
| 7dc72f5cc4 | |||
| cd64a45512 | |||
| 326dd7e8e7 |
@@ -130,6 +130,8 @@ type Result = Success | Failure
|
||||
interface Success {
|
||||
readonly ok: true
|
||||
readonly value: CodeMode.DataValue
|
||||
readonly unhandledRejections?: ReadonlyArray<CodeMode.Diagnostic>
|
||||
readonly unhandledRejectionsTruncated?: boolean
|
||||
readonly logs?: ReadonlyArray<string>
|
||||
readonly truncated?: boolean
|
||||
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
|
||||
@@ -144,7 +146,7 @@ interface Failure {
|
||||
}
|
||||
```
|
||||
|
||||
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. `truncated` is present when the value or logs were cut to fit `maxOutputBytes` (see Execution Limits).
|
||||
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `unhandledRejections`: normalized failures from promises the program started but never observed. The program's returned value remains successful, matching JavaScript's separation between an async function's result and floating promise rejections. `unhandledRejectionsTruncated` marks omitted rejection details, while `truncated` marks any result, rejection, or log truncation caused by `maxOutputBytes` (see Execution Limits).
|
||||
|
||||
### Tool-call hooks
|
||||
|
||||
@@ -246,7 +248,7 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports:
|
||||
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout.
|
||||
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
|
||||
- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention.
|
||||
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
|
||||
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on an execution-owned fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` leaves losing calls running. At most 8 tool calls run concurrently. Before successful completion, CodeMode awaits still-running promises without marking their rejections handled and returns every unobserved ordinary rejection in `Success.unhandledRejections`, in promise-creation order. A fatal program failure instead cancels outstanding work; timeout and host interruption do the same.
|
||||
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
|
||||
|
||||
Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
|
||||
@@ -263,7 +265,7 @@ The limits are exactly three knobs:
|
||||
| ---------------- | -------------------: | -------------------------------------------------------------------- |
|
||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||
| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. |
|
||||
| `maxOutputBytes` | none - no truncation | Retained payload: result value, unhandled rejections, and logs. |
|
||||
|
||||
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
|
||||
|
||||
@@ -281,7 +283,9 @@ const runtime = CodeMode.make({
|
||||
|
||||
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
|
||||
|
||||
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`.
|
||||
`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value, retained rejection diagnostics, and retained log lines. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number.
|
||||
|
||||
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, leading unhandled rejections and logs are kept within the remaining budget, omitted entries receive a summary marker, and the result carries `truncated: true`.
|
||||
|
||||
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded.
|
||||
|
||||
|
||||
@@ -62,13 +62,19 @@ path lookup, namespace browsing, deterministic ranking, and pagination.
|
||||
|
||||
### Tool execution
|
||||
|
||||
Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be
|
||||
awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently.
|
||||
Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic.
|
||||
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. Before normal completion, CodeMode drains all active promises without marking them observed,
|
||||
then returns every unobserved ordinary rejection in `Success.unhandledRejections` while preserving the program value.
|
||||
A fatal program failure, timeout, or host interruption closes the execution promise scope and interrupts its active
|
||||
fibers instead. At most eight tool calls execute concurrently.
|
||||
|
||||
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
|
||||
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
|
||||
concurrency and data nesting depth.
|
||||
concurrency and data nesting depth. `maxOutputBytes` bounds retained payload bytes, not the complete rendered message;
|
||||
fixed truncation notices and host-added framing are intentionally outside the budget.
|
||||
|
||||
### Data, files, and failures
|
||||
|
||||
@@ -131,7 +137,7 @@ represent accurately rather than guessing semantics.
|
||||
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
|
||||
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
|
||||
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
|
||||
| Start tool promises eagerly and supervise them. | This preserves normal call-time parallelism while giving each call run-once settlement and interruption safety. |
|
||||
| Own eager promises for the whole execution. | This preserves normal call-time parallelism and run-once settlement without tying promise lifetime to a transient async function. |
|
||||
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
|
||||
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
|
||||
| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ExecutionLimits = {
|
||||
readonly timeoutMs?: number
|
||||
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
|
||||
readonly maxToolCalls?: number
|
||||
/** Maximum UTF-8 bytes of model-facing output. No default: absent means no truncation. */
|
||||
/** Maximum UTF-8 bytes retained from output payloads. Fixed truncation notices and host formatting are additional. */
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ const ToolCallSchema = Schema.Struct({ name: Schema.String })
|
||||
export const Success = Schema.Struct({
|
||||
ok: Schema.Literal(true),
|
||||
value: Schema.Json,
|
||||
unhandledRejections: Schema.optionalKey(Schema.Array(Diagnostic)),
|
||||
unhandledRejectionsTruncated: Schema.optionalKey(Schema.Boolean),
|
||||
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
truncated: Schema.optionalKey(Schema.Boolean),
|
||||
toolCalls: Schema.Array(ToolCallSchema),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parse } from "acorn"
|
||||
import { Cause, Effect, Exit, Fiber, Semaphore } from "effect"
|
||||
import { Cause, Effect, Exit, Fiber, Scope, Semaphore } from "effect"
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
import {
|
||||
copyIn,
|
||||
@@ -219,7 +219,7 @@ const normalizeError = (error: unknown): Diagnostic => {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers.
|
||||
// Shared by catch bindings and Promise.allSettled rejection reasons.
|
||||
const caughtErrorValue = (thrown: unknown): unknown => {
|
||||
if (thrown instanceof ProgramThrow) return thrown.value
|
||||
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
|
||||
@@ -611,6 +611,54 @@ const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<s
|
||||
return out
|
||||
}
|
||||
|
||||
// Promise work has execution lifetime, while observation only controls whether a settled
|
||||
// rejection is reported. Awaiting work during final draining must not conflate the two.
|
||||
class PromiseRuntime<R> {
|
||||
private readonly active = new Set<SandboxPromise>()
|
||||
private readonly ids = new WeakMap<SandboxPromise, number>()
|
||||
private readonly observed = new WeakSet<SandboxPromise>()
|
||||
private readonly failures = new Map<number, Diagnostic>()
|
||||
private nextID = 0
|
||||
|
||||
constructor(private readonly scope: Scope.Scope) {}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||
const id = this.nextID++
|
||||
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
|
||||
const promise = new SandboxPromise(fiber)
|
||||
this.active.add(promise)
|
||||
this.ids.set(promise, id)
|
||||
fiber.addObserver((exit) => {
|
||||
this.active.delete(promise)
|
||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) || this.observed.has(promise)) {
|
||||
this.ids.delete(promise)
|
||||
return
|
||||
}
|
||||
this.failures.set(id, normalizeError(Cause.squash(exit.cause)))
|
||||
})
|
||||
return promise
|
||||
})
|
||||
}
|
||||
|
||||
observe(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
this.observed.add(promise)
|
||||
const id = this.ids.get(promise)
|
||||
this.ids.delete(promise)
|
||||
if (id !== undefined) this.failures.delete(id)
|
||||
return Fiber.await(promise.fiber)
|
||||
}
|
||||
|
||||
drain(): Effect.Effect<Array<Diagnostic>> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
while (self.active.size > 0) {
|
||||
for (const promise of [...self.active]) yield* Fiber.await(promise.fiber)
|
||||
}
|
||||
return [...self.failures].sort(([left], [right]) => left - right).map(([, failure]) => failure)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class Interpreter<R> {
|
||||
private scopes: Array<Map<string, Binding>>
|
||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
@@ -620,24 +668,22 @@ class Interpreter<R> {
|
||||
private readonly logs: Array<string>
|
||||
// Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
|
||||
private readonly callPermits: Semaphore.Semaphore
|
||||
// Fiber-backed promises whose settlement no program construct has observed yet. Successful
|
||||
// program completion drains these (like a runtime waiting on in-flight work at exit) and
|
||||
// surfaces a never-awaited failure as an unhandled-rejection diagnostic.
|
||||
private readonly pendingSettlements: Set<SandboxPromise>
|
||||
private readonly promises: PromiseRuntime<R>
|
||||
|
||||
constructor(
|
||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||
promises: PromiseRuntime<R>,
|
||||
logs: Array<string> = [],
|
||||
shared?: { callPermits: Semaphore.Semaphore; pendingSettlements: Set<SandboxPromise> },
|
||||
callPermits: Semaphore.Semaphore = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY),
|
||||
) {
|
||||
const globalScope = new Map<string, Binding>()
|
||||
this.scopes = [globalScope]
|
||||
this.invokeTool = invokeTool
|
||||
this.toolKeys = toolKeys
|
||||
this.logs = logs
|
||||
this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
|
||||
this.pendingSettlements = shared?.pendingSettlements ?? new Set<SandboxPromise>()
|
||||
this.callPermits = callPermits
|
||||
this.promises = promises
|
||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
||||
globalScope.set("undefined", { mutable: false, value: undefined })
|
||||
@@ -703,36 +749,12 @@ class Interpreter<R> {
|
||||
// resolves before crossing the data boundary - `return tools.ns.tool(...)` works
|
||||
// without an explicit await, exactly as in JS.
|
||||
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
|
||||
yield* self.drainPendingSettlements()
|
||||
return value
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
// Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so
|
||||
// their work completes before the execution ends - mirroring a JS runtime waiting on
|
||||
// in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection
|
||||
// diagnostic (interrupted calls, e.g. Promise.race losers, are ignored).
|
||||
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
while (self.pendingSettlements.size > 0) {
|
||||
const promise = self.pendingSettlements.values().next().value
|
||||
if (promise === undefined) break
|
||||
const exit = yield* self.observePromise(promise)
|
||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
|
||||
const failure = normalizeError(Cause.squash(exit.cause))
|
||||
throw new InterpreterRuntimeError(
|
||||
`Unhandled rejection from an un-awaited promise: ${failure.message}`,
|
||||
undefined,
|
||||
failure.kind,
|
||||
["Await promises so failures can be caught and handled."],
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Eagerly starts a tool call on a supervised child fiber (so the execution timeout and
|
||||
// scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a
|
||||
// Eagerly starts a tool call in the execution's promise scope (so timeout and teardown
|
||||
// interrupt it) gated by the concurrency semaphore, and wraps the fiber in a
|
||||
// first-class promise value. `startImmediately` makes the runtime admit the call - charging
|
||||
// the tool-call budget and firing onToolCallStart - at the call site, before any await.
|
||||
private createToolCallPromise(
|
||||
@@ -743,45 +765,28 @@ class Interpreter<R> {
|
||||
}
|
||||
|
||||
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<SandboxPromise, never, R> {
|
||||
return Effect.map(Effect.forkChild(effect, { startImmediately: true }), (fiber) => {
|
||||
const promise = new SandboxPromise(fiber)
|
||||
this.pendingSettlements.add(promise)
|
||||
return promise
|
||||
})
|
||||
return this.promises.create(effect)
|
||||
}
|
||||
|
||||
// The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking.
|
||||
// Fiber settlement is idempotent, so observing the same promise repeatedly (await twice,
|
||||
// Promise.all([p, p])) never re-runs the underlying call.
|
||||
private observePromise(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
this.pendingSettlements.delete(promise)
|
||||
return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void)
|
||||
return this.promises.observe(promise)
|
||||
}
|
||||
|
||||
// `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.
|
||||
private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect<unknown, unknown, never> {
|
||||
const self = this
|
||||
return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node))
|
||||
return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(exit, node))
|
||||
}
|
||||
|
||||
private unwrapPromiseExit(
|
||||
promise: SandboxPromise | undefined,
|
||||
exit: Exit.Exit<unknown, unknown>,
|
||||
node?: AstNode,
|
||||
): Effect.Effect<unknown, unknown> {
|
||||
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
|
||||
// A call Promise.race interrupted after losing settles as a catchable program failure;
|
||||
// any other interruption is execution teardown (timeout/host) and must keep propagating
|
||||
// as interruption rather than becoming program-visible data.
|
||||
if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) {
|
||||
return Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Effect.failCause(exit.cause)
|
||||
}
|
||||
|
||||
@@ -2212,19 +2217,21 @@ class Interpreter<R> {
|
||||
// Promise.resolve of a promise is that promise (JS flattens); anything else is a
|
||||
// promise already fulfilled with the value.
|
||||
const value = args[0]
|
||||
return Effect.succeed(
|
||||
value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)),
|
||||
)
|
||||
return value instanceof SandboxPromise ? Effect.succeed(value) : this.createPromise(Effect.succeed(value))
|
||||
}
|
||||
if (ref.name === "reject") {
|
||||
return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0]))))
|
||||
return this.createPromise(Effect.fail(new ProgramThrow(args[0])))
|
||||
}
|
||||
|
||||
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
|
||||
if (items === undefined) {
|
||||
throw 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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2235,10 +2242,10 @@ class Interpreter<R> {
|
||||
// preserve input order when they all fulfill. Rejected calls keep draining siblings.
|
||||
const observations = items.map((item, index) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit }))
|
||||
: Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }),
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
|
||||
: Effect.succeed({ index, exit: Exit.succeed(item) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const aggregate = Effect.gen(function* () {
|
||||
const remaining = [...observations]
|
||||
const values: Array<unknown> = []
|
||||
values.length = items.length
|
||||
@@ -2250,62 +2257,53 @@ class Interpreter<R> {
|
||||
values[winner.index] = winner.exit.value
|
||||
continue
|
||||
}
|
||||
yield* self.createPromise(
|
||||
Effect.asVoid(
|
||||
Effect.forEach(
|
||||
items,
|
||||
(item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* self.unwrapPromiseExit(winner.item, winner.exit, node)
|
||||
return yield* self.unwrapPromiseExit(winner.exit, node)
|
||||
}
|
||||
return values
|
||||
})
|
||||
return this.createPromise(aggregate)
|
||||
}
|
||||
case "allSettled": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit }))
|
||||
: Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }),
|
||||
? this.observePromise(item)
|
||||
: Effect.succeed(Exit.succeed(item as unknown)),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const outcomes: Array<unknown> = []
|
||||
for (const observation of observations) {
|
||||
const { exit, promise } = yield* observation
|
||||
if (Exit.isSuccess(exit)) {
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
|
||||
if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) {
|
||||
// Execution teardown (timeout/host interruption), not a program-level rejection.
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}
|
||||
const thrown = raceInterrupted
|
||||
? new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
return this.createPromise(
|
||||
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 }),
|
||||
)
|
||||
: Cause.squash(exit.cause)
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, {
|
||||
status: "rejected",
|
||||
reason: caughtErrorValue(thrown),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return outcomes
|
||||
})
|
||||
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
|
||||
}),
|
||||
)
|
||||
}
|
||||
case "race": {
|
||||
if (items.length === 0) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
return this.createPromise(
|
||||
Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
const observations = items.map((item, index) =>
|
||||
@@ -2313,31 +2311,20 @@ class Interpreter<R> {
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
|
||||
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
|
||||
// racing them yields exactly that. Losing in-flight calls are then interrupted.
|
||||
const winner = yield* Effect.raceAll(observations)
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
|
||||
item.interrupted = true
|
||||
yield* Fiber.interrupt(item.fiber)
|
||||
}
|
||||
const winningItem = items[winner.index]
|
||||
return yield* self.unwrapPromiseExit(
|
||||
winningItem instanceof SandboxPromise ? winningItem : undefined,
|
||||
winner.exit,
|
||||
node,
|
||||
)
|
||||
})
|
||||
return this.createPromise(
|
||||
Effect.gen(function* () {
|
||||
// First settlement (fulfilled OR rejected) wins; losing work remains owned by the
|
||||
// execution scope and is drained before normal completion.
|
||||
const winner = yield* Effect.raceAll(observations)
|
||||
return yield* self.unwrapPromiseExit(winner.exit, node)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.logs, {
|
||||
callPermits: this.callPermits,
|
||||
pendingSettlements: this.pendingSettlements,
|
||||
})
|
||||
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits)
|
||||
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
||||
const run = Effect.gen(function* () {
|
||||
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
||||
@@ -3505,18 +3492,28 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
})
|
||||
}
|
||||
|
||||
const operation = Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
||||
return {
|
||||
ok: true,
|
||||
value: result,
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result
|
||||
}).pipe((program) => {
|
||||
const operation = Effect.acquireUseRelease(
|
||||
Scope.make("parallel"),
|
||||
(scope) =>
|
||||
Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const promises = new PromiseRuntime<Services<Tools>>(scope)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, promises, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
// Validate the result before draining so an invalid value is a fatal completion that
|
||||
// closes the promise scope instead of waiting for unrelated work.
|
||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
||||
const unhandledRejections = yield* promises.drain()
|
||||
return {
|
||||
ok: true,
|
||||
value: result,
|
||||
...(unhandledRejections.length > 0 ? { unhandledRejections } : {}),
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result
|
||||
}),
|
||||
(scope, exit) => Scope.close(scope, exit),
|
||||
).pipe((program) => {
|
||||
const timeoutMs = limits.timeoutMs
|
||||
if (timeoutMs === undefined) return program
|
||||
return program.pipe(
|
||||
@@ -3560,11 +3557,11 @@ const utf8Truncate = (value: string, maxBytes: number): string => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`.
|
||||
* Oversized values are replaced by their truncated serialized text with an explanatory marker,
|
||||
* and logs are kept from the start until the remaining budget is exhausted. Truncation never
|
||||
* fails the execution; `truncated: true` marks affected results. Only runs when the host set
|
||||
* `maxOutputBytes` - with the limit absent, output passes through unbounded.
|
||||
* Bounds retained payload bytes (serialized result value, rejection diagnostics, and logs) to
|
||||
* `maxOutputBytes`. Fixed truncation notices are added outside that payload budget, as is any
|
||||
* framing added when a host renders the structured result. Truncation never fails the execution;
|
||||
* `truncated: true` marks affected results. Only runs when the host set `maxOutputBytes` - with
|
||||
* the limit absent, output passes through unbounded.
|
||||
*/
|
||||
const boundOutput = (result: Result, maxOutputBytes: number): Result => {
|
||||
let truncated = false
|
||||
@@ -3584,9 +3581,23 @@ const boundOutput = (result: Result, maxOutputBytes: number): Result => {
|
||||
}
|
||||
}
|
||||
|
||||
const rejections = result.ok ? (result.unhandledRejections ?? []) : []
|
||||
const keptRejections: Array<Diagnostic> = []
|
||||
const rejectionBudget = Math.max(0, maxOutputBytes - valueBytes)
|
||||
let rejectionBytes = 0
|
||||
for (const rejection of rejections) {
|
||||
const bytes = utf8ByteLength(JSON.stringify(rejection)) + 1
|
||||
if (rejectionBytes + bytes > rejectionBudget) break
|
||||
rejectionBytes += bytes
|
||||
keptRejections.push(rejection)
|
||||
}
|
||||
if (keptRejections.length < rejections.length) {
|
||||
truncated = true
|
||||
}
|
||||
|
||||
const logs = result.logs ?? []
|
||||
const kept: Array<string> = []
|
||||
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
|
||||
const logBudget = Math.max(0, maxOutputBytes - valueBytes - rejectionBytes)
|
||||
let logBytes = 0
|
||||
for (const line of logs) {
|
||||
const lineBytes = utf8ByteLength(line) + 1
|
||||
@@ -3600,8 +3611,19 @@ const boundOutput = (result: Result, maxOutputBytes: number): Result => {
|
||||
}
|
||||
|
||||
if (!truncated) return result
|
||||
const rejectionsPart = keptRejections.length > 0 ? { unhandledRejections: keptRejections } : {}
|
||||
const rejectionsTruncatedPart =
|
||||
keptRejections.length < rejections.length ? { unhandledRejectionsTruncated: true as const } : {}
|
||||
const logsPart = kept.length > 0 ? { logs: kept } : {}
|
||||
return result.ok
|
||||
? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||
? {
|
||||
ok: true,
|
||||
value,
|
||||
...rejectionsPart,
|
||||
...rejectionsTruncatedPart,
|
||||
...logsPart,
|
||||
truncated: true,
|
||||
toolCalls: result.toolCalls,
|
||||
}
|
||||
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { Effect, Fiber } from "effect"
|
||||
import type { Fiber } from "effect"
|
||||
|
||||
export class SandboxPromise {
|
||||
interrupted = false
|
||||
constructor(
|
||||
readonly fiber: Fiber.Fiber<unknown, unknown> | undefined,
|
||||
readonly immediate?: Effect.Effect<unknown, unknown>,
|
||||
) {}
|
||||
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
|
||||
}
|
||||
|
||||
export class SandboxDate {
|
||||
|
||||
@@ -48,6 +48,13 @@ const failingTool = Tool.make({
|
||||
run: () => Effect.fail(toolError("Lookup refused")),
|
||||
})
|
||||
|
||||
const interruptedTool = Tool.make({
|
||||
description: "Interrupt this call",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
run: () => Effect.interrupt,
|
||||
})
|
||||
|
||||
const completedTool = (trace: Trace) =>
|
||||
Tool.make({
|
||||
description: "Return the number of completed sleepy calls",
|
||||
@@ -63,7 +70,14 @@ const run = (
|
||||
const trace = options.trace ?? makeTrace()
|
||||
return Effect.runPromise(
|
||||
CodeMode.execute({
|
||||
tools: { host: { sleepy: sleepyTool(trace), fail: failingTool, completed: completedTool(trace) } },
|
||||
tools: {
|
||||
host: {
|
||||
sleepy: sleepyTool(trace),
|
||||
fail: failingTool,
|
||||
interrupt: interruptedTool,
|
||||
completed: completedTool(trace),
|
||||
},
|
||||
},
|
||||
code,
|
||||
...(options.limits ? { limits: options.limits } : {}),
|
||||
}),
|
||||
@@ -174,8 +188,7 @@ describe("first-class promise values", () => {
|
||||
})
|
||||
|
||||
test("an awaited failure is catchable exactly like a synchronous throw", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const result = await run(`
|
||||
const p = tools.host.fail({})
|
||||
try {
|
||||
await p
|
||||
@@ -183,8 +196,11 @@ describe("first-class promise values", () => {
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("Lookup refused")
|
||||
expect(result.unhandledRejections).toBeUndefined()
|
||||
})
|
||||
|
||||
test("a fire-and-forget call completes before the execution ends", async () => {
|
||||
@@ -201,30 +217,47 @@ describe("first-class promise values", () => {
|
||||
expect(trace.interrupted).toBe(0)
|
||||
})
|
||||
|
||||
test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => {
|
||||
const diagnostic = await error(`
|
||||
test("a never-awaited failing call preserves the result and reports the rejection", async () => {
|
||||
const result = await run(`
|
||||
tools.host.fail({})
|
||||
return "done"
|
||||
`)
|
||||
expect(diagnostic.kind).toBe("ToolFailure")
|
||||
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
|
||||
expect(diagnostic.message).toContain("Lookup refused")
|
||||
expect(diagnostic.suggestions?.join(" ")).toContain("Await promises")
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.unhandledRejections).toStrictEqual([{ kind: "ToolFailure", message: "Lookup refused" }])
|
||||
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
|
||||
})
|
||||
|
||||
test("a never-awaited failing async function surfaces as an unhandled promise rejection", async () => {
|
||||
const diagnostic = await error(`
|
||||
test("a never-awaited failing async function is reported with a successful result", async () => {
|
||||
const result = await run(`
|
||||
const fail = async () => { throw new Error("boom") }
|
||||
fail()
|
||||
return "done"
|
||||
`)
|
||||
expect(diagnostic.kind).toBe("ExecutionFailure")
|
||||
expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise")
|
||||
expect(diagnostic.message).toContain("boom")
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.unhandledRejections).toStrictEqual([{ kind: "ExecutionFailure", message: "Uncaught: boom" }])
|
||||
})
|
||||
|
||||
test("drains promises started by an async function after an await", async () => {
|
||||
const diagnostic = await error(`
|
||||
test("output truncation bounds unhandled rejection diagnostics", async () => {
|
||||
const result = await run(
|
||||
`
|
||||
for (let i = 0; i < 100; i += 1) Promise.reject(new Error("x".repeat(1_000)))
|
||||
return "done"
|
||||
`,
|
||||
{ limits: { maxOutputBytes: 64 } },
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.unhandledRejections).toBeUndefined()
|
||||
expect(result.unhandledRejectionsTruncated).toBe(true)
|
||||
})
|
||||
|
||||
test("drains and reports promises started by an async function after an await", async () => {
|
||||
const result = await run(`
|
||||
const run = async () => {
|
||||
await tools.host.sleepy({ id: 1 })
|
||||
tools.host.fail({})
|
||||
@@ -232,8 +265,92 @@ describe("first-class promise values", () => {
|
||||
run()
|
||||
return "done"
|
||||
`)
|
||||
expect(diagnostic.kind).toBe("ToolFailure")
|
||||
expect(diagnostic.message).toContain("Lookup refused")
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.unhandledRejections).toStrictEqual([{ kind: "ToolFailure", message: "Lookup refused" }])
|
||||
})
|
||||
|
||||
test("reports every unhandled rejection in promise creation order", async () => {
|
||||
const result = await run(`
|
||||
Promise.reject(new Error("first"))
|
||||
tools.host.fail({})
|
||||
Promise.reject(new Error("third"))
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.unhandledRejections).toStrictEqual([
|
||||
{ kind: "ExecutionFailure", message: "Uncaught: first" },
|
||||
{ kind: "ToolFailure", message: "Lookup refused" },
|
||||
{ kind: "ExecutionFailure", message: "Uncaught: third" },
|
||||
])
|
||||
})
|
||||
|
||||
test("orders an async function rejection before promises created inside its body", async () => {
|
||||
const result = await run(`
|
||||
const outer = async () => {
|
||||
Promise.reject(new Error("inner"))
|
||||
throw new Error("outer")
|
||||
}
|
||||
outer()
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.unhandledRejections).toStrictEqual([
|
||||
{ kind: "ExecutionFailure", message: "Uncaught: outer" },
|
||||
{ kind: "ExecutionFailure", message: "Uncaught: inner" },
|
||||
])
|
||||
})
|
||||
|
||||
test("un-awaited interruptions settle without becoming rejections", async () => {
|
||||
const result = await run(`
|
||||
tools.host.interrupt({})
|
||||
Promise.all([tools.host.interrupt({})])
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.unhandledRejections).toBeUndefined()
|
||||
})
|
||||
|
||||
test("a fatal program error cancels outstanding work without reporting unhandled rejections", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(
|
||||
`
|
||||
tools.host.sleepy({ id: 1, ms: 1_000 })
|
||||
throw new Error("boom")
|
||||
`,
|
||||
{ trace },
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.error.message).toBe("Uncaught: boom")
|
||||
expect("unhandledRejections" in result).toBe(false)
|
||||
expect(trace.completed).toBe(0)
|
||||
expect(trace.interrupted).toBe(1)
|
||||
})
|
||||
|
||||
test("async-function promises remain owned by the execution after the function returns", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(
|
||||
await value(
|
||||
`
|
||||
const launch = async () => {
|
||||
tools.host.sleepy({ id: 1, ms: 40 })
|
||||
Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
|
||||
return "returned"
|
||||
}
|
||||
return await launch()
|
||||
`,
|
||||
{ trace },
|
||||
),
|
||||
).toBe("returned")
|
||||
expect(trace.starts).toEqual([1, 2])
|
||||
expect(trace.completed).toBe(2)
|
||||
expect(trace.interrupted).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -251,6 +368,22 @@ describe("promises at data boundaries", () => {
|
||||
expect(diagnostic.message).toContain("un-awaited Promise")
|
||||
})
|
||||
|
||||
test("invalid returned data cancels pending work before final draining", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(
|
||||
`
|
||||
const pending = tools.host.sleepy({ id: 1, ms: 60_000 })
|
||||
return { pending }
|
||||
`,
|
||||
{ trace, limits: { timeoutMs: 100 } },
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.error.kind).toBe("InvalidDataValue")
|
||||
expect(trace.completed).toBe(0)
|
||||
expect(trace.interrupted).toBe(1)
|
||||
})
|
||||
|
||||
test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => {
|
||||
const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`)
|
||||
expect(diagnostic.kind).toBe("InvalidDataValue")
|
||||
@@ -270,6 +403,59 @@ describe("promises at data boundaries", () => {
|
||||
})
|
||||
|
||||
describe("Promise.all over arbitrary arrays", () => {
|
||||
test("combinators return promises that can be assigned and awaited later", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const all = Promise.all([Promise.resolve(1)])
|
||||
const settled = Promise.allSettled([Promise.reject("no")])
|
||||
const race = Promise.race([Promise.resolve(2)])
|
||||
const promises = [all instanceof Promise, settled instanceof Promise, race instanceof Promise]
|
||||
return [promises, await all, await settled, await race]
|
||||
`),
|
||||
).toEqual([[true, true, true], [1], [{ status: "rejected", reason: "no" }], 2])
|
||||
})
|
||||
|
||||
test("separately-created aggregate batches overlap before either is awaited", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(
|
||||
await value(
|
||||
`
|
||||
const first = Promise.all([tools.host.sleepy({ id: 1, ms: 40 })])
|
||||
const second = Promise.all([tools.host.sleepy({ id: 2, ms: 40 })])
|
||||
return [await first, await second]
|
||||
`,
|
||||
{ trace },
|
||||
),
|
||||
).toEqual([[1], [2]])
|
||||
expect(trace.starts).toEqual([1, 2])
|
||||
expect(trace.maxActive).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("an aggregate created before a try block rejects at its later await", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const aggregate = Promise.all([tools.host.fail({})])
|
||||
try {
|
||||
await aggregate
|
||||
return "no"
|
||||
} catch (error) {
|
||||
return error.message
|
||||
}
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
})
|
||||
|
||||
test("awaiting an aggregate repeatedly does not rerun its members", async () => {
|
||||
const result = await run(`
|
||||
const aggregate = Promise.all([tools.host.sleepy({ id: 7 })])
|
||||
return [await aggregate, await aggregate]
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toEqual([[7], [7]])
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }])
|
||||
})
|
||||
|
||||
test("mixes promises and plain values, preserving order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
@@ -340,16 +526,18 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
})
|
||||
|
||||
test("rejects with the first failure, catchable in-program", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const result = await run(`
|
||||
try {
|
||||
await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})])
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e.message
|
||||
}
|
||||
`),
|
||||
).toBe("Lookup refused")
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("Lookup refused")
|
||||
expect(result.unhandledRejections).toBeUndefined()
|
||||
})
|
||||
|
||||
test("rejects before an earlier slow promise fulfills", async () => {
|
||||
@@ -374,6 +562,30 @@ describe("Promise.all over arbitrary arrays", () => {
|
||||
expect(trace.interrupted).toBe(0)
|
||||
})
|
||||
|
||||
test("drains a later sibling rejection after failing fast", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(
|
||||
await value(
|
||||
`
|
||||
const failLater = async () => {
|
||||
await tools.host.sleepy({ id: 1, ms: 40 })
|
||||
throw new Error("later")
|
||||
}
|
||||
const aggregate = Promise.all([Promise.reject(new Error("first")), failLater()])
|
||||
try {
|
||||
await aggregate
|
||||
return "no"
|
||||
} catch (error) {
|
||||
return error.message
|
||||
}
|
||||
`,
|
||||
{ trace },
|
||||
),
|
||||
).toBe("first")
|
||||
expect(trace.completed).toBe(1)
|
||||
expect(trace.interrupted).toBe(0)
|
||||
})
|
||||
|
||||
test("a non-collection argument is a clear error", async () => {
|
||||
const diagnostic = await error(`return await Promise.all(42)`)
|
||||
expect(diagnostic.message).toContain("Promise.all expects an array")
|
||||
@@ -413,50 +625,62 @@ describe("Promise.allSettled", () => {
|
||||
return settled.filter((s) => s.status === "rejected").length
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.value).toBe(2)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe(2)
|
||||
expect(result.unhandledRejections).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Promise.race", () => {
|
||||
test("first settlement wins and losers are interrupted", async () => {
|
||||
test("first settlement wins and a direct loser completes during final draining", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await value(
|
||||
`
|
||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
||||
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
|
||||
const slow = tools.host.sleepy({ id: 2, ms: 40 })
|
||||
return await Promise.race([fast, slow])
|
||||
`,
|
||||
{ trace },
|
||||
)
|
||||
expect(result).toBe(1)
|
||||
expect(trace.interrupted).toBe(1)
|
||||
expect(trace.completed).toBe(1)
|
||||
expect(trace.completed).toBe(2)
|
||||
expect(trace.interrupted).toBe(0)
|
||||
})
|
||||
|
||||
test("awaiting an interrupted loser afterwards is a catchable program failure", async () => {
|
||||
test("a direct loser remains awaitable after the race settles", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const fast = tools.host.sleepy({ id: 1, ms: 10 })
|
||||
const slow = tools.host.sleepy({ id: 2, ms: 5000 })
|
||||
const slow = tools.host.sleepy({ id: 2, ms: 40 })
|
||||
const winner = await Promise.race([fast, slow])
|
||||
try {
|
||||
await slow
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return { winner, caught: e.message }
|
||||
}
|
||||
return { winner, loser: await slow }
|
||||
`),
|
||||
).toEqual({
|
||||
winner: 1,
|
||||
caught: "This tool call was interrupted because another value settled a Promise.race first.",
|
||||
})
|
||||
).toEqual({ winner: 1, loser: 2 })
|
||||
})
|
||||
|
||||
test("a nested aggregate loser and its members complete during final draining", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(
|
||||
await value(
|
||||
`
|
||||
const nested = Promise.all([
|
||||
tools.host.sleepy({ id: 1, ms: 40 }),
|
||||
tools.host.sleepy({ id: 2, ms: 40 }),
|
||||
])
|
||||
return await Promise.race(["immediate", nested])
|
||||
`,
|
||||
{ trace },
|
||||
),
|
||||
).toBe("immediate")
|
||||
expect(trace.completed).toBe(2)
|
||||
expect(trace.interrupted).toBe(0)
|
||||
})
|
||||
|
||||
test("a rejection can win the race", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
try {
|
||||
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })])
|
||||
await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 40 })])
|
||||
return "no"
|
||||
} catch (e) {
|
||||
return e.message
|
||||
@@ -468,9 +692,18 @@ describe("Promise.race", () => {
|
||||
test("a plain value wins over pending promises", async () => {
|
||||
const trace = makeTrace()
|
||||
expect(
|
||||
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace }),
|
||||
await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 40 }), "immediate"])`, { trace }),
|
||||
).toBe("immediate")
|
||||
expect(trace.interrupted).toBe(1)
|
||||
expect(trace.completed).toBe(1)
|
||||
expect(trace.interrupted).toBe(0)
|
||||
})
|
||||
|
||||
test("a rejected race loser is observed by the aggregate", async () => {
|
||||
const result = await run(`return await Promise.race(["winner", tools.host.fail({})])`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("winner")
|
||||
expect(result.unhandledRejections).toBeUndefined()
|
||||
})
|
||||
|
||||
test("an empty race is a clear error instead of hanging", async () => {
|
||||
@@ -484,6 +717,9 @@ describe("Promise.resolve / Promise.reject", () => {
|
||||
expect(await value(`return await Promise.resolve(42)`)).toBe(42)
|
||||
expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested")
|
||||
expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3)
|
||||
expect(await value(`const promise = Promise.resolve(1); return [promise].includes(Promise.resolve(promise))`)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test("reject produces a promise whose await throws the reason", async () => {
|
||||
@@ -498,6 +734,32 @@ describe("Promise.resolve / Promise.reject", () => {
|
||||
`),
|
||||
).toBe("nope")
|
||||
})
|
||||
|
||||
test("a rejection observed after settlement is handled", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const rejected = Promise.reject(new Error("handled"))
|
||||
await tools.host.sleepy({ id: 1 })
|
||||
try {
|
||||
await rejected
|
||||
return "no"
|
||||
} catch (error) {
|
||||
return error.message
|
||||
}
|
||||
`),
|
||||
).toBe("handled")
|
||||
})
|
||||
|
||||
test("an abandoned rejected promise is reported as unhandled", async () => {
|
||||
const result = await run(`
|
||||
Promise.reject(new Error("abandoned"))
|
||||
return "done"
|
||||
`)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).toBe("done")
|
||||
expect(result.unhandledRejections).toStrictEqual([{ kind: "ExecutionFailure", message: "Uncaught: abandoned" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("timeout interruption of forked calls", () => {
|
||||
@@ -531,6 +793,20 @@ describe("timeout interruption of forked calls", () => {
|
||||
expect(result.error.kind).toBe("TimeoutExceeded")
|
||||
expect(trace.interrupted).toBe(2)
|
||||
})
|
||||
|
||||
test("a non-settling race loser times out and is interrupted once", async () => {
|
||||
const trace = makeTrace()
|
||||
const result = await run(`return await Promise.race(["winner", tools.host.sleepy({ id: 1, ms: 60000 })])`, {
|
||||
trace,
|
||||
limits: { timeoutMs: 100 },
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.error.kind).toBe("TimeoutExceeded")
|
||||
expect(trace.starts).toEqual([1])
|
||||
expect(trace.completed).toBe(0)
|
||||
expect(trace.interrupted).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("unsupported promise surface", () => {
|
||||
|
||||
@@ -176,9 +176,15 @@ function formatResult(result: CodeMode.Result) {
|
||||
: [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))]
|
||||
.join("\n")
|
||||
.trim()
|
||||
if (!result.logs || result.logs.length === 0) return output
|
||||
const logs = `Logs:\n${result.logs.join("\n")}`
|
||||
return output === "" ? logs : `${output}\n\n${logs}`
|
||||
const rejections =
|
||||
result.ok && ((result.unhandledRejections?.length ?? 0) > 0 || result.unhandledRejectionsTruncated)
|
||||
? `Unhandled promise rejections:\n${[
|
||||
...(result.unhandledRejections ?? []).map((item) => `- [${item.kind}] ${item.message}`),
|
||||
...(result.unhandledRejectionsTruncated ? ["- Additional rejections omitted by the output limit."] : []),
|
||||
].join("\n")}\n\nAwait or explicitly settle all started promises.`
|
||||
: undefined
|
||||
const logs = result.logs && result.logs.length > 0 ? `Logs:\n${result.logs.join("\n")}` : undefined
|
||||
return [output, rejections, logs].filter((part) => part !== undefined && part !== "").join("\n\n")
|
||||
}
|
||||
|
||||
function formatValue(value: CodeMode.DataValue) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { ExecuteTool } from "@opencode-ai/core/tool/execute"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
test("execute preserves successful results with visible unhandled rejections", async () => {
|
||||
const child = Tool.make({
|
||||
description: "Always fail",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.fail(new Tool.Failure({ message: "Lookup refused" })),
|
||||
})
|
||||
const registration: ExecuteTool.Registration = { identity: {}, tool: child, name: "fail" }
|
||||
const execute = ExecuteTool.create({
|
||||
registrations: new Map([["fail", registration]]),
|
||||
current: () => registration,
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
Tool.settle(
|
||||
execute,
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_execute",
|
||||
name: "execute",
|
||||
input: { code: `tools.fail({}); return "done"` },
|
||||
},
|
||||
{
|
||||
sessionID: Session.ID.make("ses_execute"),
|
||||
agent: Agent.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_execute"),
|
||||
toolCallID: "call_execute",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.structured).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] })
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
"done",
|
||||
"",
|
||||
"Unhandled promise rejections:",
|
||||
"- [ToolFailure] Lookup refused",
|
||||
"",
|
||||
"Await or explicitly settle all started promises.",
|
||||
].join("\n"),
|
||||
},
|
||||
])
|
||||
})
|
||||
Reference in New Issue
Block a user