From 6a8483ce42a36548f1a9f4a0859f432304675c33 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 24 Sep 2026 22:44:56 +0800 Subject: [PATCH 1/6] fix(ptc): contain retained evaluation failures and output --- packages/core/tools/src/ptc.ts | 4 +- packages/core/tools/src/ts-types.ts | 16 ++-- packages/core/tools/tests/ptc.spec.ts | 8 +- .../ptc-runtime-node/README.i18n.yaml | 4 +- .../ptc-runtime/ptc-runtime-node/README.md | 9 ++- .../ptc-runtime/ptc-runtime-node/README.zh.md | 9 ++- .../ptc-runtime-node/src/bootstrap.ts | 5 +- .../ptc-runtime-node/src/config.ts | 57 ++++++++++++++ .../ptc-runtime/ptc-runtime-node/src/index.ts | 50 ++---------- .../ptc-runtime-node/src/output-ledger.ts | 4 +- .../src/persistent-process.ts | 6 +- .../ptc-runtime-node/src/persistent.ts | 76 ++++++++++++------ .../tests/persistent-host.spec.ts | 37 +++++++-- .../tests/persistent-process.spec.ts | 2 + .../ptc-runtime-node/tests/persistent.spec.ts | 77 +++++++++++++++++++ packages/ptc-runtime/ptc-runtime/src/index.ts | 2 +- .../ptc-persistent/replay.override.json | 4 +- 17 files changed, 261 insertions(+), 109 deletions(-) create mode 100644 packages/ptc-runtime/ptc-runtime-node/src/config.ts diff --git a/packages/core/tools/src/ptc.ts b/packages/core/tools/src/ptc.ts index d6030cb184..11f5aaa613 100644 --- a/packages/core/tools/src/ptc.ts +++ b/packages/core/tools/src/ptc.ts @@ -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()) diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index 71f507fa8a..4098ec06e9 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -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\`\`\`` } diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index 977c3df034..479740cbea 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -2373,16 +2373,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) diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 49e5fe6db8..5dbbaf85d1 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -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: 3a9d2c9169044efb55b1da6c4feb874ee5881089 +README.zh.md: 7a69b37eb6151b965e44a026ba0afc632a27d0af diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 0136fed8d2..3a9d2c9169 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -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 ` 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 received during an evaluation share its output budget; idle bytes are discarded. 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`. diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 34914efa1d..7a69b37eb6 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -78,11 +78,11 @@ kind: "package-reference" 保存补丁,在启用 PTC 工具呈现的组合中使用 `dsh headless --patch ` 传入路径。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`。 diff --git a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts index 227c4c7256..6d3a21aa34 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts @@ -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)) } }) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/config.ts b/packages/ptc-runtime/ptc-runtime-node/src/config.ts new file mode 100644 index 0000000000..f4ded5484e --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/src/config.ts @@ -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> & Pick +/** Loader defaults shared by both Node providers. */ +export const Config: z = 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') +} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/index.ts b/packages/ptc-runtime/ptc-runtime-node/src/index.ts index 31af8e705b..73a2038265 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/index.ts @@ -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,14 @@ 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 { Config, validateConfig, type 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> & Pick interface LiveRun { controller: AbortController; finished: Promise } const STRIP_PREFIX = 'async function __dsh_program__() {\n' const STRIP_SUFFIX = '\n}' @@ -50,17 +31,7 @@ function record(value: unknown): value is Record { 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 = 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 = Config readonly language = 'typescript' readonly isolation = 'process' override get executionInstructions(): string { @@ -73,18 +44,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] diff --git a/packages/ptc-runtime/ptc-runtime-node/src/output-ledger.ts b/packages/ptc-runtime/ptc-runtime-node/src/output-ledger.ts index 572d089dc1..6bbd22ed33 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/output-ledger.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/output-ledger.ts @@ -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[] = [] diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts index 67289b09d7..95760ed107 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts @@ -19,6 +19,7 @@ export interface PersistentProcess { interface Evaluation { id: number logs: LogBuffer + maxOutputBytes: number active: boolean finish(result: Omit): 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() diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts index a0e54a9a7c..cee2adb817 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -4,23 +4,26 @@ 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 { Config, validateConfig, type 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> & Pick +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 => 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 +43,7 @@ class Kernel { private ready = false private sequence = 0 private nextCall = 1 + private stderr = '' private stopped?: Promise private starting?: Promise private readonly controller = new AbortController() @@ -47,9 +51,14 @@ class Kernel { constructor(private readonly ctx: Context, private readonly config: ResolvedConfig) {} - async run(spec: PtcRunSpec, bindings: Map, code: string): Promise { + 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, code: string): Promise { + this.assertIdle() + this.stderr = '' + const output = persistentOutput(this.config.maxOutputBytes) const logs: string[] = [] const result = Promise.withResolvers() const timer = spec.timeoutMs === null ? undefined : setTimeout(() => { @@ -69,12 +78,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 +142,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 +189,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 +228,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 = Config 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.' } override get timeout(): { defaultMs: number; maxMs: number } { return { defaultMs: Math.min(this.config.timeoutMs, this.config.maxTimeoutMs), maxMs: this.config.maxTimeoutMs } @@ -221,12 +246,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 +277,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) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index 412ca582ce..2f52ad7fd4 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -184,17 +184,40 @@ 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('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 +238,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') } }) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts index d265325e2a..bc6e58db86 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts @@ -92,6 +92,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 () => { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts index c98c1a42fb..6ad0b0410e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts @@ -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') diff --git a/packages/ptc-runtime/ptc-runtime/src/index.ts b/packages/ptc-runtime/ptc-runtime/src/index.ts index 70b80af1f8..9691382d85 100644 --- a/packages/ptc-runtime/ptc-runtime/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime/src/index.ts @@ -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' } /** diff --git a/snapshots/session/ptc-persistent/replay.override.json b/snapshots/session/ptc-persistent/replay.override.json index ff0a3998dc..b5ffcdfdce 100644 --- a/snapshots/session/ptc-persistent/replay.override.json +++ b/snapshots/session/ptc-persistent/replay.override.json @@ -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\"}" } }, { From c5dff80c94bf2ce47aec3516bf95e4cc42b78753 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 24 Sep 2026 22:47:59 +0800 Subject: [PATCH 2/6] test(ptc): give tool bridge fixtures owned agent contexts --- packages/core/tools/tests/ptc.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index 479740cbea..8dfe2c79a0 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -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, signal = testToolSignal) => state.tools.execute({ callId: ToolCallId('program-controls'), name: RUN_CODE_NAME, arguments: { code: 'program', description: 'Test execution controls', ...args }, agent, signal, From 7101555061f082a3dd62d62ada91f81a428ec32b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 24 Sep 2026 22:52:52 +0800 Subject: [PATCH 3/6] test(ptc): cover retained process races and refresh model evidence --- .../tests/persistent-host.spec.ts | 34 +++++++++++++++++++ .../tests/persistent-process.spec.ts | 20 +++++++++++ scripts/gen-config-catalog.spec.ts | 16 +++++++++ scripts/gen-config-catalog.ts | 4 ++- .../session/ptc-persistent/session.v4.jsonl | 8 ++--- .../ptc-persistent/system-prompt.expected.md | 4 +-- .../ptc-persistent/tool-schemas.expected.json | 2 +- 7 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index 2f52ad7fd4..8f6695aa7b 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -208,6 +208,40 @@ describe('persistent PTC host protocol', () => { 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() diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts index bc6e58db86..8bc1037e5f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts @@ -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') diff --git a/scripts/gen-config-catalog.spec.ts b/scripts/gen-config-catalog.spec.ts index 321bdc38ca..71ec7a10d1 100644 --- a/scripts/gen-config-catalog.spec.ts +++ b/scripts/gen-config-catalog.spec.ts @@ -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(collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider')?.schemaKeys) + .toEqual(['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") diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index 2be0cf424e..831f67b05f 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -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 } diff --git a/snapshots/session/ptc-persistent/session.v4.jsonl b/snapshots/session/ptc-persistent/session.v4.jsonl index 1739f46b80..da086aa3a5 100644 --- a/snapshots/session/ptc-persistent/session.v4.jsonl +++ b/snapshots/session/ptc-persistent/session.v4.jsonl @@ -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"} diff --git a/snapshots/session/ptc-persistent/system-prompt.expected.md b/snapshots/session/ptc-persistent/system-prompt.expected.md index 53caa48962..20b82f89e0 100644 --- a/snapshots/session/ptc-persistent/system-prompt.expected.md +++ b/snapshots/session/ptc-persistent/system-prompt.expected.md @@ -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: diff --git a/snapshots/session/ptc-persistent/tool-schemas.expected.json b/snapshots/session/ptc-persistent/tool-schemas.expected.json index 91c754e50f..c0f07f0ca6 100644 --- a/snapshots/session/ptc-persistent/tool-schemas.expected.json +++ b/snapshots/session/ptc-persistent/tool-schemas.expected.json @@ -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. The working directory is the Session's current directory.", "parameters": { "type": "object", "properties": { From 9e17500315a984043cf043081474d279634c679c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 24 Sep 2026 22:55:09 +0800 Subject: [PATCH 4/6] fix(ptc): separate shared schema value from config type --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/ptc-runtime/ptc-runtime-node/src/config.ts | 2 +- packages/ptc-runtime/ptc-runtime-node/src/index.ts | 5 +++-- packages/ptc-runtime/ptc-runtime-node/src/persistent.ts | 5 +++-- scripts/gen-config-catalog.spec.ts | 4 ++-- 7 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 13f55f8272..06aa019d80 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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: d27ec194be5a2fe5e99346b4315f8667c6e56015 -config-catalog.zh.md: ae76fe18bea4ab0a6f1efb1bfc946e0b019ba5aa +config-catalog.md: 30a60ddd57b724b09cf5fdf680526871b5842b4a +config-catalog.zh.md: db2501de36ee9985964e717d0c01123497286929 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d27ec194be..30a60ddd57 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2246,7 +2246,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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ae76fe18be..db2501de36 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2248,7 +2248,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) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/config.ts b/packages/ptc-runtime/ptc-runtime-node/src/config.ts index f4ded5484e..51b60662ba 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/config.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/config.ts @@ -25,7 +25,7 @@ export interface Config extends LaunchConfig { /** Configuration after Loader defaults and executable selection. */ export type ResolvedConfig = Required> & Pick /** Loader defaults shared by both Node providers. */ -export const Config: z = z.object({ +export const NodeConfig: z = z.object({ timeoutMs: z.number().default(120_000), maxTimeoutMs: z.number().default(600_000), maxOutputBytes: z.number().default(67_108_864), diff --git a/packages/ptc-runtime/ptc-runtime-node/src/index.ts b/packages/ptc-runtime/ptc-runtime-node/src/index.ts index 73a2038265..a940f89ce8 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/index.ts @@ -13,7 +13,8 @@ import type {} from '@deepseek-ai/dsh-fs' import { replyToBinding, validateBindings } from './bindings.ts' import { JsonChannel } from './channel.ts' import { bootstrapArgs } from './launch.ts' -import { Config, validateConfig, type ResolvedConfig } from './config.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' @@ -31,7 +32,7 @@ function record(value: unknown): value is Record { 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 = Config + static Config = NodeConfig readonly language = 'typescript' readonly isolation = 'process' override get executionInstructions(): string { diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts index cee2adb817..be46b295b5 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -6,7 +6,8 @@ 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 { Config, validateConfig, type ResolvedConfig } from './config.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' @@ -228,7 +229,7 @@ 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 = Config + static Config = NodeConfig readonly language = 'typescript' readonly isolation = 'process' override get programFormat(): 'repl' { return 'repl' } diff --git a/scripts/gen-config-catalog.spec.ts b/scripts/gen-config-catalog.spec.ts index 71ec7a10d1..7d0f06287c 100644 --- a/scripts/gen-config-catalog.spec.ts +++ b/scripts/gen-config-catalog.spec.ts @@ -80,8 +80,8 @@ export const Config = Shared export function apply(ctx: unknown, config: Config): void {} `) if (invalid) expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden'") - else expect(collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider')?.schemaKeys) - .toEqual(['mode', 'headless', 'endpoint']) + 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', () => { From 0309d2252011319e0db00be045d6a327efa9aa25 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 24 Sep 2026 23:01:33 +0800 Subject: [PATCH 5/6] docs(ptc): clarify raw subprocess output attribution --- packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml | 4 ++-- packages/ptc-runtime/ptc-runtime-node/README.md | 2 +- packages/ptc-runtime/ptc-runtime-node/README.zh.md | 2 +- packages/ptc-runtime/ptc-runtime-node/src/persistent.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 5dbbaf85d1..ed63d91d27 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -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: 3a9d2c9169044efb55b1da6c4feb874ee5881089 -README.zh.md: 7a69b37eb6151b965e44a026ba0afc632a27d0af +README.md: b84edb9a4663446a02dbcb8a67ee1e0f23ca252a +README.zh.md: d81cc59502d4ec5396a6edeb2960fddd79ac60b6 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 3a9d2c9169..b84edb9a46 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -82,7 +82,7 @@ Each live Agent keeps its own process and top-level variables, functions, and im 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. Raw stdout/stderr received during an evaluation share its output budget; idle bytes are discarded. Early process-exit diagnostics include the exit code and retained stderr tail. +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 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 7a69b37eb6..d81cc59502 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -82,7 +82,7 @@ kind: "package-reference" 内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、取消、超时、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。普通同步和异步程序异常只结算一次,并保留此前发生的修改。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。直接 Node 访问没有沙盒约束;显式沙盒策略会被拒绝。 -服务消费者通过 `stateOwner` 对象复用环境,并在结束时等待 `release(owner)`。没有所有者的请求在临时进程中执行。同一所有者的并发调用会被拒绝;不同所有者可以并发。空闲环境没有过期计时器,进程保留到释放或失败为止。执行期间收到的原始 stdout/stderr 计入本次输出预算;空闲期间收到的字节会被丢弃。进程提前退出的诊断包含退出码和保留的 stderr 尾部。 +服务消费者通过 `stateOwner` 对象复用环境,并在结束时等待 `release(owner)`。没有所有者的请求在临时进程中执行。同一所有者的并发调用会被拒绝;不同所有者可以并发。空闲环境没有过期计时器,进程保留到释放或失败为止。原始 stdout/stderr 不携带执行标识:执行期间收到的字节计入本次输出预算,包括较早启动的子进程此时写出的内容;空闲期间收到的字节会被丢弃。结束本次执行前,应等待子进程完成。进程提前退出的诊断包含退出码和保留的 stderr 尾部。 ### 截止时间与取消 diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts index be46b295b5..95c2d82e00 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -234,7 +234,7 @@ export class PersistentNodePtcRuntime extends PtcRuntime { 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.' + 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 } From 2c986ca3c3b1e498943bbbe34b023f9cbe39793c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 24 Sep 2026 23:03:40 +0800 Subject: [PATCH 6/6] test(ptc): pin raw subprocess output guidance --- snapshots/session/ptc-persistent/tool-schemas.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/session/ptc-persistent/tool-schemas.expected.json b/snapshots/session/ptc-persistent/tool-schemas.expected.json index c0f07f0ca6..02f29737eb 100644 --- a/snapshots/session/ptc-persistent/tool-schemas.expected.json +++ b/snapshots/session/ptc-persistent/tool-schemas.expected.json @@ -2,7 +2,7 @@ "initial": [ { "name": "run_code", - "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. 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": {