diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index c388cca1bda..5e022c2cda2 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -63,8 +63,10 @@ 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. +awaited directly or through `Promise.all`, `Promise.allSettled`, and `Promise.race`. These combinators also return eager, +run-once sandbox promises, so independent aggregate batches overlap and rejection is observed at the eventual `await`. +`Promise.resolve` and `Promise.reject` use the same tracked lifecycle. At most eight tool calls execute concurrently. +Unfinished promises are drained before successful program completion, and an unhandled failure becomes a diagnostic. 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 diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 7f5d015d18d..9d9a79c13b4 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -2212,19 +2212,21 @@ class Interpreter { // 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, + ), + ), ) } @@ -2238,7 +2240,7 @@ class Interpreter { ? Effect.map(this.observePromise(item), (exit) => ({ index, item, exit })) : Effect.succeed({ index, item: undefined, exit: Exit.succeed(item) }), ) - return Effect.gen(function* () { + const aggregate = Effect.gen(function* () { const remaining = [...observations] const values: Array = [] values.length = items.length @@ -2250,19 +2252,25 @@ class Interpreter { 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 values }) + return Effect.gen(function* () { + const promise = yield* self.createPromise(aggregate) + // Keep observing every member after fail-fast settlement without tying the drain + // fiber to the aggregate fiber, whose completion interrupts its own children. + yield* self.createPromise( + Effect.asVoid( + Effect.forEach( + items, + (item) => (item instanceof SandboxPromise ? self.observePromise(item) : Effect.void), + { concurrency: "unbounded" }, + ), + ), + ) + return promise + }) } case "allSettled": { const observations = items.map((item) => @@ -2270,42 +2278,48 @@ class Interpreter { ? 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) }), ) - return Effect.gen(function* () { - const outcomes: Array = [] - 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 = [] + 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 }), ) - : Cause.squash(exit.cause) - outcomes.push( - Object.assign(Object.create(null) as SafeObject, { - status: "rejected", - reason: caughtErrorValue(thrown), - }), - ) - } - return outcomes - }) + 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, + ) + : Cause.squash(exit.cause) + outcomes.push( + Object.assign(Object.create(null) as SafeObject, { + status: "rejected", + reason: caughtErrorValue(thrown), + }), + ) + } + 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,22 +2327,24 @@ class Interpreter { ? 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; 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, + ) + }), + ) } } } diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index ce5f1e03a49..4574f952548 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -270,6 +270,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(` @@ -374,6 +427,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") @@ -484,6 +561,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 +578,16 @@ describe("Promise.resolve / Promise.reject", () => { `), ).toBe("nope") }) + + test("an abandoned rejected promise is reported as unhandled", async () => { + const diagnostic = await error(` + Promise.reject(new Error("abandoned")) + return "done" + `) + expect(diagnostic.kind).toBe("ExecutionFailure") + expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited promise") + expect(diagnostic.message).toContain("abandoned") + }) }) describe("timeout interruption of forked calls", () => {