Merge persistent PTC review fixes into dsh-cua

This commit is contained in:
creatixchu committed 2026-09-24 23:08:20 +08:00
commit a73eed85f1
25 files changed
+354 -125

No files matched your search

+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 2d92f05a645dec93af6a1af699430abf311bc36a
config-catalog.zh.md: 2b546589472f2ed2394d6719ff4f34c7f9902296
config-catalog.md: 4579d8dff0c73f1434721ab9f1499ad103b2950e
config-catalog.zh.md: 2dbb0c1d1343e607a3e4788d390b3f76ae3b7191
+1 -1
View File
@@ -2280,7 +2280,7 @@ export interface LaunchConfig {
}
```
Source: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:25`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
Source: [`packages/ptc-runtime/ptc-runtime-node/src/config.ts:8`](../packages/ptc-runtime/ptc-runtime-node/src/config.ts)
<a id="deepseek-aidsh-pwsh-local"></a>
+1 -1
View File
@@ -2282,7 +2282,7 @@ export interface LaunchConfig {
}
```
来源: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:25`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
来源: [`packages/ptc-runtime/ptc-runtime-node/src/config.ts:8`](../packages/ptc-runtime/ptc-runtime-node/src/config.ts)
<a id="deepseek-aidsh-pwsh-local"></a>
+2 -2
View File
@@ -63,7 +63,7 @@ const TYPESCRIPT_FLAVOR: RunCodeFlavor = {
}
const REPL_FLAVOR: RunCodeFlavor = {
description: 'Execute TypeScript in an in-memory REPL. Top-level declarations and await are supported; use console.log or a final JSON expression for output. Top-level return is not supported. Call tools as await tools.name(args) using the declarations in the system prompt. Image-bearing subtool results are attached after the run.',
description: 'Execute TypeScript in an in-memory REPL. Top-level declarations and await are supported; only erasable TypeScript is accepted, with no enum or namespaces. Use console.log or a final JSON expression for output. Only printed values and that final expression become program output; curate them. Top-level return is not supported. Call tools as await tools.name(args) using the declarations in the system prompt. Image-bearing subtool results are attached after the run.',
codeDescription: 'TypeScript REPL statements with optional top-level await and a final JSON expression.',
}
@@ -705,7 +705,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
try {
let result: PtcRunResult
try {
if (exec.agent !== undefined && runtime.programFormat === 'repl') {
if (exec.agent !== undefined) {
const agent = exec.agent
let runtimes = stateOwners.get(agent)
if (runtimes === undefined) stateOwners.set(agent, runtimes = new WeakSet())
+9 -7
View File
@@ -251,14 +251,16 @@ const SDK_INSTRUCTIONS = `## Writing code for run_code
\`run_code\` takes two required arguments: \`code\` — the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped) — and \`description\`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly.`
const SDK_PROGRAM_INSTRUCTIONS = `Inside the program:
function programInstructions(output: string): string {
return `Inside the program:
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
- A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue.
- Independent read-only calls MAY overlap under \`Promise.all\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`.
- Emit results with \`return\` and/or \`console.log(...)\`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
- ${output} A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
Program-only SDK bindings:`
}
/** Whether one string schema accepts the literal used by the bash example. */
function acceptsExampleString(schema: JsonSchemaNode | undefined, value: string): boolean {
@@ -315,10 +317,10 @@ export function renderToolsSdk(schemas: ToolSdkSchema[], format: 'function-body'
].join('\n\n')
const jsonValue = 'type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }'
const instructions = format === 'repl'
? '## Writing code for run_code\n\n`run_code` takes `code`, TypeScript REPL statements with erasable types and top-level `await`, and `description`, a short summary. Use `await import(...)` for modules. The declarations below describe program-only SDK bindings.'
? '## Writing code for run_code\n\n`run_code` takes `code`, TypeScript REPL statements with erasable types (no `enum` or namespaces) and top-level `await`, and `description`, a short summary. Use `await import(...)` for modules. The declarations below describe program-only SDK bindings.'
: SDK_INSTRUCTIONS
const programInstructions = format === 'repl'
? SDK_PROGRAM_INSTRUCTIONS.replace('Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output.', 'Emit results with `console.log(...)` or a final JSON expression. Top-level `return` is not supported.')
: SDK_PROGRAM_INSTRUCTIONS
return `${instructions}${renderBashExample(sorted, format)}\n\n${programInstructions}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\``
const outputInstructions = programInstructions(format === 'repl'
? 'Emit results with `console.log(...)` or a final JSON expression. Top-level `return` is not supported. Only printed values and that final expression become program output; curate them.'
: 'Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output.')
return `${instructions}${renderBashExample(sorted, format)}\n\n${outputInstructions}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\``
}
+11 -8
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
@@ -76,7 +76,7 @@ async function setup(options: SetupOptions = {}) {
/** Mint an agent scope configured like production that can register scoped tool policy. */
async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> {
const agent = { id: SessionId(name), session: { header: {}, append: () => {} } } as unknown as Agent
const agent = { ctx, id: SessionId(name), session: { header: {}, append: () => {} } } as unknown as Agent
let scope!: Scope
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) },
{ inject: ['tools', 'systemPrompt'] }))
@@ -105,7 +105,10 @@ function registerEcho(ctx: Context, name = 'echo'): unknown[] {
/** A structural fake of the owning agent: captures session appends. */
function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] } {
const events: { type: string; data: unknown }[] = []
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
const agent = {
ctx,
session: {
header: { cwd: '/workspace' },
append: (type: string, data: unknown) => { events.push({ type, data }) },
@@ -1727,7 +1730,7 @@ describe('the run_code dispatch bridge', () => {
},
}))
const session = Session.create(SessionId('deep-code-arguments'))
const agent = { session } as Agent
const agent = { ctx, session } as Agent
runtime.behavior = async (request) => {
let nested: JsonValue = 'leaf'
for (let index = 0; index < depth; index++) nested = [nested]
@@ -2202,7 +2205,7 @@ describe('per-program execution controls', () => {
})
const session = Session.create(SessionId('program-controls'))
session.append('turn/start', { turn: 1 })
const agent = { session } as unknown as Agent
const agent = { ctx: state.ctx, session } as unknown as Agent
const execute = (args: Record<string, unknown>, signal = testToolSignal) => state.tools.execute({
callId: ToolCallId('program-controls'), name: RUN_CODE_NAME,
arguments: { code: 'program', description: 'Test execution controls', ...args }, agent, signal,
@@ -2373,16 +2376,16 @@ describe('per-program execution controls', () => {
})
})
describe('REPL state ownership', () => {
it('uses the same Agent owner across calls and awaits one release on its disposal', async () => {
describe('PTC state ownership', () => {
it.each(['function-body', 'repl'] as const)('uses one Agent owner and releases %s provider state on disposal', async (programFormat) => {
const { ctx, runtime } = await setup()
const agentContext = new Context()
const { agent } = fakeAgent()
Object.defineProperty(agent, 'ctx', { value: agentContext })
const format = vi.spyOn(runtime, 'programFormat', 'get').mockReturnValue('repl')
const format = vi.spyOn(runtime, 'programFormat', 'get').mockReturnValue(programFormat)
const release = vi.spyOn(runtime, 'release')
try {
expect(ctx.tools.schemas().find(tool => tool.name === 'run_code')?.description).toContain('in-memory REPL')
expect(ctx.tools.schemas().find(tool => tool.name === 'run_code')?.description).toContain(programFormat === 'repl' ? 'in-memory REPL' : 'async function')
expect((await runCode(ctx, 'let value = 1', { agent })).isError).toBe(false)
expect((await runCode(ctx, 'value', { agent })).isError).toBe(false)
expect(runtime.lastRequest?.stateOwner).toBe(agent)
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ptc-runtime/ptc-runtime-node/README.md
README.md: 0136fed8d2c4ab43a5a20347535fb55c1f793d6a
README.zh.md: 34914efa1d9bba14a155ff073056fa33d216144a
README.md: b84edb9a4663446a02dbcb8a67ee1e0f23ca252a
README.zh.md: d81cc59502d4ec5396a6edeb2960fddd79ac60b6
@@ -78,11 +78,11 @@ The optional `@deepseek-ai/dsh-ptc-runtime-node/persistent` entry replaces the d
Save the patch and pass its path to `dsh headless --patch <path>` in a composition with PTC tool presentation. Node applications still launch through a named `dsh` profile. Packaged single-executable hosts require `nodeExecutable` pointing to Node and `bootstrapPath` pointing to this package's built `persistent-process.js`.
Each live Agent keeps its own process and top-level variables, functions, and imported modules across `run_code` calls and turns. Use `await import(...)`, `console.log(...)`, and a final JSON expression; top-level `return` is not supported. Node REPL declaration rules apply, including errors on redeclaring existing lexical names. A leading `{ ... }` is a statement block and can contain top-level await and temporary declarations; parenthesize an object expression to return it. Retained functions and saved tool methods call the current evaluation's bindings. All tool calls must be awaited; callbacks from a completed evaluation cannot invoke tools in a later evaluation.
Each live Agent keeps its own process and top-level variables, functions, and imported modules across `run_code` calls and turns. Use `await import(...)`, `console.log(...)`, and a final JSON expression; top-level `return` is not supported. Node REPL declaration rules apply, including errors on redeclaring existing `let`, `const`, or `class` names; assign to a mutable binding or choose a new name. A leading `{ ... }` is a statement block and can contain top-level await and temporary declarations; parenthesize an object expression to return it. Retained functions and saved tool methods call the current evaluation's bindings. All tool calls must be awaited; callbacks from a completed evaluation cannot invoke tools in a later evaluation.
Memory is best-effort and never saved to disk. Restart, Agent disposal, provider unload, cancellation, timeout, protocol failure, output overflow, or process exit clears it. Missing variables raise normal reference errors; reacquire handles and recreate declarations. Ordinary program exceptions preserve any earlier mutations. The Session still records programs, tool dispatches and visible results; restoring a Session or forking an Agent starts with empty memory. Direct Node access remains unsandboxed; explicit sandbox policies are rejected.
Memory is best-effort and never saved to disk. Restart, Agent disposal, provider unload, cancellation, timeout, protocol failure, output overflow, or process exit clears it. Missing variables raise normal reference errors; reacquire handles and recreate declarations. Ordinary synchronous and asynchronous program exceptions settle once and preserve any earlier mutations. The Session still records programs, tool dispatches and visible results; restoring a Session or forking an Agent starts with empty memory. Direct Node access remains unsandboxed; explicit sandbox policies are rejected.
Service consumers pass an object as `stateOwner` to reuse its environment and await `release(owner)` when finished. Requests without an owner run in temporary processes. Concurrent runs for one owner reject; distinct owners may run concurrently. An idle environment has no expiration timer and keeps its process until release or failure.
Service consumers pass an object as `stateOwner` to reuse its environment and await `release(owner)` when finished. Requests without an owner run in temporary processes. Concurrent runs for one owner reject; distinct owners may run concurrently. An idle environment has no expiration timer and keeps its process until release or failure. Raw stdout/stderr have no evaluation identity: bytes received during an evaluation share its output budget, even when written by an earlier child process; idle bytes are discarded. Await child-process completion before ending an evaluation. Early process-exit diagnostics include the exit code and retained stderr tail.
### Deadlines and cancellation
@@ -160,7 +160,8 @@ These limits qualify the execution guarantees and retained output.
- **Confinement inherits the selected backend's limits** — full and partial enforcement are reported separately; sandbox policy and managed-process containment are distinct guarantees.
- **The heap cap is not a process-tree memory limit** — native allocations and descendant memory are outside the V8 old-generation bound. No process-tree CPU meter is supplied.
- **Cleanup inherits subprocess observability** — escaped descendants on a fallback platform may remain outside the managed range; see the subprocess provider's stated limits.
- **Execution is one-shot** — no yield/wait API, live result stream or retained program state exists between calls.
- **Default execution is one-shot** — retained state requires the optional persistent provider; neither provider offers a yield/wait API.
- **Persistent environments retain one process per live owner** — idle owners keep their process and heap until explicit release, owner disposal or failure. Fan-out can retain many processes; idle expiration remains deferred.
- **Output caps reject rather than retain every byte** — spill can preserve only the bounded result delivered by this provider.
- **Bindings are bounded at transport admission** — control limits do not bound the memory a host binding allocates while producing its result.
- **The console shim has five methods** — `log`, `info`, `warn`, `error` and `debug`.
@@ -78,11 +78,11 @@ kind: "package-reference"
保存补丁,在启用 PTC 工具呈现的组合中使用 `dsh headless --patch <path>` 传入路径。Node 应用仍通过具名 `dsh` 配置档启动。打包为单个可执行文件的宿主需要将 `nodeExecutable` 指向 Node,将 `bootstrapPath` 指向本包构建的 `persistent-process.js`。
每个存活 Agent 保留独立进程,跨 `run_code` 调用和轮次保留顶层变量、函数及导入模块。使用 `await import(...)`、`console.log(...)` 和末尾 JSON 表达式;不支持顶层 `return`。声明遵循 Node REPL 规则,包括重复声明已有词法名称时报错。开头的 `{ ... }` 按语句块执行,可以包含顶层 await 和临时声明;需要返回对象表达式时,用圆括号包裹。保留的函数和工具方法引用调用当前执行的绑定。必须等待所有工具调用;已完成执行的回调不能在后续执行中调用工具。
每个存活 Agent 保留独立进程,跨 `run_code` 调用和轮次保留顶层变量、函数及导入模块。使用 `await import(...)`、`console.log(...)` 和末尾 JSON 表达式;不支持顶层 `return`。声明遵循 Node REPL 规则,包括重复声明已有 `let`、`const` 或 `class` 名称时报错;应给可变绑定赋值或选择新名称。开头的 `{ ... }` 按语句块执行,可以包含顶层 await 和临时声明;需要返回对象表达式时,用圆括号包裹。保留的函数和工具方法引用调用当前执行的绑定。必须等待所有工具调用;已完成执行的回调不能在后续执行中调用工具。
内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、取消、超时、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。普通程序异常保留此前发生的修改。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。直接 Node 访问没有沙盒约束;显式沙盒策略会被拒绝。
内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、取消、超时、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。普通同步和异步程序异常只结算一次,并保留此前发生的修改。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。直接 Node 访问没有沙盒约束;显式沙盒策略会被拒绝。
服务消费者通过 `stateOwner` 对象复用环境,并在结束时等待 `release(owner)`。没有所有者的请求在临时进程中执行。同一所有者的并发调用会被拒绝;不同所有者可以并发。空闲环境没有过期计时器,进程保留到释放或失败为止。
服务消费者通过 `stateOwner` 对象复用环境,并在结束时等待 `release(owner)`。没有所有者的请求在临时进程中执行。同一所有者的并发调用会被拒绝;不同所有者可以并发。空闲环境没有过期计时器,进程保留到释放或失败为止。原始 stdout/stderr 不携带执行标识:执行期间收到的字节计入本次输出预算,包括较早启动的子进程此时写出的内容;空闲期间收到的字节会被丢弃。结束本次执行前,应等待子进程完成。进程提前退出的诊断包含退出码和保留的 stderr 尾部。
### 截止时间与取消
@@ -160,7 +160,8 @@ Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与
- **约束继承所选后端的限制**——完整与部分强制能力分开报告;沙箱策略与受管进程约束是不同保证。
- **堆上限不是进程树内存限制**——原生分配与后代进程内存不属于 V8 老生代上限。不提供进程树 CPU 计量器。
- **清理继承子进程的可观测范围**——使用 fallback 的平台上,逃逸的后代可能仍在受管范围之外;参见子进程提供方声明的限制。
- **执行是一次性的**——没有 yield/wait API、实时结果流或跨调用保留的程序状态。
- **默认执行是一次性的**——保留状态需要可选的持久提供方;两个提供方都不提供 yield/wait API。
- **持久环境为每个存活所有者保留一个进程**——空闲所有者保留进程及堆内存,直到显式释放、所有者销毁或执行失败。并行创建多个所有者会保留多个进程;空闲过期机制留待后续处理。
- **输出上限拒绝超量内容,而不保留每个字节**——溢出只能保存本提供方交付的有界结果。
- **绑定在传输接纳时受限**——控制限制不约束 Host 绑定生成结果期间分配的内存。
- **console shim 有五个方法**——`log`、`info`、`warn`、`error` 与 `debug`。
@@ -334,7 +334,7 @@ export function makeNamespaces(
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
}
return new Promise((resolve, reject) => {
const id = nextId.value++
const id = nextId.value
pending.set(id, {
resolve,
reject: (error) => {
@@ -343,9 +343,10 @@ export function makeNamespaces(
})
try {
port.postMessage({ type: 'call', id, global, name, args: encodePtcJsonWire(detached) })
nextId.value += 1
} catch (error: unknown) {
pending.delete(id)
const message = `binding arguments must be structured-cloneable: ${error instanceof CapturedError ? error.message : String(error)}`
const message = `binding call could not be sent: ${error instanceof CapturedError ? error.message : String(error)}`
reject(bindingFailure(errorClass, name, message))
}
})
@@ -0,0 +1,57 @@
/** Shared configuration for the isolated and persistent Node providers. */
import { isAbsolute } from 'node:path'
import z from '@deepseek-ai/schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { LaunchConfig } from './launch.ts'
/** Deployment-varying runtime bounds and launch choices. */
export interface Config extends LaunchConfig {
/** Default elapsed deadline, including nested tool and approval waits. */
timeoutMs?: number
/** Maximum numeric elapsed budget accepted by resolve. */
maxTimeoutMs?: number
/** Combined serialized logs, completion and diagnostic byte cap. */
maxOutputBytes?: number
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
maxOldGenerationSizeMb?: number
/** Maximum control frame, outstanding argument and queued control-output bytes. */
maxMessageBytes?: number
/** Maximum simultaneous host binding calls accepted from a program. */
maxPendingCalls?: number
/** Managed process termination and output-drain grace in milliseconds. */
graceMs?: number
}
/** Configuration after Loader defaults and executable selection. */
export type ResolvedConfig = Required<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
/** Loader defaults shared by both Node providers. */
export const NodeConfig: z<Config> = z.object({
timeoutMs: z.number().default(120_000),
maxTimeoutMs: z.number().default(600_000),
maxOutputBytes: z.number().default(67_108_864),
maxOldGenerationSizeMb: z.number().default(512),
maxMessageBytes: z.number().default(134_217_728),
maxPendingCalls: z.number().default(128),
graceMs: z.number().default(3_000),
nodeExecutable: z.string(),
bootstrapPath: z.string(),
})
/**
* Validate resolved Node limits and paths.
* @param config - Loader-defaulted fields with the selected executable.
*/
export function validateConfig(config: ResolvedConfig): void {
for (const [key, value] of Object.entries(config)) {
if (typeof value === 'number' && (!Number.isFinite(value) || value <= 0)) throw new Error(`ptc-runtime-node: ${key} must be positive and finite`)
}
for (const key of ['timeoutMs', 'maxTimeoutMs', 'graceMs'] as const) {
if (config[key] > MAX_TIMER_DELAY_MS) throw new Error(`ptc-runtime-node: ${key} exceeds the supported timer range`)
}
if (!Number.isSafeInteger(config.maxOutputBytes) || config.maxOutputBytes < 4) throw new Error('ptc-runtime-node: maxOutputBytes must be an integer of at least 4')
if (!Number.isSafeInteger(config.maxMessageBytes) || config.maxMessageBytes > 0xffff_ffff) throw new Error('ptc-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length')
if (!Number.isSafeInteger(config.maxPendingCalls)) throw new Error('ptc-runtime-node: maxPendingCalls must be an integer')
if (!Number.isSafeInteger(config.maxOldGenerationSizeMb)) throw new Error('ptc-runtime-node: maxOldGenerationSizeMb must be an integer')
if (config.nodeExecutable.length === 0) throw new Error('ptc-runtime-node: nodeExecutable must be non-empty')
if (config.bootstrapPath !== undefined && !isAbsolute(config.bootstrapPath)) throw new Error('ptc-runtime-node: bootstrapPath must be absolute')
}
@@ -2,10 +2,9 @@
import { stripTypeScriptTypes } from 'node:module'
import { isAbsolute } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
import type { PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime'
import { MAX_TIMER_DELAY_MS, clampTimeout } from '@deepseek-ai/dsh-timeout'
import { clampTimeout } from '@deepseek-ai/dsh-timeout'
import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
@@ -14,32 +13,15 @@ import type {} from '@deepseek-ai/dsh-fs'
import { replyToBinding, validateBindings } from './bindings.ts'
import { JsonChannel } from './channel.ts'
import { bootstrapArgs } from './launch.ts'
import type { LaunchConfig } from './launch.ts'
import { NodeConfig, validateConfig } from './config.ts'
import type { Config, ResolvedConfig } from './config.ts'
export type { Config } from './config.ts'
import { OutputLedger } from './output-ledger.ts'
import { terminateProcess } from './output-stream.ts'
import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts'
import { decodePtcJsonWire } from './json-wire.ts'
import type { ProgramBootData } from './protocol.ts'
/** Deployment-varying runtime bounds and launch choices. */
export interface Config extends LaunchConfig {
/** Default elapsed deadline, including nested tool and approval waits. */
timeoutMs?: number
/** Maximum numeric elapsed budget accepted by resolve. */
maxTimeoutMs?: number
/** Combined serialized logs, completion and diagnostic byte cap. */
maxOutputBytes?: number
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
maxOldGenerationSizeMb?: number
/** Maximum control frame, outstanding argument and queued control-output bytes. */
maxMessageBytes?: number
/** Maximum simultaneous host binding calls accepted from a program. */
maxPendingCalls?: number
/** Managed process termination and output-drain grace in milliseconds. */
graceMs?: number
}
type ResolvedConfig = Required<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
interface LiveRun { controller: AbortController; finished: Promise<void> }
const STRIP_PREFIX = 'async function __dsh_program__() {\n'
const STRIP_SUFFIX = '\n}'
@@ -50,17 +32,7 @@ function record(value: unknown): value is Record<string, unknown> { return typeo
/** Node provider; direct file effects use the same sandbox service as Bash. */
export class NodePtcRuntime extends PtcRuntime {
static inject = ['fs', 'subprocess', 'sandbox', 'sandboxPolicy']
static Config: z<Config> = z.object({
timeoutMs: z.number().default(120_000),
maxTimeoutMs: z.number().default(600_000),
maxOutputBytes: z.number().default(67_108_864),
maxOldGenerationSizeMb: z.number().default(512),
maxMessageBytes: z.number().default(134_217_728),
maxPendingCalls: z.number().default(128),
graceMs: z.number().default(3_000),
nodeExecutable: z.string(),
bootstrapPath: z.string(),
})
static Config = NodeConfig
readonly language = 'typescript'
readonly isolation = 'process'
override get executionInstructions(): string {
@@ -73,18 +45,7 @@ export class NodePtcRuntime extends PtcRuntime {
constructor(ctx: Context, config: Config) {
super(ctx)
this.config = { ...config, nodeExecutable: config.nodeExecutable ?? process.execPath } as ResolvedConfig
for (const [key, value] of Object.entries(this.config)) {
if (typeof value === 'number' && (!Number.isFinite(value) || value <= 0)) throw new Error(`ptc-runtime-node: ${key} must be positive and finite`)
}
for (const key of ['timeoutMs', 'maxTimeoutMs', 'graceMs'] as const) {
if (this.config[key] > MAX_TIMER_DELAY_MS) throw new Error(`ptc-runtime-node: ${key} exceeds the supported timer range`)
}
if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < 4) throw new Error('ptc-runtime-node: maxOutputBytes must be an integer of at least 4')
if (!Number.isSafeInteger(this.config.maxMessageBytes) || this.config.maxMessageBytes > 0xffff_ffff) throw new Error('ptc-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length')
if (!Number.isSafeInteger(this.config.maxPendingCalls)) throw new Error('ptc-runtime-node: maxPendingCalls must be an integer')
if (!Number.isSafeInteger(this.config.maxOldGenerationSizeMb)) throw new Error('ptc-runtime-node: maxOldGenerationSizeMb must be an integer')
if (this.config.nodeExecutable.length === 0) throw new Error('ptc-runtime-node: nodeExecutable must be non-empty')
if (this.config.bootstrapPath !== undefined && !isAbsolute(this.config.bootstrapPath)) throw new Error('ptc-runtime-node: bootstrapPath must be absolute')
validateConfig(this.config)
ctx.effect(() => async () => {
this.disposed = true
const active = [...this.live]
@@ -7,7 +7,7 @@ export class OutputLedger {
private bytes = 2 // JSON serialization of the empty logs array: []
private entries = 0
constructor(private readonly maxBytes: number) {}
constructor(private readonly maxBytes: number, private readonly overflowMessage?: string) {}
/** Admit one exact log entry, or report that the hard cap was crossed. */
admit(text: string, sink: string[]): boolean {
@@ -34,7 +34,7 @@ export class OutputLedger {
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
limit(logs: string[]): PtcRunResult {
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
const fullMessage = this.overflowMessage ?? `outer output exceeded ${this.maxBytes} bytes`
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
const messageBytes = fullMessage.length + 2
const retained: string[] = []
@@ -19,6 +19,7 @@ export interface PersistentProcess {
interface Evaluation {
id: number
logs: LogBuffer
maxOutputBytes: number
active: boolean
finish(result: Omit<DoneMessage, 'type'>): void
}
@@ -43,7 +44,7 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number,
const output = new Writable({ write(chunk: Buffer, _encoding, callback) {
const current = context.getStore()
const message = chunk.toString().trim()
if (current?.active && message) current.finish({ error: { kind: 'exception', message } })
if (current?.active && message) current.finish(prepareException(message, current.logs.remainingOutputBytes(), current.maxOutputBytes))
callback()
} })
const repl = start({ input, output, terminal: false, prompt: '', useGlobal: false, ignoreUndefined: true })
@@ -76,8 +77,9 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number,
const { id, data } = message
const logs = new LogBuffer(data.maxOutputBytes, (text) => { send(id, { type: 'log', text }) }, () => { send(id, { type: 'output-limit' }) })
const evaluation: Evaluation = {
id, logs, active: true,
id, logs, maxOutputBytes: data.maxOutputBytes, active: true,
finish(result) {
if (!evaluation.active) return
evaluation.active = false
for (const call of pending.values()) call.reject(new Error('PTC evaluation ended before the binding completed'))
pending.clear()
@@ -4,23 +4,27 @@ 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 { MAX_TIMER_DELAY_MS, clampTimeout } from '@deepseek-ai/dsh-timeout'
import { clampTimeout } from '@deepseek-ai/dsh-timeout'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import { NodePtcRuntime, type Config } from './index.ts'
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 { terminateProcess } from './output-stream.ts'
import { drainOutput, terminateProcess } from './output-stream.ts'
import type { ProgramBootData } from './protocol.ts'
export type { Config } from './index.ts'
type ResolvedConfig = Required<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
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
@@ -40,6 +44,7 @@ class Kernel {
private ready = false
private sequence = 0
private nextCall = 1
private stderr = ''
private stopped?: Promise<void>
private starting?: Promise<void>
private readonly controller = new AbortController()
@@ -47,9 +52,14 @@ class Kernel {
constructor(private readonly ctx: Context, private readonly config: ResolvedConfig) {}
async run(spec: PtcRunSpec, bindings: Map<string, PtcBindingNamespace>, code: string): Promise<PtcRunResult> {
assertIdle(): void {
if (this.active) throw new Error('persistent PTC does not accept concurrent runs for one state owner')
const output = new OutputLedger(this.config.maxOutputBytes)
}
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(() => {
@@ -69,12 +79,13 @@ class Kernel {
}
},
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 || spec.stateOwner === undefined ? this.stop() : Promise.resolve()
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)}` }))
})
@@ -132,15 +143,26 @@ class Kernel {
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', () => { this.fail('protocol', 'raw process output cannot be attributed to a PTC evaluation') })
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) => {
this.fail(kind === 'protocol' ? 'protocol' : 'worker-exit', kind === 'protocol' ? error.message : 'persistent Node process stopped')
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}` : ''}`)
})
void handle.done.then(() => {
setImmediate(() => { if (!this.closed) this.fail('worker-exit', 'persistent Node process stopped') })
}, (error: unknown) => { this.fail('worker-exit', messageOf(error)) })
}
@@ -168,10 +190,14 @@ class Kernel {
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
}
evaluation.finish({ kind: error.kind, message: error.message }, undefined, error.kind === 'output-limit')
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)
evaluation.finish(payload.value !== undefined && value === undefined ? { kind: 'invalid-output', message: 'program completion must be lossless JSON' } : undefined, 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
}
@@ -203,12 +229,12 @@ class Kernel {
/** Unsandboxed, best-effort retained state; restart, disposal, and interrupted execution clear it. */
export class PersistentNodePtcRuntime extends PtcRuntime {
static inject = ['fs', 'subprocess']
static Config = NodePtcRuntime.Config
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, 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. Await all tool calls; background callbacks cannot call tools after their evaluation ends.'
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 }
@@ -221,12 +247,8 @@ export class PersistentNodePtcRuntime extends PtcRuntime {
constructor(ctx: Context, config: Config) {
super(ctx)
this.config = { ...config, nodeExecutable: config.nodeExecutable ?? process.execPath } as ResolvedConfig
for (const [name, value] of Object.entries(this.config)) {
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) throw new Error(`persistent PTC: ${name} must be a positive safe integer`)
}
if (this.config.maxOutputBytes < 4 || this.config.maxMessageBytes > 0xffff_ffff) throw new Error('persistent PTC: invalid output or frame limit')
if ([this.config.timeoutMs, this.config.maxTimeoutMs, this.config.graceMs].some(value => value > MAX_TIMER_DELAY_MS)) throw new Error('persistent PTC: timeout exceeds the supported timer range')
if (!this.config.nodeExecutable || (this.config.bootstrapPath !== undefined && !isAbsolute(this.config.bootstrapPath))) throw new Error('persistent PTC: invalid executable or bootstrap path')
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()))
@@ -256,14 +278,19 @@ export class PersistentNodePtcRuntime extends PtcRuntime {
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 code: string
try { code = stripTypeScriptTypes(spec.program) } catch (error: unknown) { return { logs: [], error: { kind: 'exception', message: messageOf(error) } } }
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) this.owners.set(spec.stateOwner, 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)
@@ -184,17 +184,74 @@ describe('persistent PTC host protocol', () => {
expect(waiting.spawn).not.toHaveBeenCalled()
})
it('reports raw pipe output, stream errors and process failures without retaining state', async () => {
for (const action of ['raw', 'stream', 'exit', 'rejection'] as const) {
it('captures active raw output and ignores bytes arriving while the environment is idle', async () => {
const h = await setup()
h.onBoot(() => {
h.stdout.write('stdout')
h.stderr.write('stderr')
h.emit(frame({ type: 'done' }))
})
expect(await h.start()).toEqual({ logs: ['stdout', 'stderr'] })
h.stdout.write('idle stdout')
h.stderr.write('idle stderr')
h.onBoot(() => { h.emit(frame({ type: 'done' }, 2)) })
expect(await h.start(request, NO_INITIAL_FRAME)).toEqual({ logs: [] })
expect(h.terminate).not.toHaveBeenCalled()
})
it('clears retained resources when final diagnostic accounting detects an overflow', async () => {
const h = await setup({ maxOutputBytes: 100 })
h.onBoot(() => { h.emit(frame({ type: 'done', error: { kind: 'exception', message: 'x'.repeat(1000) } })) })
const result = await h.start()
expect(result.error?.kind).toBe('output-limit')
expect(result.error?.message).toContain('retained variables were cleared')
expect(h.terminate).toHaveBeenCalledTimes(1)
})
it('keeps the first outcome when raw output overflows beside a terminal frame', async () => {
const h = await setup({ maxOutputBytes: 100 })
h.onBoot(() => {
h.emit(frame({ type: 'done', value: encodePtcJsonWire(42) }))
queueMicrotask(() => { h.stdout.write('x'.repeat(1000)) })
})
expect((await h.start()).error?.kind).toBe('output-limit')
await setImmediate()
expect(h.terminate).toHaveBeenCalledTimes(1)
})
it('reports the exit result when the control pipe ends beside process completion', async () => {
const h = await setup()
h.onBoot(() => {
h.stdout.end()
h.stderr.end('fatal output')
h.direct.resolve({ exitCode: 7, signal: null })
h.control.push(null)
})
const result = await h.start()
expect(result.error?.kind).toBe('worker-exit')
expect(result.error?.message).toContain('7')
expect(result.error?.message).toContain('fatal output')
})
it('terminates when a binding result exceeds the control write budget', async () => {
const h = await setup({ maxMessageBytes: 1024 })
h.onBoot(() => { h.emit(call()) })
const result = await h.start({ ...request, bindings: [{ global: 'tools', functions: { test: async () => 'x'.repeat(2000) } }] })
expect(result.error?.kind).toBe('protocol')
expect(result.error?.message).toContain('control output exceeds')
expect(h.terminate).toHaveBeenCalledTimes(1)
})
it('reports stream errors and process failures without retaining state', async () => {
for (const action of ['stream', 'exit', 'rejection'] as const) {
const h = await setup()
h.onBoot(() => {
if (action === 'raw') h.stdout.write('unattributed')
else if (action === 'stream') h.stderr.emit('error', new Error('pipe failed'))
else if (action === 'exit') h.direct.resolve({ exitCode: 7, signal: null })
if (action === 'stream') h.stderr.emit('error', new Error('pipe failed'))
else if (action === 'exit') { h.stdout.end(); h.stderr.end(); h.direct.resolve({ exitCode: 7, signal: null }) }
else h.direct.reject(new Error('process failed'))
})
const result = await h.start()
expect(result.error?.kind).toBe(action === 'raw' ? 'protocol' : 'worker-exit')
expect(result.error?.kind).toBe('worker-exit')
expect(result.logs).toEqual([])
}
})
@@ -215,7 +272,7 @@ describe('persistent PTC host protocol', () => {
{ timeoutMs: 0 }, { maxMessageBytes: 0x1_0000_0000 }, { maxOutputBytes: 3 }, { graceMs: 2_147_483_648 },
{ nodeExecutable: '' }, { bootstrapPath: 'relative' },
]) {
await expect(setup(config)).rejects.toThrow('persistent PTC')
await expect(setup(config)).rejects.toThrow('ptc-runtime-node')
}
})
})
@@ -58,6 +58,26 @@ it('keeps an environment while capturing each evaluation and binding replies sep
expect(decodePtcJsonWire((await h.run('count')).value)).toBe(2)
})
it('does not let an old asynchronous exception finish a later evaluation', async () => {
const h = await setup()
const declarations = [{ global: 'tools', names: ['echo'] }]
const first = await h.run('let count = 1; let resume; const pause = new Promise(resolve => { resume = resolve }); setImmediate(() => { throw new Error("async failure") }); await pause; count', declarations)
expect(first).toMatchObject({ error: { kind: 'exception' } })
const second = await h.run('resume(); await tools.echo({})', declarations)
expect(decodePtcJsonWire(second.value)).toBe(42)
expect(decodePtcJsonWire((await h.run('count')).value)).toBe(1)
expect(h.seen.filter(item => item.type === 'done')).toHaveLength(3)
})
it('treats leading braces as a statement block with reusable local declarations', async () => {
const h = await setup()
await h.run('let count = 0;')
const block = '{ const local = await Promise.resolve(2); count += local; }'
await h.run(block)
await h.run(block)
expect(decodePtcJsonWire((await h.run('({ count, local: typeof local })')).value)).toEqual({ count: 4, local: 'undefined' })
})
it('contains directory failures and output overflow in the active evaluation', async () => {
const badDirectory = await setup(4096, () => {
const error = new Error('directory missing')
@@ -92,6 +112,8 @@ it('ignores output and rejects tool calls from a completed evaluation', async ()
const result = await h.run('finish(); await new Promise(resolve => setImmediate(resolve)); old', declarations)
expect(decodePtcJsonWire(result.value)).toContain('evaluation has ended')
expect(h.seen.some(item => item.type === 'log' || item.type === 'call')).toBe(false)
expect(decodePtcJsonWire((await h.run('await tools.echo({})', declarations)).value)).toBe(42)
expect(h.seen.find(item => item.type === 'call')?.id).toBe(1)
})
it('rejects unfinished binding waits when a program returns', async () => {
@@ -1,6 +1,7 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setImmediate } from 'node:timers/promises'
import { Context } from '@deepseek-ai/cordis'
import FileSystem from '@deepseek-ai/dsh-fs-local'
import Subprocess from '@deepseek-ai/dsh-subprocess-local'
@@ -54,6 +55,47 @@ describe('persistent Node REPL', () => {
expect(await run('({ total, local: typeof amount })')).toEqual({ logs: [], value: { total: 5, local: 'undefined' } })
})
it('settles an asynchronous exception once while a later evaluation awaits a tool', async () => {
const { run } = await setup()
const failed = await run('let retained = 17; let resume; const paused = new Promise(resolve => { resume = resolve }); setImmediate(() => { throw new Error("async failure") }); await paused; retained')
expect(failed.error?.message).toContain('async failure')
const next = await run('resume(); await tools.value({})', { bindings: bindings(async () => { await setImmediate(); return 42 }) })
expect(next).toEqual({ logs: [], value: 42 })
expect((await run('retained')).value).toBe(17)
})
it('rejects a completed evaluation callback without consuming the next real binding identity', async () => {
const { run } = await setup()
const called = vi.fn(async () => 42)
const options = { bindings: bindings(called) }
await run('let retained = 17; let trigger; let rejection; const expired = new Promise(resolve => { trigger = resolve }); void expired.then(async () => { try { await tools.value({}) } catch (error) { rejection = error.message } });', options)
expect((await run('trigger(); await new Promise(resolve => setImmediate(resolve)); rejection', options)).value).toContain('evaluation has ended')
expect(called).not.toHaveBeenCalled()
expect(await run('({ retained, answer: await tools.value({}) })', options)).toEqual({ logs: [], value: { retained: 17, answer: 42 } })
expect(called).toHaveBeenCalledTimes(1)
})
it('captures inherited child stdout and stderr without losing retained variables', async () => {
const { run } = await setup()
const childProgram = 'process.stdout.write("stdout marker"); process.stderr.write("stderr marker")'
const result = await run(`let retained = 17; const child = await import("node:child_process"); child.execFileSync(process.execPath, ["-e", ${JSON.stringify(childProgram)}], { stdio: "inherit" }); retained`)
expect(result.error).toBeUndefined()
expect(result.value).toBe(17)
expect(result.logs.join('')).toContain('stdout marker')
expect(result.logs.join('')).toContain('stderr marker')
expect((await run('retained')).value).toBe(17)
})
it('reports direct process exit status and stderr after draining raw output', async () => {
const { run } = await setup()
const result = await run('const nativeFs = await import("node:fs"); nativeFs.writeSync(2, "fatal fixture"); process.exit(7)')
expect(result.error?.kind).toBe('worker-exit')
expect(result.error?.message).toContain('7')
expect(result.error?.message).toContain('fatal fixture')
expect(result.logs.join('')).toContain('fatal fixture')
expect((await run('42')).value).toBe(42)
})
it('isolates owners and uses fresh state when no owner is supplied', async () => {
const { run, runtime, cwd } = await setup()
const temporary = (program: string) => runtime.run(runtime.resolve({ program, bindings: [], cwd }))
@@ -117,6 +159,7 @@ describe('persistent Node REPL', () => {
try {
await entered.promise
await expect(run('1')).rejects.toThrow('concurrent runs')
await expect(run('const =')).rejects.toThrow('concurrent runs')
expect((await run('2', { stateOwner: {} })).value).toBe(2)
} finally { proceed.resolve(null) }
expect((await pending).value).toBeNull()
@@ -147,6 +190,40 @@ describe('persistent Node REPL', () => {
expect((await run('42', { timeoutMs: null })).value).toBe(42)
})
it.each(['throw new Error("long diagnostic ".repeat(1000))', '"large completion".repeat(1000)'])('clears retained state after a final output overflow: %s', async (program) => {
const { run } = await setup({ maxOutputBytes: 100 })
await run('let retained = true;')
const result = await run(program)
expect(result.error?.kind).toBe('output-limit')
expect(result.error?.message).toContain('retained variables were cleared')
const outputBytes = Buffer.byteLength(JSON.stringify(result.logs)) + Buffer.byteLength(JSON.stringify(result.error?.message))
expect(outputBytes).toBeLessThanOrEqual(100)
expect((await run('typeof retained')).value).toBe('undefined')
})
it('bounds type-stripping diagnostics and clears the owner after a parse-output overflow', async () => {
const { run } = await setup({ maxOutputBytes: 32 })
await run('let retained = true;')
const result = await run('const =')
expect(result.error?.kind).toBe('output-limit')
const outputBytes = Buffer.byteLength(JSON.stringify(result.logs)) + Buffer.byteLength(JSON.stringify(result.error?.message))
expect(outputBytes).toBeLessThanOrEqual(32)
expect((await run('typeof retained')).value).toBe('undefined')
})
it('rejects a packaged host without a persistent bootstrap during provider loading', async () => {
const previous = Object.getOwnPropertyDescriptor(process, 'pkg')
try {
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
await expect(setup()).rejects.toThrow('requires an explicit bootstrapPath')
const { runtime } = await setup({ bootstrapPath: join(process.cwd(), 'persistent-process.js') })
expect(runtime.programFormat).toBe('repl')
} finally {
if (previous === undefined) Reflect.deleteProperty(process, 'pkg')
else Object.defineProperty(process, 'pkg', previous)
}
})
it('bounds output and clears state after an overflow', async () => {
const { run } = await setup({ maxOutputBytes: 100 })
expect((await run('let retained = true; console.log("x".repeat(1000))')).error?.kind).toBe('output-limit')
@@ -121,7 +121,7 @@ export abstract class PtcRuntime extends Service {
*/
abstract readonly isolation: string
/** Source evaluation form; REPL providers retain declarations for an explicitly supplied state owner. */
/** Source evaluation form, independent of whether the provider retains state for an owner. */
get programFormat(): 'function-body' | 'repl' { return 'function-body' }
/**
+16
View File
@@ -68,6 +68,22 @@ describe('shared config schema catalog', () => {
expect(provider?.pastes?.[0]?.text).toBe('export type Config = BrowserConfig')
})
it.each([false, true])('checks package-local schema imports and undeclared fields (invalid=%s)', (invalid) => {
const { root, write } = fixture()
write('packages/test/provider/src/config.ts', invalid
? sharedSchema.replace('headless: Schema.boolean()', 'headless: Schema.boolean(), hidden: Schema.string()')
: sharedSchema)
write('packages/test/provider/src/index.ts', `
import { Shared, type BrowserConfig } from './config.ts'
export type Config = BrowserConfig
export const Config = Shared
export function apply(ctx: unknown, config: Config): void {}
`)
if (invalid) expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden'")
else expect(new Set(collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider')?.schemaKeys))
.toEqual(new Set(['mode', 'headless', 'endpoint']))
})
it('rejects a schema field absent from the shared config type', () => {
const { root } = fixture(sharedSchema.replace('headless: Schema.boolean()', 'headless: Schema.boolean(), hidden: Schema.string()'))
expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden' but config type 'Config' declares no such member")
+3 -1
View File
@@ -281,7 +281,9 @@ function schemaAlias(world: World, ctx: FileCtx, name: string): { ctx: FileCtx;
if (imported === undefined || imported.typeOnly || imported.imported === '*' || imported.imported === 'default') {
throw new Error(`schema alias '${name}' must name a const or named value import`)
}
const target = loadWorkspaceSource(world, ctx, imported.specifier)
const target = imported.specifier.startsWith('.') && imported.specifier.endsWith('.ts')
? loadRelative(world, ctx, imported.specifier)
: loadWorkspaceSource(world, ctx, imported.specifier)
const expr = schemaConst(target, imported.imported, true)
if (expr === null) throw new Error(`schema import '${imported.specifier}' has no exported const '${imported.imported}'`)
return { ctx: target, expr }
@@ -52,7 +52,7 @@
"index": 0,
"id": "call_reuse",
"name": "run_code",
"argumentsDelta": "{\"code\":\"await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"
"argumentsDelta": "{\"code\":\"const childProcess = await import(\\\"node:child_process\\\"); childProcess.execFileSync(process.execPath, [\\\"-e\\\", \\\"process.stdout.write(\\\\\\\"raw child output\\\\\\\")\\\"], { stdio: \\\"inherit\\\" }); await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"
},
{
"type": "block-end",
@@ -61,7 +61,7 @@
"type": "tool-call",
"id": "call_reuse",
"name": "run_code",
"arguments": "{\"code\":\"await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"
"arguments": "{\"code\":\"const childProcess = await import(\\\"node:child_process\\\"); childProcess.execFileSync(process.execPath, [\\\"-e\\\", \\\"process.stdout.write(\\\\\\\"raw child output\\\\\\\")\\\"], { stdio: \\\"inherit\\\" }); await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"
}
},
{
@@ -17,16 +17,16 @@
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_retain"},"toolCallId":"call_retain","content":[{"type":"text","text":"(run_code completed with no output)"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_reuse","name":"run_code","arguments":"{\"code\":\"await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790153754377,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790153754377,"index":0,"dt":[],"id":"call_reuse","name":"run_code","args":["{\"code\":\"await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"]},{"type":"chunk","time":1790153754377,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_reuse","name":"run_code","arguments":"{\"code\":\"await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"}}},{"type":"chunk","time":1790153754377,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790153754377,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_reuse","name":"run_code","arguments":"{\"code\":\"await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_reuse","name":"run_code","arguments":"{\"code\":\"const childProcess = await import(\\\"node:child_process\\\"); childProcess.execFileSync(process.execPath, [\\\"-e\\\", \\\"process.stdout.write(\\\\\\\"raw child output\\\\\\\")\\\"], { stdio: \\\"inherit\\\" }); await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790153754377,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790153754377,"index":0,"dt":[],"id":"call_reuse","name":"run_code","args":["{\"code\":\"const childProcess = await import(\\\"node:child_process\\\"); childProcess.execFileSync(process.execPath, [\\\"-e\\\", \\\"process.stdout.write(\\\\\\\"raw child output\\\\\\\")\\\"], { stdio: \\\"inherit\\\" }); await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"]},{"type":"chunk","time":1790153754377,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_reuse","name":"run_code","arguments":"{\"code\":\"const childProcess = await import(\\\"node:child_process\\\"); childProcess.execFileSync(process.execPath, [\\\"-e\\\", \\\"process.stdout.write(\\\\\\\"raw child output\\\\\\\")\\\"], { stdio: \\\"inherit\\\" }); await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"}}},{"type":"chunk","time":1790153754377,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790153754377,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_reuse","name":"run_code","arguments":"{\"code\":\"const childProcess = await import(\\\"node:child_process\\\"); childProcess.execFileSync(process.execPath, [\\\"-e\\\", \\\"process.stdout.write(\\\\\\\"raw child output\\\\\\\")\\\"], { stdio: \\\"inherit\\\" }); await fs.writeFile(\\\"persistent-count.txt\\\", String(increment()) + \\\"\\\\n\\\"); const result = await report({command:\\\"pwd\\\",description:\\\"Read current working directory\\\"}); ({ value: counter.value, same: alias === counter, sameDirectory: process.cwd() === result.stdout.text.trim() })\",\"description\":\"Reuse retained objects and current tool bindings\"}"}}
{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_reuse","parentCallId":"call_reuse","subCallId":"call_reuse:ptc:1","name":"bash","arguments":{"command":"pwd","description":"Read current working directory"}}}
{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_reuse","parentCallId":"call_reuse","subCallId":"call_reuse:ptc:1","name":"bash","arguments":{"command":"pwd","description":"Read current working directory"},"isError":false,"content":[{"type":"text","text":"{{cwd}}\n"}]}}
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_reuse"},"toolCallId":"call_reuse","content":[{"type":"text","text":"{\n \"value\": 2,\n \"same\": true,\n \"sameDirectory\": true\n}"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_reuse"},"toolCallId":"call_reuse","content":[{"type":"text","text":"raw child output\n{\n \"value\": 2,\n \"same\": true,\n \"sameDirectory\": true\n}"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"step/start","data":{"turn":1,"step":3}}
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_exit","name":"run_code","arguments":"{\"code\":\"process.exit(7)\",\"description\":\"End execution process to verify state loss\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790153754406,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790153754406,"index":0,"dt":[],"id":"call_exit","name":"run_code","args":["{\"code\":\"process.exit(7)\",\"description\":\"End execution process to verify state loss\"}"]},{"type":"chunk","time":1790153754406,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_exit","name":"run_code","arguments":"{\"code\":\"process.exit(7)\",\"description\":\"End execution process to verify state loss\"}"}}},{"type":"chunk","time":1790153754406,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790153754406,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":3,"callId":"call_exit","name":"run_code","arguments":"{\"code\":\"process.exit(7)\",\"description\":\"End execution process to verify state loss\"}"}}
{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"call_exit"},"toolCallId":"call_exit","content":[{"type":"text","text":"Error: code run failed (worker-exit): persistent Node process stopped; retained variables were cleared"}],"isError":true,"id":"{{message:9}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[26],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"call_exit"},"toolCallId":"call_exit","content":[{"type":"text","text":"Error: code run failed (worker-exit): Node process exited (7, signal null); retained variables were cleared"}],"isError":true,"id":"{{message:9}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[26],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":3}}
{"type":"step/start","data":{"turn":1,"step":4}}
{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_recover","name":"run_code","arguments":"{\"code\":\"let missing; try { counter.value } catch (error) { missing = error.name === \\\"ReferenceError\\\" }; const fs2 = await import(\\\"node:fs/promises\\\"); let recovered = { value: 0 }; await fs2.appendFile(\\\"persistent-count.txt\\\", \\\"recreated\\\\n\\\"); ({ missing, recreated: recovered.value })\",\"description\":\"Recreate variables after execution process exits\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:10}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1790153754442,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1790153754442,"index":0,"dt":[],"id":"call_recover","name":"run_code","args":["{\"code\":\"let missing; try { counter.value } catch (error) { missing = error.name === \\\"ReferenceError\\\" }; const fs2 = await import(\\\"node:fs/promises\\\"); let recovered = { value: 0 }; await fs2.appendFile(\\\"persistent-count.txt\\\", \\\"recreated\\\\n\\\"); ({ missing, recreated: recovered.value })\",\"description\":\"Recreate variables after execution process exits\"}"]},{"type":"chunk","time":1790153754442,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_recover","name":"run_code","arguments":"{\"code\":\"let missing; try { counter.value } catch (error) { missing = error.name === \\\"ReferenceError\\\" }; const fs2 = await import(\\\"node:fs/promises\\\"); let recovered = { value: 0 }; await fs2.appendFile(\\\"persistent-count.txt\\\", \\\"recreated\\\\n\\\"); ({ missing, recreated: recovered.value })\",\"description\":\"Recreate variables after execution process exits\"}"}}},{"type":"chunk","time":1790153754442,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1790153754442,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
@@ -33,7 +33,7 @@ Use subagent in the background by default. Start independent delegations togethe
## Writing code for run_code
`run_code` takes `code`, TypeScript REPL statements with erasable types and top-level `await`, and `description`, a short summary. Use `await import(...)` for modules. The declarations below describe program-only SDK bindings. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`:
`run_code` takes `code`, TypeScript REPL statements with erasable types (no `enum` or namespaces) and top-level `await`, and `description`, a short summary. Use `await import(...)` for modules. The declarations below describe program-only SDK bindings. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`:
`run_code({ code: "await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })`
@@ -42,7 +42,7 @@ Inside the program:
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.
- Emit results with `console.log(...)` or a final JSON expression. Top-level `return` is not supported. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
- Emit results with `console.log(...)` or a final JSON expression. Top-level `return` is not supported. Only printed values and that final expression become program output; curate them. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
Program-only SDK bindings:
@@ -2,7 +2,7 @@
"initial": [
{
"name": "run_code",
"description": "Execute TypeScript in an in-memory REPL. Top-level declarations and await are supported; use console.log or a final JSON expression for output. Top-level return is not supported. Call tools as await tools.name(args) using the declarations in the system prompt. Image-bearing subtool results are attached after the run. 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, 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. Await all tool calls; background callbacks cannot call tools after their evaluation ends. The working directory is the Session's current directory.",
"description": "Execute TypeScript in an in-memory REPL. Top-level declarations and await are supported; only erasable TypeScript is accepted, with no enum or namespaces. Use console.log or a final JSON expression for output. Only printed values and that final expression become program output; curate them. Top-level return is not supported. Call tools as await tools.name(args) using the declarations in the system prompt. Image-bearing subtool results are attached after the run. 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. The working directory is the Session's current directory.",
"parameters": {
"type": "object",
"properties": {