Files
deepseek-harness/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts
T

310 lines
16 KiB
TypeScript

/** Optional, unsandboxed Node REPL provider with memory scoped to caller-owned objects. */
import { stripTypeScriptTypes } from 'node:module'
import { isAbsolute } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
import type { PtcRunRequest, PtcRunSpec, PtcRunResult, PtcRunFailure, PtcBindingNamespace, PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime'
import { clampTimeout } from '@deepseek-ai/dsh-timeout'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import { NodeConfig, validateConfig } from './config.ts'
import type { Config, ResolvedConfig } from './config.ts'
import { replyToBinding, validateBindings } from './bindings.ts'
import { JsonChannel } from './channel.ts'
import { bootstrapArgs } from './launch.ts'
import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts'
import { decodePtcJsonWire } from './json-wire.ts'
import { OutputLedger } from './output-ledger.ts'
import { drainOutput, terminateProcess } from './output-stream.ts'
import type { ProgramBootData } from './protocol.ts'
export type { Config } from './config.ts'
const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error)
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
function persistentOutput(maxBytes: number): OutputLedger {
return new OutputLedger(maxBytes, `retained variables were cleared; outer output exceeded ${maxBytes} bytes`)
}
interface Evaluation {
id: number
data: ProgramBootData
spec: PtcRunSpec
bindings: Map<string, PtcBindingNamespace>
pending: number
pendingBytes: number
admit(text: string): void
finish(error?: PtcRunFailure, value?: PtcJsonValue, discard?: boolean): void
}
/** One managed process; all mutable program state belongs to this process alone. */
class Kernel {
private handle?: SubprocessHandle
private channel?: JsonChannel
private active: Evaluation | undefined
private ready = false
private sequence = 0
private nextCall = 1
private stderr = ''
private stopped?: Promise<void>
private starting?: Promise<void>
private readonly controller = new AbortController()
closed = false
constructor(private readonly ctx: Context, private readonly config: ResolvedConfig) {}
assertIdle(): void {
if (this.active) throw new Error('persistent PTC does not accept concurrent runs for one state owner')
}
async run(spec: PtcRunSpec, bindings: Map<string, PtcBindingNamespace>, code: string): Promise<PtcRunResult> {
this.assertIdle()
this.stderr = ''
const output = persistentOutput(this.config.maxOutputBytes)
const logs: string[] = []
const result = Promise.withResolvers<PtcRunResult>()
const timer = spec.timeoutMs === null ? undefined : setTimeout(() => {
evaluation.finish({ kind: 'timeout', message: `execution deadline reached (${spec.timeoutMs}ms); retained variables were cleared` }, undefined, true)
}, spec.timeoutMs)
const onAbort = (): void => { evaluation.finish({ kind: 'abort', message: 'execution canceled; retained variables were cleared' }, undefined, true) }
const evaluation: Evaluation = {
id: ++this.sequence,
data: { code, maxOutputBytes: this.config.maxOutputBytes, namespaces: [...bindings.values()].map(binding => ({
global: binding.global, names: Object.keys(binding.functions), ...binding.errorClass ? { errorClass: binding.errorClass } : {},
})) },
spec, bindings, pending: 0, pendingBytes: 0,
admit: (text) => {
if (!output.admit(text, logs)) {
logs.push(text)
evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true)
}
},
finish: (error, value, discard = false) => {
if (this.active !== evaluation) return
clearTimeout(timer)
spec.signal?.removeEventListener('abort', onAbort)
this.active = undefined
const outcome = error?.kind === 'output-limit' ? output.limit(logs)
: error ? output.failure(logs, error) : output.success(logs, value)
const cleanup = discard || outcome.error?.kind === 'output-limit' || spec.stateOwner === undefined ? this.stop() : Promise.resolve()
void cleanup.then(() => { result.resolve(outcome) }, (failure: unknown) => {
result.resolve(output.failure(logs, { kind: 'worker-exit', message: `managed process cleanup failed: ${messageOf(failure)}` }))
})
},
}
this.active = evaluation
spec.signal?.addEventListener('abort', onAbort, { once: true })
if (spec.signal?.aborted) onAbort()
else if (this.ready) this.dispatch(evaluation)
else {
this.starting = this.start(spec.cwd)
void this.starting.catch((error: unknown) => { this.fail('worker-exit', messageOf(error)) })
}
return await result.promise
}
async dispose(): Promise<void> {
this.active?.finish({ kind: 'abort', message: 'PTC environment disposed; retained variables were cleared' }, undefined, true)
await this.stop()
}
private stop(): Promise<void> {
if (this.stopped) return this.stopped
this.closed = true
this.controller.abort()
this.channel?.close()
this.stopped = (async () => {
await this.starting?.catch(() => {})
const handle = this.handle
if (!handle) return
if (!await terminateProcess(handle, this.config.graceMs)) throw new Error('persistent Node process output did not close cleanly')
})()
return this.stopped
}
private fail(kind: PtcRunFailure['kind'], message: string): void {
if (this.active) this.active.finish({ kind, message: `${message}; retained variables were cleared` }, undefined, true)
else void this.stop().catch((error: unknown) => { this.ctx.logger.warn(messageOf(error)) })
}
private dispatch(evaluation: Evaluation): void {
void this.channel?.send({ type: 'run', id: evaluation.id, cwd: evaluation.spec.cwd, data: evaluation.data })
.catch((error: unknown) => { this.fail('protocol', messageOf(error)) })
}
private async start(cwd: string): Promise<void> {
const executable = await this.ctx.subprocess.resolveExecutable(this.config.nodeExecutable, undefined, this.controller.signal)
if (this.closed) return
const argv = [executable, `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`, ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes, true)]
const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env)
.filter(key => !STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase()) && key.toUpperCase() !== 'ELECTRON_RUN_AS_NODE')
.map(key => [key, undefined]))
const handle = this.ctx.subprocess.spawn({ argv, cwd, env, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', control: 'pipe' }, graceMs: this.config.graceMs })
this.handle = handle
if (!handle.control || !handle.stdout || !handle.stderr) throw new Error('subprocess provider did not supply persistent PTC pipes')
for (const stream of [handle.stdout, handle.stderr]) {
stream.setEncoding('utf8')
stream.on('data', (text: string) => {
if (!this.active) return
if (stream === handle.stderr) this.stderr = (this.stderr + text).slice(-this.config.maxOutputBytes)
this.active.admit(text)
})
stream.on('error', (error: Error) => { this.fail('worker-exit', error.message) })
}
let exited = false
this.channel = new JsonChannel(handle.control, this.config.maxMessageBytes,
(raw, bytes) => { this.receive(raw, bytes) }, (error, kind) => {
if (kind === 'protocol') this.fail('protocol', error.message)
else setImmediate(() => { if (!this.closed && !exited) handle.terminate() })
})
void handle.done.then(async (outcome) => {
exited = true
await Promise.all([drainOutput(handle.stdout, this.config.graceMs), drainOutput(handle.stderr, this.config.graceMs)])
// The terminal control frame can already be queued when process exit arrives.
setImmediate(() => {
if (!this.closed) this.fail('worker-exit', `Node process exited (${String(outcome.exitCode)}, signal ${String(outcome.signal)})${this.stderr ? `: ${this.stderr}` : ''}`)
})
}, (error: unknown) => { this.fail('worker-exit', messageOf(error)) })
}
private receive(raw: unknown, bytes: number): void {
if (!record(raw)) { this.fail('protocol', 'invalid persistent PTC frame'); return }
const evaluation = this.active
if (!evaluation) { this.fail('protocol', 'frame arrived without an active PTC evaluation'); return }
if (!this.ready) {
if (raw.type !== 'ready') { this.fail('protocol', 'expected persistent PTC readiness'); return }
this.ready = true
this.dispatch(evaluation)
return
}
if (raw.id !== evaluation.id || !record(raw.payload)) { this.fail('protocol', 'frame does not belong to the active PTC evaluation'); return }
const payload = raw.payload
switch (payload.type) {
case 'log':
if (typeof payload.text === 'string') evaluation.admit(payload.text)
else this.fail('protocol', 'invalid persistent PTC log')
return
case 'output-limit': evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true); return
case 'done': {
if (payload.error !== undefined) {
const error = payload.error
if (!record(error) || typeof error.message !== 'string' || (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) {
this.fail('protocol', 'invalid persistent PTC failure'); return
}
const failure = { kind: error.kind, message: error.message } as PtcRunFailure
setImmediate(() => { evaluation.finish(failure, undefined, failure.kind === 'output-limit') })
} else {
const value = payload.value === undefined ? undefined : decodePtcJsonWire(payload.value)
const failure: PtcRunFailure | undefined = payload.value !== undefined && value === undefined
? { kind: 'invalid-output', message: 'program completion must be lossless JSON' } : undefined
// Raw stdio may be queued beside the terminal control frame on separate pipes.
setImmediate(() => { evaluation.finish(failure, value) })
}
return
}
case 'call': {
if (payload.id !== this.nextCall || typeof payload.global !== 'string' || typeof payload.name !== 'string') { this.fail('protocol', 'invalid persistent PTC binding identity'); return }
this.nextCall += 1
const args = decodePtcJsonWire(payload.args)
if (args === undefined) { this.fail('protocol', 'binding arguments must be lossless JSON'); return }
if (++evaluation.pending > this.config.maxPendingCalls || (evaluation.pendingBytes += bytes) > this.config.maxMessageBytes) {
this.fail('protocol', 'pending binding calls exceed configured limits'); return
}
const functions = evaluation.bindings.get(payload.global)?.functions
const fn = functions && Object.hasOwn(functions, payload.name) ? functions[payload.name] : undefined
const id = payload.id
void (async () => {
const reply = await replyToBinding(id, fn, args).finally(() => {
evaluation.pending -= 1
evaluation.pendingBytes -= bytes
})
if (this.active === evaluation) await this.channel?.send(reply)
})().catch((error: unknown) => { this.fail('protocol', messageOf(error)) })
return
}
default: this.fail('protocol', 'unknown persistent PTC message')
}
}
}
/** Unsandboxed, best-effort retained state; restart, disposal, and interrupted execution clear it. */
export class PersistentNodePtcRuntime extends PtcRuntime {
static inject = ['fs', 'subprocess']
static Config = NodeConfig
readonly language = 'typescript'
readonly isolation = 'process'
override get programFormat(): 'repl' { return 'repl' }
override get executionInstructions(): string {
return 'Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. This environment has full host access without a file sandbox; process.env starts empty. Restart, Agent disposal, cancellation, timeout, output overflow, or process failure clears the environment. If a variable is undefined, recreate it and reacquire its resources. State is not restored from the conversation, and failed evaluations can leave earlier mutations in memory. Redeclaring an existing let, const, or class name fails; assign to a mutable binding or use a new name. Await all tool calls; background callbacks cannot call tools after their evaluation ends. Await child-process completion before ending an evaluation; raw subprocess output belongs to whichever evaluation is active when it arrives.'
}
override get timeout(): { defaultMs: number; maxMs: number } {
return { defaultMs: Math.min(this.config.timeoutMs, this.config.maxTimeoutMs), maxMs: this.config.maxTimeoutMs }
}
private readonly config: ResolvedConfig
private readonly owners = new WeakMap<object, Kernel>()
private readonly kernels = new Set<Kernel>()
private disposed = false
constructor(ctx: Context, config: Config) {
super(ctx)
this.config = { ...config, nodeExecutable: config.nodeExecutable ?? process.execPath } as ResolvedConfig
validateConfig(this.config)
if ('pkg' in process && this.config.bootstrapPath === undefined) throw new Error('Persistent PTC requires an explicit bootstrapPath in a packaged executable')
ctx.effect(() => async () => {
this.disposed = true
await Promise.all([...this.kernels].map(kernel => kernel.dispose()))
this.kernels.clear()
}, 'Persistent PTC environments')
}
/**
* Resolve full-access REPL execution without accepting a sandbox policy.
* @param request - Program, bindings, owner, directory and elapsed budget.
* @returns Resolved request; explicit confinement requests fail before execution.
*/
resolve(request: PtcRunRequest): PtcRunSpec {
if (this.disposed) throw new Error('persistent PTC: resolve after disposal')
if (request.sandboxPolicy !== undefined) throw new Error('persistent PTC does not support sandbox policies')
const cwd = request.cwd ?? process.cwd()
if (!isAbsolute(cwd)) throw new Error('persistent PTC: cwd must be absolute')
return { ...request, cwd, timeoutMs: request.timeoutMs === null ? null : clampTimeout(request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs, 'persistent PTC timeoutMs') }
}
/**
* Evaluate statements using this owner's process, or a temporary process without an owner.
* @param spec - Resolved request; concurrent calls for one owner are rejected.
* @returns Logs, a final JSON expression, or a structured execution failure.
*/
async run(spec: PtcRunSpec): Promise<PtcRunResult> {
if (this.disposed) throw new Error('persistent PTC: run after disposal')
if (spec.sandboxPolicy !== undefined || !isAbsolute(spec.cwd) || (spec.timeoutMs !== null && (!Number.isFinite(spec.timeoutMs) || spec.timeoutMs <= 0 || spec.timeoutMs > this.config.maxTimeoutMs))) throw new Error('persistent PTC: run requires resolved inputs without a sandbox policy')
const bindings = validateBindings(spec)
let kernel = spec.stateOwner === undefined ? undefined : this.owners.get(spec.stateOwner)
kernel?.assertIdle()
let code: string
try { code = stripTypeScriptTypes(spec.program) } catch (error: unknown) {
const outcome = persistentOutput(this.config.maxOutputBytes).failure([], { kind: 'exception', message: messageOf(error) })
if (outcome.error?.kind === 'output-limit' && spec.stateOwner !== undefined) await this.release(spec.stateOwner)
return outcome
}
if (!kernel || kernel.closed) {
if (kernel) this.kernels.delete(kernel)
kernel = new Kernel(this.ctx, this.config)
this.kernels.add(kernel)
if (spec.stateOwner !== undefined) this.owners.set(spec.stateOwner, kernel)
}
try { return await kernel.run(spec, bindings, code) } finally {
if (spec.stateOwner === undefined) this.kernels.delete(kernel)
}
}
override async release(owner: object): Promise<void> {
const kernel = this.owners.get(owner)
if (!kernel) return
this.owners.delete(owner)
await kernel.dispose()
this.kernels.delete(kernel)
}
}
export default PersistentNodePtcRuntime