fix(ptc): contain retained evaluation failures and output
This commit is contained in:
1 parent
75ae02da4a
commit
6a8483ce42
17 files changed
+261
-109
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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\`\`\``
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -78,11 +78,11 @@ The optional `@deepseek-ai/dsh-ptc-runtime-node/persistent` entry replaces the d
|
||||
|
||||
Save the patch and pass its path to `dsh headless --patch <path>` in a composition with PTC tool presentation. Node applications still launch through a named `dsh` profile. Packaged single-executable hosts require `nodeExecutable` pointing to Node and `bootstrapPath` pointing to this package's built `persistent-process.js`.
|
||||
|
||||
Each live Agent keeps its own process and top-level variables, functions, and imported modules across `run_code` calls and turns. Use `await import(...)`, `console.log(...)`, and a final JSON expression; top-level `return` is not supported. Node REPL declaration rules apply, including errors on redeclaring existing lexical names. A leading `{ ... }` is a statement block and can contain top-level await and temporary declarations; parenthesize an object expression to return it. Retained functions and saved tool methods call the current evaluation's bindings. All tool calls must be awaited; callbacks from a completed evaluation cannot invoke tools in a later evaluation.
|
||||
Each live Agent keeps its own process and top-level variables, functions, and imported modules across `run_code` calls and turns. Use `await import(...)`, `console.log(...)`, and a final JSON expression; top-level `return` is not supported. Node REPL declaration rules apply, including errors on redeclaring existing `let`, `const`, or `class` names; assign to a mutable binding or choose a new name. A leading `{ ... }` is a statement block and can contain top-level await and temporary declarations; parenthesize an object expression to return it. Retained functions and saved tool methods call the current evaluation's bindings. All tool calls must be awaited; callbacks from a completed evaluation cannot invoke tools in a later evaluation.
|
||||
|
||||
Memory is best-effort and never saved to disk. Restart, Agent disposal, provider unload, cancellation, timeout, protocol failure, output overflow, or process exit clears it. Missing variables raise normal reference errors; reacquire handles and recreate declarations. Ordinary program exceptions preserve any earlier mutations. The Session still records programs, tool dispatches and visible results; restoring a Session or forking an Agent starts with empty memory. Direct Node access remains unsandboxed; explicit sandbox policies are rejected.
|
||||
Memory is best-effort and never saved to disk. Restart, Agent disposal, provider unload, cancellation, timeout, protocol failure, output overflow, or process exit clears it. Missing variables raise normal reference errors; reacquire handles and recreate declarations. Ordinary synchronous and asynchronous program exceptions settle once and preserve any earlier mutations. The Session still records programs, tool dispatches and visible results; restoring a Session or forking an Agent starts with empty memory. Direct Node access remains unsandboxed; explicit sandbox policies are rejected.
|
||||
|
||||
Service consumers pass an object as `stateOwner` to reuse its environment and await `release(owner)` when finished. Requests without an owner run in temporary processes. Concurrent runs for one owner reject; distinct owners may run concurrently. An idle environment has no expiration timer and keeps its process until release or failure.
|
||||
Service consumers pass an object as `stateOwner` to reuse its environment and await `release(owner)` when finished. Requests without an owner run in temporary processes. Concurrent runs for one owner reject; distinct owners may run concurrently. An idle environment has no expiration timer and keeps its process until release or failure. Raw stdout/stderr 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`.
|
||||
|
||||
@@ -78,11 +78,11 @@ kind: "package-reference"
|
||||
|
||||
保存补丁,在启用 PTC 工具呈现的组合中使用 `dsh headless --patch <path>` 传入路径。Node 应用仍通过具名 `dsh` 配置档启动。打包为单个可执行文件的宿主需要将 `nodeExecutable` 指向 Node,将 `bootstrapPath` 指向本包构建的 `persistent-process.js`。
|
||||
|
||||
每个存活 Agent 保留独立进程,跨 `run_code` 调用和轮次保留顶层变量、函数及导入模块。使用 `await import(...)`、`console.log(...)` 和末尾 JSON 表达式;不支持顶层 `return`。声明遵循 Node REPL 规则,包括重复声明已有词法名称时报错。开头的 `{ ... }` 按语句块执行,可以包含顶层 await 和临时声明;需要返回对象表达式时,用圆括号包裹。保留的函数和工具方法引用调用当前执行的绑定。必须等待所有工具调用;已完成执行的回调不能在后续执行中调用工具。
|
||||
每个存活 Agent 保留独立进程,跨 `run_code` 调用和轮次保留顶层变量、函数及导入模块。使用 `await import(...)`、`console.log(...)` 和末尾 JSON 表达式;不支持顶层 `return`。声明遵循 Node REPL 规则,包括重复声明已有 `let`、`const` 或 `class` 名称时报错;应给可变绑定赋值或选择新名称。开头的 `{ ... }` 按语句块执行,可以包含顶层 await 和临时声明;需要返回对象表达式时,用圆括号包裹。保留的函数和工具方法引用调用当前执行的绑定。必须等待所有工具调用;已完成执行的回调不能在后续执行中调用工具。
|
||||
|
||||
内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、取消、超时、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。普通程序异常保留此前发生的修改。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。直接 Node 访问没有沙盒约束;显式沙盒策略会被拒绝。
|
||||
内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、取消、超时、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。普通同步和异步程序异常只结算一次,并保留此前发生的修改。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。直接 Node 访问没有沙盒约束;显式沙盒策略会被拒绝。
|
||||
|
||||
服务消费者通过 `stateOwner` 对象复用环境,并在结束时等待 `release(owner)`。没有所有者的请求在临时进程中执行。同一所有者的并发调用会被拒绝;不同所有者可以并发。空闲环境没有过期计时器,进程保留到释放或失败为止。
|
||||
服务消费者通过 `stateOwner` 对象复用环境,并在结束时等待 `release(owner)`。没有所有者的请求在临时进程中执行。同一所有者的并发调用会被拒绝;不同所有者可以并发。空闲环境没有过期计时器,进程保留到释放或失败为止。执行期间收到的原始 stdout/stderr 计入本次输出预算;空闲期间收到的字节会被丢弃。进程提前退出的诊断包含退出码和保留的 stderr 尾部。
|
||||
|
||||
### 截止时间与取消
|
||||
|
||||
@@ -160,7 +160,8 @@ Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与
|
||||
- **约束继承所选后端的限制**——完整与部分强制能力分开报告;沙箱策略与受管进程约束是不同保证。
|
||||
- **堆上限不是进程树内存限制**——原生分配与后代进程内存不属于 V8 老生代上限。不提供进程树 CPU 计量器。
|
||||
- **清理继承子进程的可观测范围**——使用 fallback 的平台上,逃逸的后代可能仍在受管范围之外;参见子进程提供方声明的限制。
|
||||
- **执行是一次性的**——没有 yield/wait API、实时结果流或跨调用保留的程序状态。
|
||||
- **默认执行是一次性的**——保留状态需要可选的持久提供方;两个提供方都不提供 yield/wait API。
|
||||
- **持久环境为每个存活所有者保留一个进程**——空闲所有者保留进程及堆内存,直到显式释放、所有者销毁或执行失败。并行创建多个所有者会保留多个进程;空闲过期机制留待后续处理。
|
||||
- **输出上限拒绝超量内容,而不保留每个字节**——溢出只能保存本提供方交付的有界结果。
|
||||
- **绑定在传输接纳时受限**——控制限制不约束 Host 绑定生成结果期间分配的内存。
|
||||
- **console shim 有五个方法**——`log`、`info`、`warn`、`error` 与 `debug`。
|
||||
|
||||
@@ -334,7 +334,7 @@ export function makeNamespaces(
|
||||
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId.value++
|
||||
const id = nextId.value
|
||||
pending.set(id, {
|
||||
resolve,
|
||||
reject: (error) => {
|
||||
@@ -343,9 +343,10 @@ export function makeNamespaces(
|
||||
})
|
||||
try {
|
||||
port.postMessage({ type: 'call', id, global, name, args: encodePtcJsonWire(detached) })
|
||||
nextId.value += 1
|
||||
} catch (error: unknown) {
|
||||
pending.delete(id)
|
||||
const message = `binding arguments must be structured-cloneable: ${error instanceof CapturedError ? error.message : String(error)}`
|
||||
const message = `binding call could not be sent: ${error instanceof CapturedError ? error.message : String(error)}`
|
||||
reject(bindingFailure(errorClass, name, message))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Shared configuration for the isolated and persistent Node providers. */
|
||||
import { isAbsolute } from 'node:path'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { LaunchConfig } from './launch.ts'
|
||||
|
||||
/** Deployment-varying runtime bounds and launch choices. */
|
||||
export interface Config extends LaunchConfig {
|
||||
/** Default elapsed deadline, including nested tool and approval waits. */
|
||||
timeoutMs?: number
|
||||
/** Maximum numeric elapsed budget accepted by resolve. */
|
||||
maxTimeoutMs?: number
|
||||
/** Combined serialized logs, completion and diagnostic byte cap. */
|
||||
maxOutputBytes?: number
|
||||
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
/** Maximum control frame, outstanding argument and queued control-output bytes. */
|
||||
maxMessageBytes?: number
|
||||
/** Maximum simultaneous host binding calls accepted from a program. */
|
||||
maxPendingCalls?: number
|
||||
/** Managed process termination and output-drain grace in milliseconds. */
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
/** Configuration after Loader defaults and executable selection. */
|
||||
export type ResolvedConfig = Required<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
|
||||
/** Loader defaults shared by both Node providers. */
|
||||
export const Config: z<Config> = z.object({
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(67_108_864),
|
||||
maxOldGenerationSizeMb: z.number().default(512),
|
||||
maxMessageBytes: z.number().default(134_217_728),
|
||||
maxPendingCalls: z.number().default(128),
|
||||
graceMs: z.number().default(3_000),
|
||||
nodeExecutable: z.string(),
|
||||
bootstrapPath: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate resolved Node limits and paths.
|
||||
* @param config - Loader-defaulted fields with the selected executable.
|
||||
*/
|
||||
export function validateConfig(config: ResolvedConfig): void {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (typeof value === 'number' && (!Number.isFinite(value) || value <= 0)) throw new Error(`ptc-runtime-node: ${key} must be positive and finite`)
|
||||
}
|
||||
for (const key of ['timeoutMs', 'maxTimeoutMs', 'graceMs'] as const) {
|
||||
if (config[key] > MAX_TIMER_DELAY_MS) throw new Error(`ptc-runtime-node: ${key} exceeds the supported timer range`)
|
||||
}
|
||||
if (!Number.isSafeInteger(config.maxOutputBytes) || config.maxOutputBytes < 4) throw new Error('ptc-runtime-node: maxOutputBytes must be an integer of at least 4')
|
||||
if (!Number.isSafeInteger(config.maxMessageBytes) || config.maxMessageBytes > 0xffff_ffff) throw new Error('ptc-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length')
|
||||
if (!Number.isSafeInteger(config.maxPendingCalls)) throw new Error('ptc-runtime-node: maxPendingCalls must be an integer')
|
||||
if (!Number.isSafeInteger(config.maxOldGenerationSizeMb)) throw new Error('ptc-runtime-node: maxOldGenerationSizeMb must be an integer')
|
||||
if (config.nodeExecutable.length === 0) throw new Error('ptc-runtime-node: nodeExecutable must be non-empty')
|
||||
if (config.bootstrapPath !== undefined && !isAbsolute(config.bootstrapPath)) throw new Error('ptc-runtime-node: bootstrapPath must be absolute')
|
||||
}
|
||||
@@ -2,10 +2,9 @@
|
||||
import { stripTypeScriptTypes } from 'node:module'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
|
||||
import type { PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime'
|
||||
import { MAX_TIMER_DELAY_MS, clampTimeout } from '@deepseek-ai/dsh-timeout'
|
||||
import { clampTimeout } from '@deepseek-ai/dsh-timeout'
|
||||
import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
@@ -14,32 +13,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<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
|
||||
interface LiveRun { controller: AbortController; finished: Promise<void> }
|
||||
const STRIP_PREFIX = 'async function __dsh_program__() {\n'
|
||||
const STRIP_SUFFIX = '\n}'
|
||||
@@ -50,17 +31,7 @@ function record(value: unknown): value is Record<string, unknown> { return typeo
|
||||
/** Node provider; direct file effects use the same sandbox service as Bash. */
|
||||
export class NodePtcRuntime extends PtcRuntime {
|
||||
static inject = ['fs', 'subprocess', 'sandbox', 'sandboxPolicy']
|
||||
static Config: z<Config> = z.object({
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(67_108_864),
|
||||
maxOldGenerationSizeMb: z.number().default(512),
|
||||
maxMessageBytes: z.number().default(134_217_728),
|
||||
maxPendingCalls: z.number().default(128),
|
||||
graceMs: z.number().default(3_000),
|
||||
nodeExecutable: z.string(),
|
||||
bootstrapPath: z.string(),
|
||||
})
|
||||
static Config = 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]
|
||||
|
||||
@@ -7,7 +7,7 @@ export class OutputLedger {
|
||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||
private entries = 0
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
constructor(private readonly maxBytes: number, private readonly overflowMessage?: string) {}
|
||||
|
||||
/** Admit one exact log entry, or report that the hard cap was crossed. */
|
||||
admit(text: string, sink: string[]): boolean {
|
||||
@@ -34,7 +34,7 @@ export class OutputLedger {
|
||||
|
||||
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
|
||||
limit(logs: string[]): PtcRunResult {
|
||||
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
||||
const fullMessage = this.overflowMessage ?? `outer output exceeded ${this.maxBytes} bytes`
|
||||
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
|
||||
const messageBytes = fullMessage.length + 2
|
||||
const retained: string[] = []
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface PersistentProcess {
|
||||
interface Evaluation {
|
||||
id: number
|
||||
logs: LogBuffer
|
||||
maxOutputBytes: number
|
||||
active: boolean
|
||||
finish(result: Omit<DoneMessage, 'type'>): void
|
||||
}
|
||||
@@ -43,7 +44,7 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number,
|
||||
const output = new Writable({ write(chunk: Buffer, _encoding, callback) {
|
||||
const current = context.getStore()
|
||||
const message = chunk.toString().trim()
|
||||
if (current?.active && message) current.finish({ error: { kind: 'exception', message } })
|
||||
if (current?.active && message) current.finish(prepareException(message, current.logs.remainingOutputBytes(), current.maxOutputBytes))
|
||||
callback()
|
||||
} })
|
||||
const repl = start({ input, output, terminal: false, prompt: '', useGlobal: false, ignoreUndefined: true })
|
||||
@@ -76,8 +77,9 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number,
|
||||
const { id, data } = message
|
||||
const logs = new LogBuffer(data.maxOutputBytes, (text) => { send(id, { type: 'log', text }) }, () => { send(id, { type: 'output-limit' }) })
|
||||
const evaluation: Evaluation = {
|
||||
id, logs, active: true,
|
||||
id, logs, maxOutputBytes: data.maxOutputBytes, active: true,
|
||||
finish(result) {
|
||||
if (!evaluation.active) return
|
||||
evaluation.active = false
|
||||
for (const call of pending.values()) call.reject(new Error('PTC evaluation ended before the binding completed'))
|
||||
pending.clear()
|
||||
|
||||
@@ -4,23 +4,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<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
|
||||
export type { Config } from './config.ts'
|
||||
const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error)
|
||||
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
function persistentOutput(maxBytes: number): OutputLedger {
|
||||
return new OutputLedger(maxBytes, `retained variables were cleared; outer output exceeded ${maxBytes} bytes`)
|
||||
}
|
||||
|
||||
interface Evaluation {
|
||||
id: number
|
||||
data: ProgramBootData
|
||||
@@ -40,6 +43,7 @@ class Kernel {
|
||||
private ready = false
|
||||
private sequence = 0
|
||||
private nextCall = 1
|
||||
private stderr = ''
|
||||
private stopped?: Promise<void>
|
||||
private starting?: Promise<void>
|
||||
private readonly controller = new AbortController()
|
||||
@@ -47,9 +51,14 @@ class Kernel {
|
||||
|
||||
constructor(private readonly ctx: Context, private readonly config: ResolvedConfig) {}
|
||||
|
||||
async run(spec: PtcRunSpec, bindings: Map<string, PtcBindingNamespace>, code: string): Promise<PtcRunResult> {
|
||||
assertIdle(): void {
|
||||
if (this.active) throw new Error('persistent PTC does not accept concurrent runs for one state owner')
|
||||
const output = new OutputLedger(this.config.maxOutputBytes)
|
||||
}
|
||||
|
||||
async run(spec: PtcRunSpec, bindings: Map<string, PtcBindingNamespace>, code: string): Promise<PtcRunResult> {
|
||||
this.assertIdle()
|
||||
this.stderr = ''
|
||||
const output = persistentOutput(this.config.maxOutputBytes)
|
||||
const logs: string[] = []
|
||||
const result = Promise.withResolvers<PtcRunResult>()
|
||||
const timer = spec.timeoutMs === null ? undefined : setTimeout(() => {
|
||||
@@ -69,12 +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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import FileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import Subprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
@@ -54,6 +55,47 @@ describe('persistent Node REPL', () => {
|
||||
expect(await run('({ total, local: typeof amount })')).toEqual({ logs: [], value: { total: 5, local: 'undefined' } })
|
||||
})
|
||||
|
||||
it('settles an asynchronous exception once while a later evaluation awaits a tool', async () => {
|
||||
const { run } = await setup()
|
||||
const failed = await run('let retained = 17; let resume; const paused = new Promise(resolve => { resume = resolve }); setImmediate(() => { throw new Error("async failure") }); await paused; retained')
|
||||
expect(failed.error?.message).toContain('async failure')
|
||||
const next = await run('resume(); await tools.value({})', { bindings: bindings(async () => { await setImmediate(); return 42 }) })
|
||||
expect(next).toEqual({ logs: [], value: 42 })
|
||||
expect((await run('retained')).value).toBe(17)
|
||||
})
|
||||
|
||||
it('rejects a completed evaluation callback without consuming the next real binding identity', async () => {
|
||||
const { run } = await setup()
|
||||
const called = vi.fn(async () => 42)
|
||||
const options = { bindings: bindings(called) }
|
||||
await run('let retained = 17; let trigger; let rejection; const expired = new Promise(resolve => { trigger = resolve }); void expired.then(async () => { try { await tools.value({}) } catch (error) { rejection = error.message } });', options)
|
||||
expect((await run('trigger(); await new Promise(resolve => setImmediate(resolve)); rejection', options)).value).toContain('evaluation has ended')
|
||||
expect(called).not.toHaveBeenCalled()
|
||||
expect(await run('({ retained, answer: await tools.value({}) })', options)).toEqual({ logs: [], value: { retained: 17, answer: 42 } })
|
||||
expect(called).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('captures inherited child stdout and stderr without losing retained variables', async () => {
|
||||
const { run } = await setup()
|
||||
const childProgram = 'process.stdout.write("stdout marker"); process.stderr.write("stderr marker")'
|
||||
const result = await run(`let retained = 17; const child = await import("node:child_process"); child.execFileSync(process.execPath, ["-e", ${JSON.stringify(childProgram)}], { stdio: "inherit" }); retained`)
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe(17)
|
||||
expect(result.logs.join('')).toContain('stdout marker')
|
||||
expect(result.logs.join('')).toContain('stderr marker')
|
||||
expect((await run('retained')).value).toBe(17)
|
||||
})
|
||||
|
||||
it('reports direct process exit status and stderr after draining raw output', async () => {
|
||||
const { run } = await setup()
|
||||
const result = await run('const nativeFs = await import("node:fs"); nativeFs.writeSync(2, "fatal fixture"); process.exit(7)')
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('7')
|
||||
expect(result.error?.message).toContain('fatal fixture')
|
||||
expect(result.logs.join('')).toContain('fatal fixture')
|
||||
expect((await run('42')).value).toBe(42)
|
||||
})
|
||||
|
||||
it('isolates owners and uses fresh state when no owner is supplied', async () => {
|
||||
const { run, runtime, cwd } = await setup()
|
||||
const temporary = (program: string) => runtime.run(runtime.resolve({ program, bindings: [], cwd }))
|
||||
@@ -117,6 +159,7 @@ describe('persistent Node REPL', () => {
|
||||
try {
|
||||
await entered.promise
|
||||
await expect(run('1')).rejects.toThrow('concurrent runs')
|
||||
await expect(run('const =')).rejects.toThrow('concurrent runs')
|
||||
expect((await run('2', { stateOwner: {} })).value).toBe(2)
|
||||
} finally { proceed.resolve(null) }
|
||||
expect((await pending).value).toBeNull()
|
||||
@@ -147,6 +190,40 @@ describe('persistent Node REPL', () => {
|
||||
expect((await run('42', { timeoutMs: null })).value).toBe(42)
|
||||
})
|
||||
|
||||
it.each(['throw new Error("long diagnostic ".repeat(1000))', '"large completion".repeat(1000)'])('clears retained state after a final output overflow: %s', async (program) => {
|
||||
const { run } = await setup({ maxOutputBytes: 100 })
|
||||
await run('let retained = true;')
|
||||
const result = await run(program)
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(result.error?.message).toContain('retained variables were cleared')
|
||||
const outputBytes = Buffer.byteLength(JSON.stringify(result.logs)) + Buffer.byteLength(JSON.stringify(result.error?.message))
|
||||
expect(outputBytes).toBeLessThanOrEqual(100)
|
||||
expect((await run('typeof retained')).value).toBe('undefined')
|
||||
})
|
||||
|
||||
it('bounds type-stripping diagnostics and clears the owner after a parse-output overflow', async () => {
|
||||
const { run } = await setup({ maxOutputBytes: 32 })
|
||||
await run('let retained = true;')
|
||||
const result = await run('const =')
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
const outputBytes = Buffer.byteLength(JSON.stringify(result.logs)) + Buffer.byteLength(JSON.stringify(result.error?.message))
|
||||
expect(outputBytes).toBeLessThanOrEqual(32)
|
||||
expect((await run('typeof retained')).value).toBe('undefined')
|
||||
})
|
||||
|
||||
it('rejects a packaged host without a persistent bootstrap during provider loading', async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(process, 'pkg')
|
||||
try {
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
await expect(setup()).rejects.toThrow('requires an explicit bootstrapPath')
|
||||
const { runtime } = await setup({ bootstrapPath: join(process.cwd(), 'persistent-process.js') })
|
||||
expect(runtime.programFormat).toBe('repl')
|
||||
} finally {
|
||||
if (previous === undefined) Reflect.deleteProperty(process, 'pkg')
|
||||
else Object.defineProperty(process, 'pkg', previous)
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds output and clears state after an overflow', async () => {
|
||||
const { run } = await setup({ maxOutputBytes: 100 })
|
||||
expect((await run('let retained = true; console.log("x".repeat(1000))')).error?.kind).toBe('output-limit')
|
||||
|
||||
@@ -121,7 +121,7 @@ export abstract class PtcRuntime extends Service {
|
||||
*/
|
||||
abstract readonly isolation: string
|
||||
|
||||
/** Source evaluation form; REPL providers retain declarations for an explicitly supplied state owner. */
|
||||
/** Source evaluation form, independent of whether the provider retains state for an owner. */
|
||||
get programFormat(): 'function-body' | 'repl' { return 'function-body' }
|
||||
|
||||
/**
|
||||
|
||||
@@ -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\"}"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user