diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 2cc67fe36a..771d672943 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -62,7 +62,7 @@ const result = `result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. -Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well. +Successful result values are JSON-safe data. An explicit `return` produces the program result; when it is omitted, the final executable top-level expression is returned as a model-friendly REPL convenience. Otherwise reaching the end produces `null`. Returned `undefined` and nested `undefined` values are normalized to `null` as well. ## API diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 08d47cd37e..e26538550c 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -30,7 +30,6 @@ export type Binding = { export type StatementResult = | { kind: "none" } - | { kind: "value"; value: unknown } | { kind: "return"; value: unknown } | { kind: "break" } | { kind: "continue" } diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 2825e744b5..bdee13b2a8 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -606,7 +606,6 @@ class Interpreter { // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array - private lastValue: unknown // 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 @@ -625,7 +624,6 @@ class Interpreter { this.invokeTool = invokeTool this.toolKeys = toolKeys this.logs = logs - this.lastValue = undefined this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) this.pendingSettlements = shared?.pendingSettlements ?? new Set() globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) @@ -671,13 +669,15 @@ class Interpreter { return Effect.gen(function* () { self.hoistFunctions(program.body) let value: unknown = undefined - let returned = false - for (const statement of program.body) { + for (const [index, statement] of program.body.entries()) { + if (index === program.body.length - 1 && statement.type === "ExpressionStatement") { + value = yield* self.evaluateExpression(getNode(statement, "expression")) + break + } const result = yield* self.evaluateStatement(statement) if (result.kind === "return") { value = result.value - returned = true break } @@ -685,11 +685,7 @@ class Interpreter { throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) } - if (result.kind === "value") { - self.lastValue = result.value - } } - if (!returned) value = self.lastValue // The program body runs inside an implicit async function, so a returned promise // resolves before crossing the data boundary - `return tools.ns.tool(...)` works @@ -780,7 +776,7 @@ class Interpreter { private evaluateStatement(node: AstNode): Effect.Effect { switch (node.type) { case "ExpressionStatement": - return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) + return Effect.as(this.evaluateExpression(getNode(node, "expression")), { kind: "none" }) case "VariableDeclaration": return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) case "ReturnStatement": { @@ -833,11 +829,6 @@ class Interpreter { const statement = asNode(statementValue, "body") const result = yield* self.evaluateStatement(statement) - if (result.kind === "value") { - self.lastValue = result.value - continue - } - if (result.kind !== "none") { return result } @@ -929,7 +920,6 @@ class Interpreter { const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) if (result.kind === "break") return { kind: "none" } satisfies StatementResult if (result.kind === "return" || result.kind === "continue") return result - if (result.kind === "value") self.lastValue = result.value } } return { kind: "none" } satisfies StatementResult @@ -957,9 +947,6 @@ class Interpreter { return result } - if (result.kind === "value") { - self.lastValue = result.value - } } return { kind: "none" } satisfies StatementResult @@ -987,9 +974,6 @@ class Interpreter { return result } - if (result.kind === "value") { - self.lastValue = result.value - } } while (yield* self.evaluateExpression(testNode)) return { kind: "none" } satisfies StatementResult @@ -1045,10 +1029,6 @@ class Interpreter { return { kind: "none" } satisfies StatementResult } - if (result.kind === "value") { - self.lastValue = result.value - } - if (iterationScope) { const loopScope = self.currentScope() for (const name of perIterationBindings) { @@ -1133,10 +1113,6 @@ class Interpreter { return { kind: "none" } } - if (result.kind === "value") { - self.lastValue = result.value - } - if (result.kind === "continue") { continue } @@ -1226,10 +1202,6 @@ class Interpreter { return { kind: "none" } } - if (result.kind === "value") { - self.lastValue = result.value - } - if (result.kind === "continue") { continue } @@ -2372,7 +2344,7 @@ class Interpreter { if (fn.body.type === "BlockStatement") { const result = yield* invocation.evaluateStatement(fn.body) - return result.kind === "return" || result.kind === "value" ? result.value : undefined + return result.kind === "return" ? result.value : undefined } return yield* invocation.evaluateExpression(fn.body) diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 221b5e07df..1113b44683 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -1081,6 +1081,24 @@ describe("CodeMode public contract", () => { expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) }) + test("returns the final top-level expression when return is omitted", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `1; 2` })) + + expect(result).toStrictEqual({ ok: true, value: 2, toolCalls: [] }) + }) + + test("does not implicitly return expressions nested in control flow", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `if (true) { 2 }` })) + + expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] }) + }) + + test("returns null when the final top-level statement is not an expression", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `1; const value = 2` })) + + expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] }) + }) + test("rejects invalid configuration and discovery limits", async () => { expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(