refactor(codemode): split the interpreter into a per-execution Runtime and per-call Frame (#48082)

This commit is contained in:
Aiden Cline
2026-09-09 00:43:48 -05:00
committed by GitHub
Unverified
parent d55d941f3c
commit c0aa963c13
2 changed files with 54 additions and 52 deletions
+2 -3
View File
@@ -9,7 +9,7 @@ import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { InterpreterRuntimeError } from "./model.js"
import { PromiseRuntime } from "./promises.js"
import { Interpreter } from "./runtime.js"
import { Runtime } from "./runtime.js"
export const executeProgram = <R>(
code: string,
@@ -39,8 +39,7 @@ export const executeProgram = <R>(
Effect.gen(function* () {
const program = parseProgram(code)
const promises = new PromiseRuntime<R>(scope)
const interpreter = new Interpreter<R>(tools.execute, tools.search, tools.keys, promises, logs)
const value = yield* interpreter.run(program)
const value = yield* new Runtime<R>(tools.execute, tools.search, tools.keys, promises, logs).run(program)
const result = toData(value, "Execution result", "result") as DataValue
returned = { value: result, promises }
const warnings = yield* promises.interrupt()
+52 -49
View File
@@ -197,43 +197,45 @@ type GeneratorState = {
const promiseResolutionNode: AstNode = { type: "PromiseResolution", start: 0, end: 0 }
export class Interpreter<R> {
private scopes: ScopeStack
private readonly executeTool: (
path: ReadonlyArray<string>,
args: Array<unknown>,
) => Effect.Effect<unknown, unknown, R>
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
private readonly logs: Array<string>
private readonly promises: PromiseRuntime<R>
private generatorState?: GeneratorState
private generatorAsync = false
private readonly runner: Runner<R> = {
invokeFunction: (fn, args) => this.invokeFunction(fn, args),
invokeCallable: (callable, args, node) => this.invokeCallable(callable, args, node),
settlePromise: (promise) => this.settlePromise(promise),
syncIterator: (value, node) => this.syncIterator(value, node),
}
/** One program execution: the tool bridge, promise scheduler, captured logs, and the global scope built once. */
export class Runtime<R> {
readonly runner: Runner<R>
private readonly root: Frame<R>
constructor(
executeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
promises: PromiseRuntime<R>,
logs: Array<string> = [],
readonly executeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
readonly promises: PromiseRuntime<R>,
readonly logs: Array<string> = [],
) {
const globalScope = new Map<string, Binding>()
this.scopes = new ScopeStack([globalScope])
this.executeTool = executeTool
this.invokeSearch = invokeSearch
this.toolKeys = toolKeys
this.logs = logs
this.promises = promises
const host = { runner: this.runner, promises, search: invokeSearch, toolKeys, logs }
for (const [name, value] of globals(host)) globalScope.set(name, { mutable: false, value })
// Calling back into the program never reads frame state, so any frame serves; the root is always alive.
this.root = new Frame(this, new ScopeStack([globalScope]))
this.runner = {
invokeFunction: (fn, args) => this.root.invokeFunction(fn, args),
invokeCallable: (callable, args, node) => this.root.invokeCallable(callable, args, node),
settlePromise: (promise) => this.root.settlePromise(promise),
syncIterator: (value, node) => this.root.syncIterator(value, node),
}
for (const [name, value] of globals(this)) globalScope.set(name, { mutable: false, value })
}
run(program: Program): Effect.Effect<unknown, unknown, R> {
return this.root.run(program)
}
}
/** One activation: the top-level program or a single function call, evaluating against its own scope chain. */
class Frame<R> {
private generatorState?: GeneratorState
private generatorAsync = false
constructor(
private readonly runtime: Runtime<R>,
private scopes: ScopeStack,
) {}
run(program: Program): Effect.Effect<unknown, unknown, R> {
const self = this
// Keep top-level declarations separate so they can shadow builtins.
@@ -260,7 +262,7 @@ export class Interpreter<R> {
}
// The implicit async body adopts returned promises before copy-out.
value = yield* resolvePromiseValue(self.runner, value, program)
value = yield* resolvePromiseValue(self.runtime.runner, value, program)
return value
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
}
@@ -270,16 +272,16 @@ export class Interpreter<R> {
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<Values.Promise, never, R> {
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
return this.createPromise(Effect.suspend(() => this.runtime.executeTool(path, args)))
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
return this.promises.create(effect)
return this.runtime.promises.create(effect)
}
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
private settlePromise(promise: Values.Promise): Effect.Effect<unknown, unknown, never> {
const promises = this.promises
settlePromise(promise: Values.Promise): Effect.Effect<unknown, unknown, never> {
const promises = this.runtime.promises
return Effect.suspend(() => {
promises.markObserved(promise)
return Effect.flatMap(promises.await(promise), (exit) => Effect.andThen(Effect.yieldNow, exit))
@@ -652,7 +654,7 @@ export class Interpreter<R> {
}
private awaitValue(value: unknown, node: AstNode = promiseResolutionNode): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(resolvePromise(this.runner, this.promises, value, node), (promise) =>
return Effect.flatMap(resolvePromise(this.runtime.runner, this.runtime.promises, value, node), (promise) =>
this.settlePromise(promise),
)
}
@@ -674,7 +676,7 @@ export class Interpreter<R> {
})
}
private syncIterator(value: unknown, node: AstNode) {
syncIterator(value: unknown, node: AstNode) {
const iterator = Array.isArray(value)
? value[Symbol.iterator]()
: typeof value === "string"
@@ -824,7 +826,7 @@ export class Interpreter<R> {
private enumerableKeys(value: unknown): Array<string> | undefined {
if (value instanceof ToolReference) {
return [...this.toolKeys(value.path)]
return [...this.runtime.toolKeys(value.path)]
}
if (Array.isArray(value)) {
return Object.keys(value)
@@ -1524,7 +1526,7 @@ export class Interpreter<R> {
}
// The single dispatch for every invocation: call expressions and callbacks share it.
private invokeCallable(
invokeCallable(
callable: unknown,
args: Array<unknown>,
node: AstNode,
@@ -1547,10 +1549,10 @@ export class Interpreter<R> {
return callable.generator.asynchronous ? yield* self.createPromise(requested) : yield* requested
}
if (callable instanceof IntrinsicReference) {
return yield* invokeIntrinsic(self.runner, callable, args, node)
return yield* invokeIntrinsic(self.runtime.runner, callable, args, node)
}
if (callable instanceof PromiseInstanceMethodReference) {
return yield* invokePromiseInstanceMethod(self.runner, self.promises, callable, args, node)
return yield* invokePromiseInstanceMethod(self.runtime.runner, self.runtime.promises, callable, args, node)
}
if (callable instanceof HostFunction) return yield* (callable as HostFunction<R>).call(args, node)
if (callable === undefined || callable === null) {
@@ -1589,9 +1591,8 @@ export class Interpreter<R> {
})
}
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
const invocation = new Interpreter(this.executeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs)
invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()])
invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
const invocation = new Frame(this.runtime, new ScopeStack([...fn.capturedScopes, new Map()]))
const run = Effect.gen(function* () {
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
const paramScope = invocation.scopes.current()
@@ -1620,7 +1621,9 @@ export class Interpreter<R> {
// The initial yield assigns the promise before the body can self-resolve.
const box: { promise?: Values.Promise } = {}
return Effect.map(
this.createPromise(Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runner, value, fn.body, box))),
this.createPromise(
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, box)),
),
(promise) => {
box.promise = promise
return promise
@@ -1629,7 +1632,7 @@ export class Interpreter<R> {
}
private createGenerator(
invocation: Interpreter<R>,
invocation: Frame<R>,
run: Effect.Effect<unknown, unknown, R>,
asynchronous: boolean,
): CodeModeGenerator {
@@ -1648,7 +1651,7 @@ export class Interpreter<R> {
if (state.draining) return Deferred.await(request.response)
state.draining = true
return Effect.andThen(
this.promises.fork(
this.runtime.promises.fork(
invocation
.completeGeneratorRequests(state, true)
.pipe(Effect.ensuring(Effect.sync(() => (state.draining = false)))),
@@ -1699,7 +1702,7 @@ export class Interpreter<R> {
yield* invocation.completeGeneratorRequests(state, asynchronous)
state.completed = true
})
return Effect.andThen(this.promises.fork(body), Deferred.await(request.response))
return Effect.andThen(this.runtime.promises.fork(body), Deferred.await(request.response))
}
return Deferred.await(request.response)
})