From 4fb14dcac6785bc214299b0327f46822b8901427 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 23 Sep 2026 16:41:38 +0800 Subject: [PATCH] feat(ptc): add opt-in best-effort persistent Node REPL --- ...09-11-sandboxed-node-ptc-runtime.i18n.yaml | 4 +- .../2026-09-11-sandboxed-node-ptc-runtime.md | 2 + ...026-09-11-sandboxed-node-ptc-runtime.zh.md | 2 + ...09-23-best-effort-persistent-ptc.i18n.yaml | 6 + .../2026-09-23-best-effort-persistent-ptc.md | 29 ++ ...026-09-23-best-effort-persistent-ptc.zh.md | 29 ++ .../feature/2026-06-15-ptc.i18n.yaml | 4 +- .../implemented/feature/2026-06-15-ptc.md | 2 + .../implemented/feature/2026-06-15-ptc.zh.md | 2 + docs/subsystems/ptc-runtime.i18n.yaml | 4 +- docs/subsystems/ptc-runtime.md | 15 +- docs/subsystems/ptc-runtime.zh.md | 15 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/index.ts | 4 +- packages/core/tools/src/ptc.ts | 20 ++ packages/core/tools/src/ts-types.ts | 15 +- packages/ptc-runtime/README.i18n.yaml | 4 +- packages/ptc-runtime/README.md | 2 +- packages/ptc-runtime/README.zh.md | 2 +- .../ptc-runtime-node/README.i18n.yaml | 4 +- .../ptc-runtime/ptc-runtime-node/README.md | 18 ++ .../ptc-runtime/ptc-runtime-node/README.zh.md | 18 ++ .../ptc-runtime/ptc-runtime-node/package.json | 8 +- .../ptc-runtime-node/src/bootstrap.ts | 4 +- .../ptc-runtime-node/src/launch.ts | 16 +- .../src/persistent-process-entry.ts | 5 + .../src/persistent-process.ts | 113 +++++++ .../ptc-runtime-node/src/persistent.ts | 297 ++++++++++++++++++ .../ptc-runtime-node/tests/persistent.spec.ts | 145 +++++++++ .../ptc-runtime-node/tsdown.config.ts | 4 +- .../ptc-runtime/ptc-runtime/README.i18n.yaml | 4 +- packages/ptc-runtime/ptc-runtime/README.md | 12 +- packages/ptc-runtime/ptc-runtime/README.zh.md | 12 +- packages/ptc-runtime/ptc-runtime/src/index.ts | 12 +- packages/ptc-runtime/ptc-runtime/src/types.ts | 13 +- .../workflow/workflow-ptc/README.i18n.yaml | 4 +- packages/workflow/workflow-ptc/README.md | 2 +- packages/workflow/workflow-ptc/README.zh.md | 2 +- packages/workflow/workflow-ptc/src/host.ts | 4 +- tsconfig.base.json | 1 + 42 files changed, 807 insertions(+), 60 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md create mode 100644 .agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md create mode 100644 packages/ptc-runtime/ptc-runtime-node/src/persistent-process-entry.ts create mode 100644 packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts create mode 100644 packages/ptc-runtime/ptc-runtime-node/src/persistent.ts create mode 100644 packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml index f40d8780e9..6bb50069a8 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.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 .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md -2026-09-11-sandboxed-node-ptc-runtime.md: f851775460c5bd01760d31552bde8c691807ec06 -2026-09-11-sandboxed-node-ptc-runtime.zh.md: 94f3a37eb702a97161c1d08cec531fcffb3460f2 +2026-09-11-sandboxed-node-ptc-runtime.md: 7efe06e8a4a48b2c1d4dcb672abc09961f3a2b14 +2026-09-11-sandboxed-node-ptc-runtime.zh.md: 2382b02247ebe6e0c524cd89fa64c8e59cf49797 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md index f851775460..7efe06e8a4 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md @@ -34,6 +34,8 @@ Program completion, timeout, cancellation and protocol failure all close executi The default elapsed deadline is 120 seconds, capped at 600 seconds by default. Trusted service consumers may request `timeoutMs: null` to disable this timer; [workflow sandbox reuse](2026-09-13-workflow-ptc-sandbox-reuse.md) owns that caller-controlled lifetime. Omitted and numeric requests, including model-facing `run_code`, retain the numeric defaults and caps. An enabled deadline includes runtime setup and nested tool or approval waits. V8 old-generation memory, serialized outer output and control traffic have separate configured bounds. The heap limit excludes native allocations and descendant memory, and elapsed time is not a process-tree CPU budget. +The separate [best-effort persistent provider](2026-09-23-best-effort-persistent-ptc.md) retains disposable memory under explicit full access; this decision continues to own the default confined provider. + ## Alternatives considered **Keep the worker and add tool checks.** Tool checks cannot intercept direct Node imports or establish OS confinement. Keeping a worker inside a confined supervisor process preserves worker metering but adds another execution lifetime without supplying a process-tree CPU limit. diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md index 94f3a37eb7..2382b02247 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md @@ -34,6 +34,8 @@ Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前 默认经过时间截止为 120 秒,默认上限为 600 秒。可信服务消费方可以请求 `timeoutMs: null` 来禁用该定时器;[工作流沙箱复用](2026-09-13-workflow-ptc-sandbox-reuse.zh.md)负责这种由调用方控制的生命周期。省略 timeout 或使用数值的请求(包括面向模型的 `run_code`)保留数值默认值与上限。启用的截止包括运行时准备以及嵌套工具或审批等待。V8 老生代内存、序列化外层输出与控制通信具有独立配置的上限。堆限制不包含原生分配和后代内存,经过时间也不是进程树 CPU 预算。 +独立的[尽力持久提供方](2026-09-23-best-effort-persistent-ptc.zh.md)在显式完全访问下保留可丢失的内存;本决定继续负责默认的受约束提供方。 + ## 考虑过的替代方案 **保留 worker 并增加工具检查。** 工具检查不能拦截直接 Node 导入,也不能建立 OS 约束。在受限监督进程中保留 worker 可以保留 worker 计量,但会增加一层执行生命周期,仍不能提供进程树 CPU 限制。 diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml new file mode 100644 index 0000000000..89b99cd7d7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md +2026-09-23-best-effort-persistent-ptc.md: ab35a716ff6c0c7330e0c62500b84ee6173b988a +2026-09-23-best-effort-persistent-ptc.zh.md: 0ec7ae1f7c789eadd87c80365354f914fc720b98 diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md new file mode 100644 index 0000000000..ab35a716ff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md @@ -0,0 +1,29 @@ +# Agent Note: Best-effort persistent PTC memory + +Status: implemented + +English | [中文](2026-09-23-best-effort-persistent-ptc.zh.md) + +## Problem + +Interactive automation retains application objects, imported modules and helper functions between model calls. Recreating every object for every program prevents consumers from using ordinary JavaScript references across observations and actions. + +## Decision + +The optional `dsh-ptc-runtime-node/persistent` provider keeps an unsandboxed Node REPL process per explicit, object-identity state owner. PTC uses the live Agent as owner and awaits release during its context disposal. The default [sandboxed provider](2026-09-11-sandboxed-node-ptc-runtime.md) retains its fresh-process behavior. This extends the [PTC foundation](../feature/2026-06-15-ptc.md) with disposable memory, while preserving registry dispatch and logged model requests. + +The Session records submitted programs, nested tool calls and returned output. It does not serialize the REPL heap or rerun earlier programs after restart, resume or fork: replaying automation could repeat external side effects. A missing variable is a normal program error, and the model-visible instructions require recreating variables and reacquiring resources. No environment identifier or new durable Session event is required because the provider exposes no unlogged model input. + +Cancellation, deadline expiry, protocol or output-limit failure, owner disposal and process exit discard the environment. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink. + +## Alternatives considered + +Serializing arbitrary JavaScript objects cannot preserve native resources and closures. Replaying prior programs can repeat writes and input actions. Both would imply recovery guarantees that this provider does not offer. + +A realm inside the Harness process shares process lifetime with model code and cannot terminate a synchronous loop independently. A managed child process permits cancellation and resource cleanup while retaining imports and variables during healthy use. This process is not a file sandbox or a multi-tenant security boundary. + +## Consequences + +Enabling the provider grants direct Node code full host access. It is opt-in, rejects explicit sandbox policies, and leaves nested tools under their normal authorization and logging. Memory and native handles may disappear without a Harness restart. The model must recover from reference or resource errors; the runtime never retries a program automatically. + +An idle owner retains a process with no expiration timer. Callers release owners explicitly; ownerless service calls use temporary processes. Programs use REPL statements and a final expression rather than top-level return, and calls for one retained owner cannot overlap. diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md new file mode 100644 index 0000000000..0ec7ae1f7c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 尽力保留的持久 PTC 内存 + +Status: implemented + +[English](2026-09-23-best-effort-persistent-ptc.md) | 中文 + +## Problem + +交互式自动化需要在模型调用之间保留应用对象、导入模块和辅助函数。每个程序都重新创建对象,会使消费者无法在观察和操作之间使用普通 JavaScript 引用。 + +## Decision + +可选提供方 `dsh-ptc-runtime-node/persistent` 为每个以对象身份标识的显式状态所有者保留无沙盒 Node REPL 进程。PTC 使用存活 Agent 作为所有者,并在其上下文销毁时等待释放。默认的[沙盒提供方](2026-09-11-sandboxed-node-ptc-runtime.zh.md)保留每次启动新进程的行为。此决定为 [PTC 基础](../feature/2026-06-15-ptc.zh.md)增加可丢失的内存,同时保留注册表分派和模型请求日志。 + +Session 记录提交的程序、嵌套工具调用和返回输出。它不序列化 REPL 堆,也不在重启、恢复或派生后重跑以前的程序,因为回放自动化可能重复外部副作用。缺失变量是普通程序错误,模型可见说明要求重新创建变量和获取资源。提供方没有暴露未记录的模型输入,因此不需要环境标识符或新的持久 Session 事件。 + +取消、截止到期、协议失败、输出超限、所有者销毁和进程退出会丢弃环境。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行产生的异步工作不能取得后续执行的工具权限或输出接收器。 + +## Alternatives considered + +序列化任意 JavaScript 对象无法保留原生资源和闭包。回放以前的程序可能重复写入和输入操作。这两种方案都会隐含本提供方没有承诺的恢复保证。 + +Harness 进程内的执行域与模型代码共享进程寿命,无法单独终止同步循环。受管子进程允许取消和资源清理,同时在正常使用时保留导入模块和变量。该进程不提供文件沙盒或多租户安全隔离。 + +## Consequences + +启用提供方会允许直接 Node 代码完全访问宿主。它需要显式启用,拒绝显式沙盒策略,嵌套工具继续遵循原有授权和日志规则。即使 Harness 没有重启,内存和原生句柄也可能丢失。模型必须从引用错误或资源错误中恢复;运行时不会自动重试程序。 + +空闲所有者保留进程,没有过期计时器。调用方显式释放所有者;没有所有者的服务调用使用临时进程。程序使用 REPL 语句和末尾表达式,不能使用顶层 return;同一保留环境的调用不能重叠。 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index 0e6c3a467b..bf097e40c1 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.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 .agents/notes/implemented/feature/2026-06-15-ptc.md -2026-06-15-ptc.md: d5ed3fc79d4586d3754b7273e08970fb066fe6e9 -2026-06-15-ptc.zh.md: 1434db5c352cdb14b857dc5d4ca3bb2f5332e98e +2026-06-15-ptc.md: 220f8d27fa912fe708be251e04947cf6e1a46239 +2026-06-15-ptc.zh.md: e244d0173e3734ebe00c07f5362da0662310dd8d diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index d5ed3fc79d..220f8d27fa 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -101,6 +101,8 @@ Deployments switching to `'ptc'` must update any native-only `toolOrder`. Assemb - **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a PTC mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. - **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. The TypeScript SDK PTC scenario mounts its Node runtime through an explicit test-owned profile patch and pins Session events and JSON-RPC notifications; its expected response and completed-turn checks run before refresh writes. +The optional [best-effort persistent provider](../architecture/2026-09-23-best-effort-persistent-ptc.md) permits disposable cross-call memory without restoring objects or replaying programs after restart; the default provider stays stateless. + ## Alternatives considered **An add-on consumer plugin with zero core changes.** Rejected because `agent/request` is call-config-only under [reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md), while transforming an assembled tool list would have to undo `toolOrder` canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store. diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 1434db5c35..e244d0173e 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -101,6 +101,8 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 - **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 PTC mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 - **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。TypeScript SDK PTC 场景通过测试拥有的显式 profile patch 挂载 Node 运行时,并固定 Session 事件与 JSON-RPC 通知;预期回复和完成轮次检查在 refresh 写入前执行。 +可选的[尽力持久提供方](../architecture/2026-09-23-best-effort-persistent-ptc.zh.md)允许跨调用保留可丢失的内存,不在重启后恢复对象或回放程序;默认提供方保持无状态。 + ## 曾考虑的替代方案 **一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../architecture/2026-07-05-reconstructable-requests.zh.md)下仅限 call-config,而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。 diff --git a/docs/subsystems/ptc-runtime.i18n.yaml b/docs/subsystems/ptc-runtime.i18n.yaml index 0e6f21ce73..8c74f07937 100644 --- a/docs/subsystems/ptc-runtime.i18n.yaml +++ b/docs/subsystems/ptc-runtime.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/subsystems/ptc-runtime.md -ptc-runtime.md: efab5bcefa0aaec50c29c73f981f6a6b717cd087 -ptc-runtime.zh.md: f35c5bd84d79ddaccc40653707d792f5672e73d9 +ptc-runtime.md: a6fd827adbf52a5ad6f71f126d41d91e4edf31e2 +ptc-runtime.zh.md: c1a2454dba8f2232d415efcc2161222eeba9f93c diff --git a/docs/subsystems/ptc-runtime.md b/docs/subsystems/ptc-runtime.md index efab5bcefa..a6fd827adb 100644 --- a/docs/subsystems/ptc-runtime.md +++ b/docs/subsystems/ptc-runtime.md @@ -18,11 +18,18 @@ Source: [`packages/ptc-runtime/ptc-runtime/src/types.ts`](../../packages/ptc-run interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It - * runs as the body of an async function: top-level `await` and `return` - * are available, and the completion value becomes + * follows the provider's programFormat: an async function body supports + * top-level `await` and `return`; a REPL supports top-level declarations, + * `await`, and a final expression. The completion value becomes * {@link PtcRunResult.value}. */ program: string + /** + * Object-identity owner for optional in-memory state. Omission requests a fresh + * environment. Consumers must await runtime.release(owner) when the owner ends. + * Stateless providers ignore this field; retained state is never durable. + */ + stateOwner?: object /** Host functions exposed to the program, one global object per namespace. */ bindings: PtcBindingNamespace[] /** Working directory in the mounted filesystem and subprocess execution world. */ @@ -77,7 +84,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (its top-level `return`), when it ran to + * The program's completion value (a return or final REPL expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -94,6 +101,8 @@ interface PtcRunResult { } ``` +`programFormat` distinguishes async function bodies from REPL statements. `stateOwner` is an in-process object identity for optional retained memory; distinct objects remain isolated. Consumers await `release(owner)` when that owner ends. Omitted owners are one-shot, and stateless providers ignore ownership. The [persistent Node provider](../../packages/ptc-runtime/ptc-runtime-node/README.md#persistent-repl) defines reset and recovery behavior; no retained object enters the durable Session format. + ## Bindings: host functions as program globals Each `PtcBindingNamespace` becomes a global object of async callables; PTC passes `tools`. Arguments and resolutions must be lossless JSON. Providers enforce their own transport caps; the seam sets no uniform binding-byte limit. An optional error-class descriptor creates program-visible typed rejections without naming a consumer inside the runtime. Binding names are own properties, so `__proto__` cannot traverse a prototype: diff --git a/docs/subsystems/ptc-runtime.zh.md b/docs/subsystems/ptc-runtime.zh.md index f35c5bd84d..c1a2454dba 100644 --- a/docs/subsystems/ptc-runtime.zh.md +++ b/docs/subsystems/ptc-runtime.zh.md @@ -18,11 +18,18 @@ PTC 执行[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13- interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It - * runs as the body of an async function: top-level `await` and `return` - * are available, and the completion value becomes + * follows the provider's programFormat: an async function body supports + * top-level `await` and `return`; a REPL supports top-level declarations, + * `await`, and a final expression. The completion value becomes * {@link PtcRunResult.value}. */ program: string + /** + * Object-identity owner for optional in-memory state. Omission requests a fresh + * environment. Consumers must await runtime.release(owner) when the owner ends. + * Stateless providers ignore this field; retained state is never durable. + */ + stateOwner?: object /** Host functions exposed to the program, one global object per namespace. */ bindings: PtcBindingNamespace[] /** Working directory in the mounted filesystem and subprocess execution world. */ @@ -77,7 +84,7 @@ interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (its top-level `return`), when it ran to + * The program's completion value (a return or final REPL expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. @@ -94,6 +101,8 @@ interface PtcRunResult { } ``` +`programFormat` 区分异步函数体与 REPL 语句。`stateOwner` 是标识可选保留内存的进程内对象身份;不同对象相互隔离。消费者在所有者结束时等待 `release(owner)`。省略所有者时仅执行一次,无状态提供方忽略所有权。[持久 Node 提供方](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md#persistent-repl)定义重置和恢复行为;保留对象不会进入持久 Session 格式。 + ## 绑定:宿主函数作为程序全局变量 每个 `PtcBindingNamespace` 成为一个异步可调用函数的全局对象;PTC 传入 `tools`。参数与返回值必须是无损 JSON。提供方强制各自的传输上限;seam 不设统一的绑定字节上限。可选错误类描述符创建程序可见的类型化拒绝,无需在运行时内点名消费方。绑定名是自有属性,因此 `__proto__` 不能遍历原型: diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 7fb7a8ad41..f50e8e8969 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: 83f04d86d74b3b8c3a5028be65a6dd9eb6709a2e -README.zh.md: 2b5c4e87b8a77bbdad7125732359f817b7ce7014 +README.md: a6720fb2b5ffaf18059fe63130043dfdfbc0903e +README.zh.md: e4b091de78c3983a822cf5bf50391ef52f5447fd diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 83f04d86d7..a6720fb2b5 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -232,7 +232,7 @@ These limits define when the registry needs special care. They are current packa - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-tool-call-timeout-policy` wrapper. - **PTC mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: ptc`/`both` rejects prompt assembly unless `ctx.ptcRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is ptc-only. - **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. -- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP, because cross-call state would be invisible to the log. +- **State depends on the runtime**: the default backend starts fresh; a REPL backend retains memory per live Agent. Agent disposal awaits release. Restart and execution failures can clear memory; model-visible guidance explains how to recreate missing variables. Programs and results remain in the Session log, without serializing live objects. `defineTool()`, registry schema projection, and system-prompt assembly preserve `deferLoading: true`. The marker requests deferred definition loading and does not imply a `tool-addition` record; the [LLM package](../../../packages/llm/llm/README.md#known-limitations-and-deferred-work) documents provider enforcement limits. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 2b5c4e87b8..e4b091de78 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -232,7 +232,7 @@ Program-only SDK bindings: - **定义中的 `timeoutMs` 仅作声明之用**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-tool-call-timeout-policy` 包装层。 - **PTC mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:`mode: ptc`/`both` 会拒绝组装提示词,除非 `ctx.ptcRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 PTC。 - **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。 -- **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核,因为跨调用状态不会出现在日志中。 +- **状态由运行时决定**:默认后端每次重新开始;REPL 后端按存活 Agent 保留内存。Agent 销毁时等待环境释放。重启与执行失败可能清除内存;模型说明会指导重新创建缺失变量。程序与结果仍写入 Session 日志,不序列化运行中的对象。 `defineTool()`、注册表模式投影和系统提示组装会保留 `deferLoading: true`。该标记请求延迟加载工具定义,并不意味着存在 `tool-addition` 记录;提供方执行语义的限制见 [LLM 包](../../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index baca4ec56f..d7500b89de 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -58,7 +58,7 @@ declare module '@deepseek-ai/dsh-llm' { */ const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.` -const SDK_RENDERERS: Record string> = { +const SDK_RENDERERS: Record string> = { typescript: renderToolsSdk, python: renderToolsSdkPy, } satisfies Record string> @@ -907,7 +907,7 @@ export class ToolRuntime extends Service { const render = SDK_RENDERERS[runtime.language] /* v8 ignore next -- requirePtcRuntime rejects an unknown language before this runs. */ if (render === undefined) throw new Error(`dsh-tools: no SDK renderer for ${runtime.language}`) - return render(this.sdkSchemas(context.scope)) + return render(this.sdkSchemas(context.scope), runtime.programFormat) }, } } diff --git a/packages/core/tools/src/ptc.ts b/packages/core/tools/src/ptc.ts index eadfbaabcc..f4ff87eb22 100644 --- a/packages/core/tools/src/ptc.ts +++ b/packages/core/tools/src/ptc.ts @@ -66,6 +66,11 @@ const TYPESCRIPT_FLAVOR: RunCodeFlavor = { * `return`, answer via `print` and/or the returned value, matching * {@link ./py-types.ts}'s SDK instructions. */ +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.', + codeDescription: 'TypeScript REPL statements with optional top-level await and a final JSON expression.', +} + const PYTHON_FLAVOR: RunCodeFlavor = { description: 'Execute a Python program against the available tools. Takes two required ' @@ -161,6 +166,10 @@ function resolveFlavor(peekRuntime: () => PtcRuntime | undefined): RunCodeFlavor const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ') throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`) } + if (runtime.programFormat === 'repl') { + if (runtime.language !== 'typescript') throw new Error('dsh-tools: REPL presentation requires TypeScript') + return REPL_FLAVOR + } return flavor } @@ -332,6 +341,7 @@ export interface RunCodeBridgeOptions { */ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeOptions): ToolDefinition { const { requireRuntime, peekRuntime, maxParallel, shapeDispatchLog } = options + const stateOwners = new WeakMap>() const definition = defineTool({ name: RUN_CODE_NAME, // The description and `code` parameter description are placeholders here: @@ -694,8 +704,18 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO try { let result: PtcRunResult try { + if (exec.agent !== undefined && runtime.programFormat === 'repl') { + const agent = exec.agent + let runtimes = stateOwners.get(agent) + if (runtimes === undefined) stateOwners.set(agent, runtimes = new WeakSet()) + if (!runtimes.has(runtime)) { + agent.ctx.effect(() => () => runtime.release(agent), 'PTC retained environment') + runtimes.add(runtime) + } + } result = await runtime.run(runtime.resolve({ program: args.code, + ...exec.agent === undefined ? {} : { stateOwner: exec.agent }, bindings: [{ global: 'tools', functions, diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index d92c987817..71f507fa8a 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -268,7 +268,7 @@ function acceptsExampleString(schema: JsonSchemaNode | undefined, value: string) } /** Render the bash example only when its literal arguments satisfy the current parameter schema. */ -function renderBashExample(schemas: ToolSdkSchema[]): string { +function renderBashExample(schemas: ToolSdkSchema[], format: 'function-body' | 'repl'): string { const bash = schemas.find(schema => schema.name === 'bash') if (bash === undefined) return '' const parameters = bash.parameters as JsonSchemaNode @@ -279,7 +279,7 @@ function renderBashExample(schemas: ToolSdkSchema[]): string { const needsDescription = required.includes('description') if (needsDescription && !acceptsExampleString(parameters.properties?.description, 'Show current directory')) return '' const description = needsDescription ? ", description: 'Show current directory'" : '' - return ` When no separate \`bash\` schema is supplied, invoke a declared \`bash\` binding inside \`run_code\`:\n\n\`run_code({ code: "return await tools.bash({ command: 'pwd'${description} })", description: "Show current directory" })\`` + return ` When no separate \`bash\` schema is supplied, invoke a declared \`bash\` binding inside \`run_code\`:\n\n\`run_code({ code: "${format === 'repl' ? '' : 'return '}await tools.bash({ command: 'pwd'${description} })", description: "Show current directory" })\`` } /** @@ -292,9 +292,10 @@ function renderBashExample(schemas: ToolSdkSchema[]): string { * by name, so the input never carries a duplicate. * @param schemas - the tool schemas to declare (the caller excludes * `run_code` itself). + * @param format - Whether the program is an async body or REPL statements. * @returns the complete section text. */ -export function renderToolsSdk(schemas: ToolSdkSchema[]): string { +export function renderToolsSdk(schemas: ToolSdkSchema[], format: 'function-body' | 'repl' = 'function-body'): string { const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) const argsMembers: string[] = [] const outputMembers: string[] = [] @@ -313,5 +314,11 @@ export function renderToolsSdk(schemas: ToolSdkSchema[]): string { ['declare const tools: {', ' [K in ToolName]: (args: ToolArgsMap[K]) => Promise;', '}'].join('\n'), ].join('\n\n') const jsonValue = 'type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }' - return `${SDK_INSTRUCTIONS}${renderBashExample(sorted)}\n\n${SDK_PROGRAM_INSTRUCTIONS}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\`` + 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.' + : 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\`\`\`` } diff --git a/packages/ptc-runtime/README.i18n.yaml b/packages/ptc-runtime/README.i18n.yaml index d14c2081b5..39a1cbae1d 100644 --- a/packages/ptc-runtime/README.i18n.yaml +++ b/packages/ptc-runtime/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/README.md -README.md: 049624bade5dac3d70e9916a37eb3639a1a31ed4 -README.zh.md: b0c46c8e7e17c9f3f6e5e132245b313627b006f7 +README.md: 48af8ef445fbe5e43a04aeaf6d2e7356111967a1 +README.zh.md: 5ace3e3175db42f3d0ba046eb1ad2347f8ff48fd diff --git a/packages/ptc-runtime/README.md b/packages/ptc-runtime/README.md index 049624bade..48af8ef445 100644 --- a/packages/ptc-runtime/README.md +++ b/packages/ptc-runtime/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `ptc-runtime/` group lets a model write one program that calls host-provided functions as ordinary async calls, then returns only the program's printed output and return value. Choose the TypeScript backend for execution in a fresh Node process under the configured sandbox policy, or the experimental Python backend when a CPython process is required. Each run starts without state from earlier programs. Failures are returned as results so callers can diagnose them or provide them to the model. +The `ptc-runtime/` group executes model programs against host-provided async functions. The default Node backend starts a fresh process under the configured sandbox policy. Its optional persistent entry keeps a separate unsandboxed REPL for each explicit state owner; memory ends with the process or owner. The experimental Python backend uses a fresh CPython process. Failures return as results for callers to diagnose or present to the model. ## Table of Contents diff --git a/packages/ptc-runtime/README.zh.md b/packages/ptc-runtime/README.zh.md index b0c46c8e7e..5ace3e3175 100644 --- a/packages/ptc-runtime/README.zh.md +++ b/packages/ptc-runtime/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`ptc-runtime/` 组让模型编写一个程序,以普通异步调用的方式调用宿主提供的函数,然后只返回程序的打印输出和返回值。如需在全新 Node 进程中按所配沙箱策略执行,请选择 TypeScript 后端;如需 CPython 进程,请选择实验性 Python 后端。每次运行都不会保留之前程序的状态。失败会作为结果返回,供调用方诊断或提供给模型。 +`ptc-runtime/` 分组执行调用宿主异步函数的模型程序。默认 Node 后端按配置的沙盒策略启动新进程。可选的持久入口为每个显式状态所有者保留独立且无沙盒的 REPL;进程或所有者结束时内存消失。实验性 Python 后端使用新的 CPython 进程。失败作为结果返回,供调用方诊断或交给模型处理。 ## 目录 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 4c672e15a9..86e047683d 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: 5196d6982c3bf8edda3b8b825e41db83f6994141 -README.zh.md: 16c9f8fc426b1f2d28eaa039ba259f08088664a8 +README.md: 11adc74cb46db7977ca68b184ac9426b6139590c +README.zh.md: 4ba69e1781308437ab9349752ed70abc122b713b diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 5196d6982c..11adc74cb4 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -63,6 +63,24 @@ Programs are async function bodies: top-level `await` and `return` work, and onl Direct filesystem, network and subprocess operations remain Node operations, subject to the selected OS sandbox. Nested host bindings cross the control channel; PTC tool calls retain the registry's visibility, ordering, logging and approval rules. Running a program does not change the Session's standing policy or automatically replay it after a denial. + +### Persistent REPL + +The optional `@deepseek-ai/dsh-ptc-runtime-node/persistent` entry replaces the default provider. It requires `fs` and `subprocess`, uses the same configuration fields, and does not create a file sandbox. Enable it deliberately with a profile patch; it is not a shipped default: + +```yaml +- id: ptc-runtime + name: '@deepseek-ai/dsh-ptc-runtime-node/persistent' +``` + +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. 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. + +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. + ### Deadlines and cancellation The PTC consumer exposes per-call timeout and approved sandbox choices as described in [dsh-tools](../../core/tools/README.md#ptc-mode). The runtime's readonly `timeout` descriptor reports its effective default and maximum to that consumer. Its `executionInstructions` describes fresh Node state, direct Node APIs, the empty program environment and file policy in the model-visible schema. diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 16c9f8fc42..4ba69e1781 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -63,6 +63,24 @@ kind: "package-reference" 直接文件系统、网络与子进程操作仍是 Node 操作,受所选 OS 沙箱约束。嵌套 Host 绑定通过控制通道调用;PTC 工具调用保留注册表的可见性、排序、日志和审批规则。运行程序不会改变 Session 的常设策略,也不会在拒绝后自动重放程序。 + +### 持久 REPL + +可选入口 `@deepseek-ai/dsh-ptc-runtime-node/persistent` 替换默认提供方。它需要 `fs` 和 `subprocess`,使用同一组配置字段,不创建文件沙盒。通过配置档补丁显式启用;默认配置不会启用它: + +```yaml +- id: ptc-runtime + name: '@deepseek-ai/dsh-ptc-runtime-node/persistent' +``` + +保存补丁,在启用 PTC 工具呈现的组合中使用 `dsh headless --patch ` 传入路径。Node 应用仍通过具名 `dsh` 配置档启动。打包为单个可执行文件的宿主需要将 `nodeExecutable` 指向 Node,将 `bootstrapPath` 指向本包构建的 `persistent-process.js`。 + +每个存活 Agent 保留独立进程,跨 `run_code` 调用和轮次保留顶层变量、函数及导入模块。使用 `await import(...)`、`console.log(...)` 和末尾 JSON 表达式;不支持顶层 `return`。声明遵循 Node REPL 规则,包括重复声明已有词法名称时报错。保留的函数和工具方法引用调用当前执行的绑定。必须等待所有工具调用;已完成执行的回调不能在后续执行中调用工具。 + +内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、取消、超时、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。普通程序异常保留此前发生的修改。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。直接 Node 访问没有沙盒约束;显式沙盒策略会被拒绝。 + +服务消费者通过 `stateOwner` 对象复用环境,并在结束时等待 `release(owner)`。没有所有者的请求在临时进程中执行。同一所有者的并发调用会被拒绝;不同所有者可以并发。空闲环境没有过期计时器,进程保留到释放或失败为止。 + ### 截止时间与取消 PTC 消费方按 [dsh-tools](../../core/tools/README.zh.md#ptc-mode) 的说明公开逐次超时与经审批的沙箱选择。运行时只读 `timeout` 描述符向该消费方报告有效默认值与上限。 其 `executionInstructions` 在面向模型的 schema 中说明全新 Node 状态、直接 Node API、空程序环境和文件策略。 diff --git a/packages/ptc-runtime/ptc-runtime-node/package.json b/packages/ptc-runtime/ptc-runtime-node/package.json index c8219b1743..3e1142e2e1 100644 --- a/packages/ptc-runtime/ptc-runtime-node/package.json +++ b/packages/ptc-runtime/ptc-runtime-node/package.json @@ -22,12 +22,18 @@ "./process": { "types": "./lib/types/process-entry.d.ts", "default": "./lib/process.js" + }, + "./persistent": { + "types": "./lib/types/persistent.d.ts", + "default": "./lib/persistent.js" } }, "files": [ "lib/index.js", "lib/process.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/persistent.js", + "lib/persistent-process.js" ], "license": "MIT", "peerDependencies": { diff --git a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts index 44af9c3c1a..227c4c7256 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts @@ -107,7 +107,7 @@ const CONSOLE_LEVELS = ['log', 'info', 'warn', 'error', 'debug'] as const * @param logs - the buffer every rendered line is pushed into. * @returns the five-method console object handed to the program. */ -export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> { +export function makeConsoleShim(logs: Pick): Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> { const render = (args: unknown[]): string => args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ') const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> @@ -129,7 +129,7 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS) * @returns the restore function (the in-process tests un-patch; the real * child never needs to). */ -export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => void { +export function captureStreamWrites(logs: Pick, stream: PatchableStream): () => void { // The slot's VALUE is stored for restore and reassigned — never invoked // detached, so the unbound-method concern does not apply. // oxlint-disable-next-line typescript/unbound-method diff --git a/packages/ptc-runtime/ptc-runtime-node/src/launch.ts b/packages/ptc-runtime/ptc-runtime-node/src/launch.ts index 7a88482197..ac9e31b93c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/launch.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/launch.ts @@ -16,11 +16,16 @@ export interface LaunchConfig { * @param fs - Filesystem mapping host bootstrap assets into the process world. * @param config - Optional preinstalled built bootstrap. * @param maxMessageBytes - Validated frame and queued-write limit. + * @param persistent - Select the in-memory REPL bootstrap instead of the one-shot evaluator. * @returns Arguments following the resolved Node executable. */ -export function bootstrapArgs(fs: FileSystem, config: LaunchConfig, maxMessageBytes: number): string[] { +export function bootstrapArgs(fs: FileSystem, config: LaunchConfig, maxMessageBytes: number, persistent = false): string[] { if (config.bootstrapPath !== undefined) return [config.bootstrapPath, String(maxMessageBytes)] - if ('pkg' in process) return [String(maxMessageBytes)] + if ('pkg' in process) { + if (persistent) throw new Error('Persistent PTC requires an explicit bootstrapPath in a packaged executable') + return [String(maxMessageBytes)] + } + const moduleName = persistent ? 'persistent-process' : 'process' const mapped = (path: string): string => { const result = fs.processPathFromHostPath(path) if (result === undefined) throw new Error(`PTC runtime bootstrap is unavailable in the subprocess execution world: ${path}`) @@ -28,11 +33,12 @@ export function bootstrapArgs(fs: FileSystem, config: LaunchConfig, maxMessageBy } /* v8 ignore next 3 -- built-lib.e2e.ts executes the bundled provider and sibling process.js under plain Node. */ if (!new URL(import.meta.url).pathname.endsWith('.ts')) { - return [mapped(fileURLToPath(new URL('./process.js', import.meta.url))), String(maxMessageBytes)] + return [mapped(fileURLToPath(new URL(`./${moduleName}.js`, import.meta.url))), String(maxMessageBytes)] } - const entry = mapped(fileURLToPath(new URL('./process.ts', import.meta.url))) + const entry = mapped(fileURLToPath(new URL(`./${moduleName}.ts`, import.meta.url))) const subprocess = dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess/package.json'))) const helper = mapped(resolve(subprocess, 'src/control.ts')) - const source = `const {openInheritedControlChannel}=await import(${JSON.stringify(pathToFileURL(helper).href)});const {runNodeMain}=await import(${JSON.stringify(pathToFileURL(entry).href)});await runNodeMain(openInheritedControlChannel(),${maxMessageBytes},process);` + const main = persistent ? 'runPersistentMain' : 'runNodeMain' + const source = `const {openInheritedControlChannel}=await import(${JSON.stringify(pathToFileURL(helper).href)});const {${main}}=await import(${JSON.stringify(pathToFileURL(entry).href)});await ${main}(openInheritedControlChannel(),${maxMessageBytes},process);` return ['--input-type=module', '--eval', source] } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process-entry.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process-entry.ts new file mode 100644 index 0000000000..521f49f3e7 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process-entry.ts @@ -0,0 +1,5 @@ +/** Private built entry for the unsandboxed persistent Node REPL. */ +import { openInheritedControlChannel } from '@deepseek-ai/dsh-subprocess/control' +import { runPersistentMain } from './persistent-process.ts' + +void runPersistentMain(openInheritedControlChannel(), Number(process.argv[2])) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts new file mode 100644 index 0000000000..6871c72a1a --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts @@ -0,0 +1,113 @@ +/** In-memory Node REPL with per-evaluation bindings and output attribution. */ +import { AsyncLocalStorage } from 'node:async_hooks' +import { start } from 'node:repl' +import { PassThrough, Writable, type Duplex } from 'node:stream' +import { JsonChannel } from './channel.ts' +import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClasses, makeNamespaces, prepareCompletion, prepareException, wireReplies } from './bootstrap.ts' +import type { BindingErrorConstructor, PendingCall } from './bootstrap.ts' +import type { DoneMessage, ProgramBootData, ProgramToHost, ReplyMessage } from './protocol.ts' +import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' + +interface Evaluation { + id: number + logs: LogBuffer + active: boolean + finish(result: Omit): void +} + +/** + * Serve sequential evaluations until the owning host closes the control channel. + * @param stream - Inherited private control endpoint. + * @param maxMessageBytes - Maximum control frame and queued output bytes. + * @returns After the channel closes and the REPL releases its streams. + */ +export async function runPersistentMain(stream: Duplex, maxMessageBytes: number): Promise { + for (const key of Object.keys(process.env)) { + if (!STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase())) Reflect.deleteProperty(process.env, key) + } + process.env = Object.create(null) as NodeJS.ProcessEnv + const context = new AsyncLocalStorage() + const closed = Promise.withResolvers() + const pending = new Map() + const nextId = { value: 1 } + const errors = new Map() + const replies: Array<(message: ReplyMessage) => void> = [] + const input = new PassThrough() + 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 } }) + callback() + } }) + const repl = start({ input, output, terminal: false, prompt: '', useGlobal: false, ignoreUndefined: true }) + let current: Evaluation | undefined + const send = (id: number, payload: ProgramToHost): void => { + void channel.send({ id, payload }).catch(() => { channel.close(); closed.resolve() }) + } + const port = { + postMessage(payload: ProgramToHost): void { + const evaluation = context.getStore() + if (!evaluation?.active) throw new Error('PTC evaluation has ended; call this function from a new run_code evaluation') + send(evaluation.id, payload) + }, + on(_event: 'message', listener: (message: ReplyMessage) => void): void { replies.push(listener) }, + } + wireReplies(port, pending) + const logSink = { push(text: string): void { const evaluation = context.getStore(); if (evaluation?.active) evaluation.logs.push(text) } } + repl.context.console = makeConsoleShim(logSink) + captureStreamWrites(logSink, process.stdout) + captureStreamWrites(logSink, process.stderr) + const channel = new JsonChannel(stream, maxMessageBytes, (raw) => { + // The host owns these frames. Model-written traffic is validated on the host. + const message = raw as { type: 'run'; id: number; cwd: string; data: ProgramBootData } | ReplyMessage + if (message.type === 'reply') { + for (const listener of replies) listener(message) + return + } + if (current?.active) { channel.close(); closed.resolve(); return } + 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, + 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() + send(id, { type: 'done', ...result }) + }, + } + current = evaluation + context.run(evaluation, () => { + try { + process.chdir(message.cwd) + for (const [name, constructor] of makeBindingErrorClasses(data)) { + if (!errors.has(name)) errors.set(name, constructor) + } + const namespaces = makeNamespaces(data, port, pending, nextId, errors) + for (const [index, namespace] of data.namespaces.entries()) { + Object.defineProperty(repl.context, namespace.global, { configurable: true, value: namespaces[index] }) + if (namespace.errorClass) { + Object.defineProperty(repl.context, namespace.errorClass.name, { configurable: true, value: errors.get(namespace.global) }) + } + } + repl.eval(`${data.code}\n`, repl.context, 'ptc-repl', (error: Error | null, value: unknown) => { + evaluation.finish(error ? prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes) + : prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes)) + }) + } catch (error: unknown) { + evaluation.finish(prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)) + } + }) + }, () => { channel.close(); closed.resolve() }) + stream.once('close', () => { closed.resolve() }) + await channel.send({ type: 'ready' }) + try { await closed.promise } finally { + if (current) current.active = false + repl.close() + input.destroy() + output.destroy() + channel.close() + context.disable() + } +} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts new file mode 100644 index 0000000000..b1de40a764 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -0,0 +1,297 @@ +/** Optional, unsandboxed Node REPL provider with memory scoped to caller-owned objects. */ +import { stripTypeScriptTypes } from 'node:module' +import { isAbsolute } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' +import type { PtcRunRequest, PtcRunSpec, PtcRunResult, PtcRunFailure, PtcBindingNamespace, PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime' +import { MAX_TIMER_DELAY_MS, clampTimeout } from '@deepseek-ai/dsh-timeout' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' +import { NodePtcRuntime, type Config } from './index.ts' +import { validateBindings } from './bindings.ts' +import { JsonChannel } from './channel.ts' +import { bootstrapArgs } from './launch.ts' +import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' +import { decodePtcJsonWire, encodePtcJsonWire } from './json-wire.ts' +import { OutputLedger } from './output-ledger.ts' +import { drainOutput } from './output-stream.ts' +import type { ProgramBootData } from './protocol.ts' + +export type { Config } from './index.ts' +type ResolvedConfig = Required> & Pick +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) + +interface Evaluation { + id: number + data: ProgramBootData + spec: PtcRunSpec + bindings: Map + pending: number + pendingBytes: number + admit(text: string): void + finish(error?: PtcRunFailure, value?: PtcJsonValue, discard?: boolean): void +} + +/** One managed process; all mutable program state belongs to this process alone. */ +class Kernel { + private handle?: SubprocessHandle + private channel?: JsonChannel + private active?: Evaluation + private ready = false + private sequence = 0 + private nextCall = 1 + private stopped?: Promise + private starting?: Promise + private readonly controller = new AbortController() + closed = false + + constructor(private readonly ctx: Context, private readonly config: ResolvedConfig) {} + + async run(spec: PtcRunSpec, bindings: Map, code: string): Promise { + if (this.active) throw new Error('persistent PTC does not accept concurrent runs for one state owner') + const output = new OutputLedger(this.config.maxOutputBytes) + const logs: string[] = [] + const result = Promise.withResolvers() + let settled = false + const timer = spec.timeoutMs === null ? undefined : setTimeout(() => { + evaluation.finish({ kind: 'timeout', message: `execution deadline reached (${spec.timeoutMs}ms); retained variables were cleared` }, undefined, true) + }, spec.timeoutMs) + const onAbort = (): void => { evaluation.finish({ kind: 'abort', message: 'execution canceled; retained variables were cleared' }, undefined, true) } + const evaluation: Evaluation = { + id: ++this.sequence, + data: { code, maxOutputBytes: this.config.maxOutputBytes, namespaces: [...bindings.values()].map(binding => ({ + global: binding.global, names: Object.keys(binding.functions), ...binding.errorClass ? { errorClass: binding.errorClass } : {}, + })) }, + spec, bindings, pending: 0, pendingBytes: 0, + admit: (text) => { + if (!output.admit(text, logs)) { + logs.push(text) + evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true) + } + }, + finish: (error, value, discard = false) => { + if (settled) return + settled = true + 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() + void cleanup.then(() => result.resolve(outcome), (failure: unknown) => { + result.resolve(output.failure(logs, { kind: 'worker-exit', message: `managed process cleanup failed: ${messageOf(failure)}` })) + }) + }, + } + this.active = evaluation + spec.signal?.addEventListener('abort', onAbort, { once: true }) + if (spec.signal?.aborted) onAbort() + else if (this.ready) this.dispatch(evaluation) + else { + this.starting = this.start(spec.cwd) + void this.starting.catch((error: unknown) => this.fail('worker-exit', messageOf(error))) + } + return await result.promise + } + + async dispose(): Promise { + this.active?.finish({ kind: 'abort', message: 'PTC environment disposed; retained variables were cleared' }, undefined, true) + await this.stop() + } + + private stop(): Promise { + if (this.stopped) return this.stopped + this.closed = true + this.controller.abort() + this.channel?.close() + this.stopped = (async () => { + await this.starting?.catch(() => {}) + const handle = this.handle + if (!handle) return + try { + handle.terminate() + await Promise.all([handle.done.catch(() => {}), handle.waitForExit()]) + const drained = await Promise.all([ + drainOutput(handle.stdout, this.config.graceMs), drainOutput(handle.stderr, this.config.graceMs), + ]) + if (drained.includes(false)) throw new Error('persistent Node process output did not close cleanly') + } finally { handle.stdout?.destroy(); handle.stderr?.destroy() } + })() + return this.stopped + } + + private fail(kind: PtcRunFailure['kind'], message: string): void { + if (this.active) this.active.finish({ kind, message: `${message}; retained variables were cleared` }, undefined, true) + else void this.stop().catch((error: unknown) => { this.ctx.logger.warn(messageOf(error)) }) + } + + private dispatch(evaluation: Evaluation): void { + void this.channel?.send({ type: 'run', id: evaluation.id, cwd: evaluation.spec.cwd, data: evaluation.data }) + .catch((error: unknown) => this.fail('protocol', messageOf(error))) + } + + private async start(cwd: string): Promise { + const executable = await this.ctx.subprocess.resolveExecutable(this.config.nodeExecutable, undefined, this.controller.signal) + if (this.closed) return + const argv = [executable, `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`, ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes, true)] + const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env) + .filter(key => !STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase()) && key.toUpperCase() !== 'ELECTRON_RUN_AS_NODE') + .map(key => [key, undefined])) + const handle = this.ctx.subprocess.spawn({ argv, cwd, env, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', control: 'pipe' }, graceMs: this.config.graceMs }) + this.handle = handle + if (!handle.control || !handle.stdout || !handle.stderr) throw new Error('subprocess provider did not supply persistent PTC pipes') + for (const stream of [handle.stdout, handle.stderr]) { + stream.setEncoding('utf8') + stream.on('data', (text: string) => { this.active?.admit(text) }) + stream.on('error', (error: Error) => this.fail('worker-exit', error.message)) + } + this.channel = new JsonChannel(handle.control, this.config.maxMessageBytes, (raw, bytes) => this.receive(raw, bytes), (error, kind) => { + if (!this.closed) this.fail(kind === 'protocol' ? 'protocol' : 'worker-exit', error.message) + }) + void handle.done.then((outcome) => { + setImmediate(() => { if (!this.closed) this.fail('worker-exit', `persistent Node process exited (${String(outcome.exitCode)})`) }) + }, (error: unknown) => this.fail('worker-exit', messageOf(error))) + } + + private receive(raw: unknown, bytes: number): void { + if (this.closed) return + if (!record(raw)) { this.fail('protocol', 'invalid persistent PTC frame'); return } + if (!this.ready) { + if (raw.type !== 'ready') { this.fail('protocol', 'expected persistent PTC readiness'); return } + this.ready = true + if (this.active) this.dispatch(this.active) + return + } + const evaluation = this.active + if (!evaluation || raw.id !== evaluation.id || !record(raw.payload)) { this.fail('protocol', 'frame does not belong to the active PTC evaluation'); return } + const payload = raw.payload + switch (payload.type) { + case 'log': + if (typeof payload.text === 'string') evaluation.admit(payload.text) + else this.fail('protocol', 'invalid persistent PTC log') + return + case 'output-limit': evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true); return + case 'done': { + if (payload.error !== undefined) { + const error = payload.error + if (!record(error) || typeof error.message !== 'string' || (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) { + this.fail('protocol', 'invalid persistent PTC failure'); return + } + evaluation.finish({ kind: error.kind, message: error.message }, undefined, error.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) + } + return + } + case 'call': { + if (payload.id !== this.nextCall || typeof payload.global !== 'string' || typeof payload.name !== 'string') { this.fail('protocol', 'invalid persistent PTC binding identity'); return } + this.nextCall += 1 + const args = decodePtcJsonWire(payload.args) + if (args === undefined) { this.fail('protocol', 'binding arguments must be lossless JSON'); return } + if (++evaluation.pending > this.config.maxPendingCalls || (evaluation.pendingBytes += bytes) > this.config.maxMessageBytes) { + this.fail('protocol', 'pending binding calls exceed configured limits'); return + } + const functions = evaluation.bindings.get(payload.global)?.functions + const fn = functions && Object.hasOwn(functions, payload.name) ? functions[payload.name] : undefined + const id = payload.id + void (async () => { + let reply: unknown + try { + if (!fn) throw new Error('binding is unavailable in this evaluation; reacquire the current tool') + const value = snapshotJsonValue(await fn(args)) + if (value === undefined) throw new Error('binding resolution must be lossless JSON') + reply = { type: 'reply', id, ok: true, value: encodePtcJsonWire(value) } + } catch (error: unknown) { + reply = { type: 'reply', id, ok: false, message: messageOf(error) } + } finally { evaluation.pending -= 1; evaluation.pendingBytes -= bytes } + if (this.active === evaluation) await this.channel?.send(reply) + })().catch((error: unknown) => this.fail('protocol', messageOf(error))) + return + } + default: this.fail('protocol', 'unknown persistent PTC message') + } + } +} + +/** Unsandboxed, best-effort retained state; restart, disposal, and interrupted execution clear it. */ +export class PersistentNodePtcRuntime extends PtcRuntime { + static inject = ['fs', 'subprocess'] + static Config = NodePtcRuntime.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.' + } + override get timeout(): { defaultMs: number; maxMs: number } { + return { defaultMs: Math.min(this.config.timeoutMs, this.config.maxTimeoutMs), maxMs: this.config.maxTimeoutMs } + } + private readonly config: ResolvedConfig + private readonly owners = new WeakMap() + private readonly kernels = new Set() + private disposed = false + + 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') + ctx.effect(() => async () => { + this.disposed = true + await Promise.all([...this.kernels].map(kernel => kernel.dispose())) + this.kernels.clear() + }, 'Persistent PTC environments') + } + + /** + * Resolve full-access REPL execution without accepting a sandbox policy. + * @param request - Program, bindings, owner, directory and elapsed budget. + * @returns Resolved request; explicit confinement requests fail before execution. + */ + resolve(request: PtcRunRequest): PtcRunSpec { + if (this.disposed) throw new Error('persistent PTC: resolve after disposal') + if (request.sandboxPolicy !== undefined) throw new Error('persistent PTC does not support sandbox policies') + const cwd = request.cwd ?? process.cwd() + if (!isAbsolute(cwd)) throw new Error('persistent PTC: cwd must be absolute') + return { ...request, cwd, timeoutMs: request.timeoutMs === null ? null : clampTimeout(request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs, 'persistent PTC timeoutMs') } + } + + /** + * Evaluate statements using this owner's process, or a temporary process without an owner. + * @param spec - Resolved request; concurrent calls for one owner are rejected. + * @returns Logs, a final JSON expression, or a structured execution failure. + */ + async run(spec: PtcRunSpec): Promise { + 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) + 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) + } + try { return await kernel.run(spec, bindings, code) } finally { + if (spec.stateOwner === undefined) this.kernels.delete(kernel) + } + } + + override async release(owner: object): Promise { + const kernel = this.owners.get(owner) + if (!kernel) return + this.owners.delete(owner) + await kernel.dispose() + this.kernels.delete(kernel) + } +} + +export default PersistentNodePtcRuntime diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts new file mode 100644 index 0000000000..36df9deef9 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts @@ -0,0 +1,145 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import FileSystem from '@deepseek-ai/dsh-fs-local' +import Subprocess from '@deepseek-ai/dsh-subprocess-local' +import type { PtcBindingFunction, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import PersistentRuntime, { type Config } from '../src/persistent.ts' + +async function setup(config: Config = {}) { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-persistent-ptc-')) + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose(); await rm(cwd, { recursive: true, force: true }) }) + await ctx.plugin(FileSystem) + await ctx.plugin(Subprocess) + const fiber = await ctx.plugin(PersistentRuntime, config) + const runtime = ctx.ptcRuntime + const owner = {} + const run = (program: string, options: Partial = {}) => + runtime.run(runtime.resolve({ program, bindings: [], cwd, stateOwner: owner, ...options })) + return { ctx, fiber, runtime, owner, run, cwd } +} + +function bindings(value: PtcBindingFunction) { + return [{ global: 'tools', functions: { value }, errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' } }] +} + +describe('persistent Node REPL', () => { + it('retains declarations, closures, object identity, and imports across evaluations', async () => { + const { run, cwd } = await setup() + expect(await run('const box: { count: number } = { count: 1 }; let alias = box; function increment() { return ++box.count }; const path = await import("node:path");')).toEqual({ logs: [] }) + expect(await run('console.log("second call"); ({ count: increment(), same: box === alias, dir: path.basename(process.cwd()), env: Object.keys(process.env) })')).toEqual({ logs: ['second call'], value: { count: 2, same: true, dir: cwd.split(/[\\/]/).at(-1), env: [] } }) + }) + + it('uses current host bindings through retained functions and saved method references', async () => { + const { run } = await setup() + const first = vi.fn(async () => 'old') + const second = vi.fn(async () => 'new') + expect((await run('const saved = tools.value; async function query() { return await saved({}) }; await query()', { bindings: bindings(first) })).value).toBe('old') + expect((await run('await query()', { bindings: bindings(second) })).value).toBe('new') + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + expect((await run('try { await query() } catch (error) { console.log(error.toolName); "unavailable" }')).value).toBe('unavailable') + expect(second).toHaveBeenCalledTimes(1) + }) + + it('isolates owners and uses fresh state when no owner is supplied', async () => { + const { run } = await setup() + expect((await run('let privateValue = 7;')).error).toBeUndefined() + expect((await run('typeof privateValue', { stateOwner: {} })).value).toBe('undefined') + expect((await run('let temporary = 8;', { stateOwner: undefined })).error).toBeUndefined() + expect((await run('typeof temporary', { stateOwner: undefined })).value).toBe('undefined') + expect((await run('privateValue')).value).toBe(7) + }) + + it('releases an owner and reports missing variables until the caller recreates them', async () => { + const { run, runtime, owner } = await setup() + await run('let app = { ready: true };') + await runtime.release(owner) + const missing = await run('app.ready') + expect(missing.error?.kind).toBe('exception') + expect(missing.error?.message).toContain('app is not defined') + expect((await run('let app = { ready: true }; app.ready')).value).toBe(true) + await runtime.release(owner) + await runtime.release(owner) + }) + + it('keeps mutations before an exception and recovers after syntax and output errors', async () => { + const { run } = await setup() + await run('let count = 0;') + expect((await run('count++; throw new Error("expected failure")')).error?.message).toContain('expected failure') + expect((await run('count')).value).toBe(1) + expect((await run('const =')).error?.kind).toBe('exception') + expect((await run('() => count')).error?.kind).toBe('invalid-output') + expect((await run('count')).value).toBe(1) + }) + + it('cancels active execution, awaits process exit, and clears all variables', async () => { + const { run } = await setup() + const entered = Promise.withResolvers() + const controller = new AbortController() + const pending = run('let app = {}; await tools.value({}); while (true) {}', { + signal: controller.signal, + bindings: bindings(async () => { entered.resolve(true); return null }), + }) + await entered.promise + controller.abort() + expect((await pending).error?.kind).toBe('abort') + expect((await run('typeof app')).value).toBe('undefined') + }) + + it('resets a timed-out environment and does not reuse an exited process', async () => { + const { run } = await setup() + await run('let retained = 1;') + expect((await run('while (true) {}', { timeoutMs: 50 })).error?.kind).toBe('timeout') + expect((await run('typeof retained')).value).toBe('undefined') + expect((await run('process.exit(7)')).error?.kind).toBe('worker-exit') + expect((await run('40 + 2')).value).toBe(42) + }) + + it('rejects overlapping runs for one owner and permits other owners', async () => { + const { run } = await setup() + const entered = Promise.withResolvers() + const proceed = Promise.withResolvers() + const pending = run('await tools.value({})', { bindings: bindings(async () => { entered.resolve(true); return await proceed.promise }) }) + try { + await entered.promise + await expect(run('1')).rejects.toThrow('concurrent runs') + expect((await run('2', { stateOwner: {} })).value).toBe(2) + } finally { proceed.resolve(null) } + expect((await pending).value).toBeNull() + }) + + it('disposes in-flight execution and removes the service on plugin unload', async () => { + const { run, fiber, ctx, runtime } = await setup() + const entered = Promise.withResolvers() + const pending = run('await tools.value({}); await new Promise(() => {})', { bindings: bindings(async () => { entered.resolve(true); return null }) }) + await entered.promise + await fiber.dispose() + expect((await pending).error?.kind).toBe('abort') + expect(ctx.get('ptcRuntime')).toBeUndefined() + expect(() => runtime.resolve({ program: '1', bindings: [] })).toThrow('after disposal') + await expect(runtime.run({ program: '1', bindings: [], cwd: process.cwd(), timeoutMs: null })).rejects.toThrow('after disposal') + }) + + it('rejects confinement requests and validates options before starting a process', async () => { + const { run, runtime } = await setup() + expect(runtime.sandboxMode).toBeUndefined() + expect(runtime.programFormat).toBe('repl') + expect(runtime.timeout).toEqual({ defaultMs: 120_000, maxMs: 600_000 }) + expect(() => runtime.resolve({ program: '1', bindings: [], sandboxPolicy: { mode: 'read-only', workspaceRoot: process.cwd() } })).toThrow('sandbox policies') + expect(() => runtime.resolve({ program: '1', bindings: [], cwd: 'relative' })).toThrow('absolute') + expect(() => runtime.resolve({ program: '1', bindings: [], timeoutMs: 0 })).toThrow() + await expect(run('1', { bindings: [{ global: 'console', functions: {} }] })).rejects.toThrow('reserved') + expect((await run('1', { signal: AbortSignal.abort() })).error?.kind).toBe('abort') + expect((await run('42', { timeoutMs: null })).value).toBe(42) + }) + + 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') + expect((await run('typeof retained')).value).toBe('undefined') + }) +}) diff --git a/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts b/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts index 820b4d923f..e62c16277e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from 'tsdown' /** Separate entry bundles keep the private process bootstrap self-contained. */ export default defineConfig([ - { entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false }, - { entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] }, + { entry: ['lib/types/index.js', 'lib/types/persistent.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false }, + { entry: { process: 'lib/types/process-entry.js', 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] }, ]) diff --git a/packages/ptc-runtime/ptc-runtime/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime/README.i18n.yaml index 424c231bef..4d7d45d2c8 100644 --- a/packages/ptc-runtime/ptc-runtime/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime/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/README.md -README.md: 658697a73f126722474c3280e74c1a73ec0c8320 -README.zh.md: 70c2315e9ab7ed8597f031fee95f2ca847a90a77 +README.md: 5e6016472ce5a43f7e798449c0b04a47ff2a8d4d +README.zh.md: 12c6401e5c03dea7a484aa804943925802130bb8 diff --git a/packages/ptc-runtime/ptc-runtime/README.md b/packages/ptc-runtime/ptc-runtime/README.md index 658697a73f..5e6016472c 100644 --- a/packages/ptc-runtime/ptc-runtime/README.md +++ b/packages/ptc-runtime/ptc-runtime/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-ptc-runtime` to run one model-written program against host-provided asynchronous functions through a configured backend. A request returns a lossless-JSON value, ordered per-channel logs, or a structured error; program failures resolve in the result, while rejected promises indicate caller misuse. Each run is isolated from prior runs, and the runtime has no knowledge of tools or sessions. Choose an execution backend separately; its language and isolation descriptors identify the required source language and execution substrate but do not themselves promise a security boundary. +Use `dsh-ptc-runtime` to execute model programs against host-provided asynchronous functions. Requests return lossless JSON, captured logs, or structured failures; rejected promises indicate caller misuse. Providers isolate distinct state owners and declare whether source is an async function body or REPL statements. A provider may retain memory for an explicit owner until release; it never restores that memory from a Session. Language and isolation descriptors identify syntax and execution substrate, not security guarantees. ## Table of Contents @@ -29,7 +29,7 @@ Choose this package when you compose a deployment that executes model-written pr ### Run a program -Give the runtime a program and binding namespaces, then call `resolve(request)` followed by `run(spec)`. Resolution validates optional cwd, timeout and sandbox policy against the provider's capabilities and fills deployment defaults. The program runs as an async function body, so top-level `await` and `return` work; a lossless-JSON completion becomes `result.value`, captured text becomes `result.logs`, and program failures become `result.error`. Each output channel preserves its own order, while cross-channel interleaving is backend-dependent. +Call `resolve(request)` followed by `run(spec)` with a program and binding namespaces. Resolution validates cwd, timeout and sandbox choices and applies provider defaults. `programFormat` selects an async function body with top-level `await` and `return`, or REPL statements with top-level declarations, `await`, and a final expression. Lossless-JSON completion becomes `result.value`, text becomes `result.logs`, and failures become `result.error`. ```text const spec = ctx.ptcRuntime.resolve({ @@ -44,6 +44,8 @@ const result = await ctx.ptcRuntime.run(spec) Backends expose `language` and `isolation` as diagnostic descriptors; neither grants authority or proves confinement. [`dsh-ptc-runtime-node`](../ptc-runtime-node/README.md) executes erasable TypeScript in a fresh managed Node process under the resolved sandbox policy. The private [`dsh-experimental-ptc-runtime-python`](../../experimental/ptc-runtime-python/README.md) provider executes Python in a fresh CPython subprocess without file confinement. `sandboxMode` advertises a provider's deployment file-policy mode, or is absent when that capability is unsupported. +For the optional [persistent Node provider](../ptc-runtime-node/README.md#persistent-repl), pass the same `stateOwner` object across runs and await `release(owner)` at the owner's end. Different object identities never share an environment; omitted owners use a temporary environment. Stateless providers ignore ownership. Calls for one retained owner must not overlap. + ### Name your bindings portably Binding-global and error-class names are language-portable: they must match `[A-Za-z_][A-Za-z0-9_]*`, avoid every portable target language's reserved words, and avoid backend-owned slots, so one namespace list is valid against every backend. A name like `$tools`, `lambda`, or `console` fails the run before it starts; the exact exclusion sets are part of the seam contract. @@ -126,7 +128,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. These limits define what the seam cannot do; they are current package constraints, not a task backlog. - **`run()` is one-shot** — `logs` arrive only on the resolved `PtcRunResult`; the seam exposes no streaming-log or progress API for a live program's output. -- **No state survives between runs** — every request runs against a fresh world; a persistent REPL-style kernel is deferred until a backend brings its own logging story. +- **Retained memory is disposable**: providers may keep state for an explicit owner; restart, release, and provider-specific failures discard it. Programs, binding calls and results remain logged by consumers; live objects are neither serialized nor replayed. - **Providers have different confinement capabilities** — the shipped Node provider enforces a resolved file policy, while the private experimental Python provider rejects an explicit policy. No container provider is supplied. - **No uniform binding byte cap applies across providers** — each provider owns its transport limits; a binding can still allocate memory before its result reaches those limits. @@ -138,9 +140,9 @@ These limits define what the seam cannot do; they are current package constraint This Dev Note is working context for maintainers: undecided directions and open questions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above and the package code. -#### Future: persistent kernel backend +#### Retained state -A REPL-style kernel that keeps state across `run_code` calls remains undecided; it would need its own logging story, because the no-state-between-runs contract is what keeps every request reconstructable from the session log alone. +Retained state has no disk checkpoint or restoration protocol. Consumers must tolerate its loss and retain only model-visible programs and outcomes in the Session log. #### Future: container backend diff --git a/packages/ptc-runtime/ptc-runtime/README.zh.md b/packages/ptc-runtime/ptc-runtime/README.zh.md index 70c2315e9a..12c6401e5c 100644 --- a/packages/ptc-runtime/ptc-runtime/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-ptc-runtime`,可通过已配置的后端,针对宿主提供的异步函数运行一段模型编写的程序。请求返回无损 JSON 值、通道内有序的日志或结构化错误;程序失败在结果中 resolve,而 Promise reject 表示调用方误用。每次运行都与先前运行隔离,且运行时不了解工具或会话。执行后端需另行选择;其语言与隔离描述符标明所需的源语言和执行基底,但这些描述符本身不承诺安全边界。 +使用 `dsh-ptc-runtime` 执行调用宿主异步函数的模型程序。请求返回无损 JSON、捕获日志或结构化失败;Promise 拒绝表示调用方误用。提供方隔离不同状态所有者,并声明源码是异步函数体还是 REPL 语句。提供方可以为显式所有者保留内存,直到释放,但不会从 Session 恢复这些内存。语言与隔离描述符标识语法和执行基底,不提供安全保证。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 运行一个程序 -向运行时提供程序与绑定命名空间,然后依次调用 `resolve(request)` 和 `run(spec)`。解析根据提供方能力验证可选 cwd、timeout 与沙箱策略,并填入部署默认值。程序作为异步函数体运行,支持顶层 `await` 与 `return`;无损 JSON 完成值成为 `result.value`,捕获文本成为 `result.logs`,程序失败成为 `result.error`。每个输出通道保持自身顺序,跨通道交错由后端决定。 +传入程序和绑定命名空间,依次调用 `resolve(request)` 和 `run(spec)`。解析阶段验证 cwd、超时与沙盒选项,并应用提供方默认值。`programFormat` 选择支持顶层 `await`、`return` 的异步函数体,或支持顶层声明、`await` 和末尾表达式的 REPL 语句。无损 JSON 完成值进入 `result.value`,文本进入 `result.logs`,失败进入 `result.error`。 ```text const spec = ctx.ptcRuntime.resolve({ @@ -44,6 +44,8 @@ const result = await ctx.ptcRuntime.run(spec) 后端以 `language` 与 `isolation` 提供诊断描述符;两者都不授予权限或证明约束。[`dsh-ptc-runtime-node`](../ptc-runtime-node/README.zh.md) 在全新的受管 Node 进程中按已解析沙箱策略执行可擦除 TypeScript。私有的 [`dsh-experimental-ptc-runtime-python`](../../experimental/ptc-runtime-python/README.zh.md) 提供方在全新 CPython 子进程中执行 Python,不提供文件约束。`sandboxMode` 声明提供方的部署文件策略模式;不支持该能力时则缺省。 +使用可选的[持久 Node 提供方](../ptc-runtime-node/README.zh.md#persistent-repl)时,跨调用传入同一个 `stateOwner` 对象,并在所有者结束时等待 `release(owner)`。不同对象不会共享环境;省略所有者时使用临时环境。无状态提供方忽略所有权。同一保留环境的调用不能重叠。 + ### 可移植地命名绑定 binding-global 与 error-class 名称是语言可移植的:必须匹配 `[A-Za-z_][A-Za-z0-9_]*`,避开每个可移植目标语言的保留字,并避开后端拥有的槽位,因此同一份命名空间列表对每个后端都有效。`$tools`、`lambda` 或 `console` 之类的名称会在运行开始前失败;确切的排除集是 seam 约定的一部分。 @@ -126,7 +128,7 @@ binding-global 与 error-class 名称是语言可移植的:必须匹配标识 这些限制说明 seam 不能做什么;它们是当前包约束,不是任务积压。 - **`run()` 是一次性的**——`logs` 只有在 `PtcRunResult` resolve 后才能获得;seam 不提供正在运行的程序所产生输出的流式日志或进度接口。 -- **运行之间不保留状态**——每次请求都在全新环境中运行;持久 REPL 风格内核在某个后端带来自己的日志方案之前保持延期。 +- **保留内存可以丢失**:提供方可以为显式所有者保留状态;重启、释放以及提供方指定的失败会清除状态。程序、绑定调用和结果仍由消费者记录;运行中的对象不会被序列化或回放。 - **提供方的约束能力不同**——已发布 Node 提供方强制执行已解析文件策略,私有实验性 Python 提供方拒绝显式策略。不提供容器提供方。 - **提供方之间没有统一的绑定字节上限**——各提供方负责自己的传输限制;绑定仍可能在结果到达这些限制前分配内存。 @@ -138,9 +140,9 @@ binding-global 与 error-class 名称是语言可移植的:必须匹配标识 本开发备注是维护者的工作上下文:尚未决定的方向与开放问题。它明确不具权威性——已交付的行为与限制以上文和包代码为准。 -#### 未来:持久内核后端 +#### 保留状态 -跨 `run_code` 调用保留状态的 REPL 风格内核仍未决定;它需要自己的日志方案,因为「运行之间不保留状态」的约定正是让每次请求仅凭会话日志即可重建的原因。 +保留状态没有磁盘检查点或恢复协议。消费者必须容忍状态丢失,并在 Session 日志中记录模型可见的程序与结果。 #### 未来:容器后端 diff --git a/packages/ptc-runtime/ptc-runtime/src/index.ts b/packages/ptc-runtime/ptc-runtime/src/index.ts index ae217e590f..731aa9a6b2 100644 --- a/packages/ptc-runtime/ptc-runtime/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime/src/index.ts @@ -98,7 +98,7 @@ declare module '@deepseek-ai/cordis' { * Registers one `ctx.ptcRuntime` implementation. Program, budget, abort, and substrate * failures resolve in {@link PtcRunResult}; only Service Definition contract misuse rejects. Implementations bridge * structured-cloneable bindings, materialize each declared namespace rejection - * class, treat programs as hostile peers, isolate runs from one another, and + * class, treat programs as hostile peers, isolate distinct state owners, and * terminate and await in-flight runs during disposal. */ export abstract class PtcRuntime extends Service { @@ -121,6 +121,16 @@ export abstract class PtcRuntime extends Service { */ abstract readonly isolation: string + /** Source evaluation form; REPL providers retain declarations for an explicitly supplied state owner. */ + get programFormat(): 'function-body' | 'repl' { return 'function-body' } + + /** + * Release an owner's retained environment and await its execution resources. + * Stateless providers have no retained resources to release. + * @param owner - The exact object supplied as the request's state owner. + */ + async release(owner: object): Promise { void owner } + /** Provider-owned program usage guidance for consumers to present alongside the source language. */ get executionInstructions(): string { return '' } diff --git a/packages/ptc-runtime/ptc-runtime/src/types.ts b/packages/ptc-runtime/ptc-runtime/src/types.ts index a6f061b2ea..b00957c5c4 100644 --- a/packages/ptc-runtime/ptc-runtime/src/types.ts +++ b/packages/ptc-runtime/ptc-runtime/src/types.ts @@ -73,11 +73,18 @@ export interface PtcBindingNamespace { export interface PtcRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It - * runs as the body of an async function: top-level `await` and `return` - * are available, and the completion value becomes + * follows the provider's programFormat: an async function body supports + * top-level `await` and `return`; a REPL supports top-level declarations, + * `await`, and a final expression. The completion value becomes * {@link PtcRunResult.value}. */ program: string + /** + * Object-identity owner for optional in-memory state. Omission requests a fresh + * environment. Consumers must await runtime.release(owner) when the owner ends. + * Stateless providers ignore this field; retained state is never durable. + */ + stateOwner?: object /** Host functions exposed to the program, one global object per namespace. */ bindings: PtcBindingNamespace[] /** Working directory in the mounted filesystem and subprocess execution world. */ @@ -145,7 +152,7 @@ export interface PtcRunResult { /** Applied file policy and observed denial, when the provider enforces file policy. */ sandbox?: PtcRunSandbox /** - * The program's completion value (its top-level `return`), when it ran to + * The program's completion value (a return or final REPL expression), when it ran to * completion and the value crossed the runtime's lossless-JSON boundary. * Invalid or over-limit completions fail the run instead of substituting a * rendered string; a failed or value-less run leaves this absent. diff --git a/packages/workflow/workflow-ptc/README.i18n.yaml b/packages/workflow/workflow-ptc/README.i18n.yaml index 4b17b7fd9c..9480f65103 100644 --- a/packages/workflow/workflow-ptc/README.i18n.yaml +++ b/packages/workflow/workflow-ptc/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/workflow/workflow-ptc/README.md -README.md: ddc160a2725d36cf30afede2d4afed746a6b89d1 -README.zh.md: 3b562bfe12f5dfdc22b55f209d2120a548d60c24 +README.md: e37d2795b1ff4dcf143165741d3742fe9901d27a +README.zh.md: 6ead4447e4f5d92a4679987afe5f131b4a4148e8 diff --git a/packages/workflow/workflow-ptc/README.md b/packages/workflow/workflow-ptc/README.md index ddc160a272..e37d2795b1 100644 --- a/packages/workflow/workflow-ptc/README.md +++ b/packages/workflow/workflow-ptc/README.md @@ -56,7 +56,7 @@ Invalid metadata, an unparseable body, an unavailable provider route or a per-ru ### File policy and cancellation -The engine resolves the calling Session's standing file policy and cwd for PTC execution. The VM retains the documented helper API, but it is not a security boundary: code that reaches Node remains subject to the selected OS file policy. The program-visible environment is empty. Network access is not restricted by the file policy. +The engine resolves the calling Session's standing file policy and cwd. It passes file policy only to a provider that supports confinement. The optional persistent Node provider executes an async wrapper in a temporary unsandboxed environment, so workflow-local variables never join the Agent's retained REPL. Network access is not restricted by the file policy. The workflow requests `timeoutMs: null` from PTC. Its initial VM slice still has `syncTimeoutMs`, and a caller's abort signal still applies, including an enclosing tool deadline. Cancellation immediately aborts the PTC process and pending or active subagents. The caller must dispose every run and await child cleanup; there is no separate workflow cleanup timer. diff --git a/packages/workflow/workflow-ptc/README.zh.md b/packages/workflow/workflow-ptc/README.zh.md index 3b562bfe12..6ead4447e4 100644 --- a/packages/workflow/workflow-ptc/README.zh.md +++ b/packages/workflow/workflow-ptc/README.zh.md @@ -56,7 +56,7 @@ Node PTC 提供方的 `maxPendingCalls` 也限制工作流并发:子 agent 启 ### 文件策略与取消 -引擎为 PTC 执行解析调用 Session 的常设文件策略与 cwd。VM 保留文档说明的辅助 API,但它不是安全边界:触达 Node 的代码仍受所选 OS 文件策略约束。程序可见的环境为空。文件策略不限制网络访问。 +引擎解析调用方 Session 的常设文件策略和 cwd,只向支持约束的提供方传递文件策略。可选持久 Node 提供方在临时无沙盒环境中执行异步包装函数,因此工作流局部变量不会进入 Agent 保留的 REPL。文件策略不限制网络访问。 工作流向 PTC 请求 `timeoutMs: null`。最初的 VM 片段仍受 `syncTimeoutMs` 限制,调用方的中止信号仍然生效,包括外层工具的截止。取消立即中止 PTC 进程及待启动或活跃的 subagent。调用方必须释放每次运行并等待子 agent 清理;不另设工作流清理定时器。 diff --git a/packages/workflow/workflow-ptc/src/host.ts b/packages/workflow/workflow-ptc/src/host.ts index 843d48f219..333b778b52 100644 --- a/packages/workflow/workflow-ptc/src/host.ts +++ b/packages/workflow/workflow-ptc/src/host.ts @@ -274,10 +274,10 @@ export class PtcWorkflowRun implements WorkflowRun { let result: WorkflowResult try { const outcome = await this.runtime.run(this.runtime.resolve({ - program: PROGRAM, + program: this.runtime.programFormat === 'repl' ? `(async () => {\n${PROGRAM}\n})()` : PROGRAM, bindings: [{ global: 'workflowHost', functions: this.bindings() }], cwd: this.policy.workspaceRoot, - sandboxPolicy: this.policy, + ...this.runtime.sandboxMode === undefined ? {} : { sandboxPolicy: this.policy }, timeoutMs: null, signal: this.controller.signal, })) diff --git a/tsconfig.base.json b/tsconfig.base.json index 8b15b0bb7c..119998f3d9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -416,6 +416,7 @@ "@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"], "@deepseek-ai/dsh-ptc-runtime": ["./packages/ptc-runtime/ptc-runtime/src"], "@deepseek-ai/dsh-ptc-runtime-node": ["./packages/ptc-runtime/ptc-runtime-node/src"], + "@deepseek-ai/dsh-ptc-runtime-node/persistent": ["./packages/ptc-runtime/ptc-runtime-node/src/persistent.ts"], "@deepseek-ai/dsh-pwsh-sandbox": ["./packages/shell/pwsh-sandbox/src"], "@deepseek-ai/dsh-remote-mock": ["./packages/test-support/remote-mock/src"], "@deepseek-ai/dsh-repeat-tool-reminder": ["./packages/guard/repeat-tool-reminder/src"],