From b96fea90f32f91e6a6c9e29f79517776edcbd98d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:44:14 +0800 Subject: [PATCH 01/18] rfc: single-file executable SDK runtime distribution (single-exe) --- docs/rfc/INDEX.md | 1 + ...cutable-sdk-runtime-distribution.i18n.yaml | 6 ++ ...ile-executable-sdk-runtime-distribution.md | 83 +++++++++++++++++++ ...-executable-sdk-runtime-distribution.zh.md | 83 +++++++++++++++++++ scripts/translation-pairing.manifest.json | 1 + 5 files changed, 174 insertions(+) create mode 100644 docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md create mode 100644 docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 1483401835..01fd7c2b4e 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -134,6 +134,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 | | [Tool-call timeout policy as a plugin](implemented/architecture/2026-07-07-tool-call-timeout-policy.md) | 2026-07-07 | | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | +| [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 | | [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml new file mode 100644 index 0000000000..bf09a201d4 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: eab441e9b73d49d783a85f457ee84b7f479d86b4 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 4244d9e4e3af6cb04618c24cfa72ef77cd17335c diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md new file mode 100644 index 0000000000..eab441e9b7 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -0,0 +1,83 @@ +# RFC: Single-file executable SDK runtime distribution (single-exe) + +Status: implemented + +English | [中文](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md) + +## Problem + +DeepSeek Harness needs a dedicated SDK distribution form for the Python library — no Node installation, runs directly on the target platform: a single-file executable (hereafter "the exe") that exposes a stdio JSON-RPC serving surface (`HarnessSdkServer`, the Python SDK's peer), where the plugins and configuration actually booted are decided entirely by a `cordis.yml` supplied from outside the exe. + +- The JSONRPC protocol for talking to the Python SDK is already validated +- A standardized way for cordis.yml to load every plugin (ESModule) is needed +- The distribution must carry the Node runtime, and support a locally linked source debugging mode + +## Decision + +### Packaging route: @yao-pkg/pkg's `--sea` mode + +The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](https://github.com/yao-pkg/pkg) (the actively maintained fork after vercel/pkg was archived). Relative to Node's native SEA, pkg adds a `/snapshot` VFS and runtime module hooks on top, hands the ESM entry to Node's default ESM loader unchanged, and depends on no ESM→CJS transpilation. +> Measured (macos-arm64, node24 target, pkg 6.21.0): bare-specifier ESM dynamic import inside the VFS (including top-level await), CJS interop, `node:sqlite`, fail-loud on package names outside the set, and on-disk ESM import outside the VFS all pass; `import.meta.url` comes through unchanged as `file:///snapshot/...`. + +`--sea` requires target ≥ node22; the exe uniformly targets node24. One pkg invocation packages exactly one target; multi-platform builds invoke it once per platform. + +Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay goldens, `$DSH_SNAPSHOT`); this document says "VFS" for the former. + +### The serving surface is a plugin: the two packages ui/jsonrpc + ui/jsonrpc-agent + +The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `ui/acp` + `ui/acp-agent` pattern — the serving surface is itself a plugin: + +- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering the `shutdown` request it disposes its own fiber, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). +- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md) (`@deepseek-ai/dsh-jsonrpc-agent`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). + +Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. + +### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root + +Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. + +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. Two closure-completeness rules learned from measurement: in-repo pure libraries that closure members reference as peerDependency (`dsh-brand`, `dsh-timeout`, `dsh-invariants`, `@cordisjs/plugin-timer`) must be listed explicitly in the manifest's `dependencies`; deploy packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. + +### Build pipeline and artifacts + +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the artifacts `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` (the CI artifact) and are copied back into the runtime directory. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. + +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), manually triggered via `workflow_dispatch` only; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached and artifacts uploaded per platform; macOS ad-hoc signing is handled by pkg. Windows is a non-goal. + +### Python SDK distribution: two carriers, exe for production, node for development + +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel/sdist distributions. + +The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. + +### Naming lineage + +`@deepseek-ai/dsh-jsonrpc-agent` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`. + +## Disposition of worker-style plugins + +`dsh-workflow-workerthread` and `dsh-code-runtime-worker` depend on on-disk sibling files via `new Worker(new URL('./worker.js', import.meta.url))`. PoC measurement: pkg's Worker patch intercepts **string paths only**; the URL-object form finds no file inside the VFS. The fix is already known (`fileURLToPath()` to a string), but this round's review decision is **do not verify, do not promise, do not handle**: they compile into the exe with the full set, and behavior when an external `cordis.yml` references them is undefined. + +## Testing + +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the 28 pytest cases in `python/sdk/tests` cover the client protocol (18 cases, keyless, against a fake runtime peer), dual-carrier launch (6 cases, each skipping independently when the artifact is missing), and the carrier resolution rules (4 cases). End-to-end tier: the acceptance material (five keyless items: env-channel launch, fail-loud on missing config, fail-loud on a bad plugin name, config channel, full turn against a mock endpoint + JSONL on disk; two with-key items: a real turn, and a world check where the bash tool writes a marker file + clean exit) drives the exe from a real Python SDK client. Current debts: running the above suites against the current artifacts, and a built-exe e2e. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). + +Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. + +## Alternatives considered + +**Bare Node native SEA.** The injected main script must be a single CJS file, and the blob carries no filesystem and no module resolution, so a dynamic import of a bare specifier has nothing to resolve against; the only option is compiling plugins statically into the main script and registering them by hand — bypassing standard module resolution and hardcoding the plugin set, contrary to "configuration decides everything". The final route is in fact "the official SEA foundation + pkg's VFS/module-hook layer"; what was rejected is the bare use, not SEA itself. + +**pkg standard mode.** Killed by the PoC, not a trade-off: it turns ESM into CJS + V8 bytecode via esbuild, the runtime vm compilation wires up no dynamic-import callback, every `import()` throws `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`, and `--options experimental-require-module` has no effect; it also depends on community-patched Node binaries (no macos-arm64 prebuilt; compiling from source on the spot takes about 10 minutes). Zero viability for this repo's architecture. + +**Pre-bundling each package ESM→CJS into the VFS.** The compromise that keeps real resolution semantics and only downgrades the module format; `--sea` passed measurement outright, so this layer of build complexity never needed introducing. + +**jsonrpc-agent carrying the full closure dependencies.** The app bin would declare 53+ dependencies it never imports — a "packaging manifest" masquerading as real dependency relationships — and would force constraints to open two exceptions for it, cordis-in-dependencies and a files wildcard. With the closure manifest landing on the python-side manifest package, constraints needs no exception at all and the bin keeps the normal package shape isomorphic to acp-agent. + +**An open plugin set (loading user plugins from disk).** This round ships a closed set; the PoC incidentally confirmed that on-disk ESM import outside the VFS works (through the `ctx.baseUrl` relative-path channel). It is listed as a future evolution, which must separately solve sharing the cordis instance inside the exe with external plugins. + +## Consequences + +**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving surface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. + +**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial); worker-style plugins have undefined behavior inside the exe; acceptance and coverage carry catch-up debts (see Testing). diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md new file mode 100644 index 0000000000..4244d9e4e3 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -0,0 +1,83 @@ +# RFC: 单文件可执行的 SDK 运行时分发(single-exe) + +Status: implemented + +[English](2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 中文 + +## 问题 + +DeepSeek Harness 需要为 Python 库专门提供一个免安装 Node、可直接在目标平台运行的 SDK 分发形态:一个单文件可执行程序(下称 exe),对外提供 stdio JSON-RPC 服务面(`HarnessSdkServer`,Python SDK 的对端),且实际启动的插件与配置完全由 exe 外部输入的 `cordis.yml` 决定。 + +- 与 Python SDK 通信的 JSONRPC 协议已经过验证 +- 需要提供标准化的 cordis.yml 加载所有插件(ESModule)的能力 +- 分发物要自带 Node 运行时,并支持本地源码链接的调试模式 + +## 决策 + +### 打包路线:@yao-pkg/pkg 的 `--sea` 模式 + +exe 用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后的活跃维护 fork)的 **`--sea`(enhanced SEA)模式**打包。相比 Node 原生 SEA,pkg 在其上加装 `/snapshot` VFS 与运行时模块钩子,ESM 入口原样交给 Node 默认 ESM loader,不依赖任何 ESM→CJS 转译。 +> 实测(macos-arm64、node24 target、pkg 6.21.0):VFS 内裸包名 ESM 动态 import(含顶层 await)、CJS 互操作、`node:sqlite`、集合外包名 fail loud、VFS 外磁盘 ESM import 全部通过,`import.meta.url` 原样为 `file:///snapshot/...`。 + +`--sea` 要求 target ≥ node22,exe 统一以 node24 为 target;单次 pkg 调用只打一个 target,多平台各调一次。 + +术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「snapshot」(ACP replay goldens、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 + +### serving 面是插件:ui/jsonrpc + ui/jsonrpc-agent 两包 + +确定性协议实现(`server.ts` / `transport.ts`)按 `ui/acp` + `ui/acp-agent` 的既有模式落为两包——serving 面本身也是插件: + +- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件,apply 时在进程 stdio 上挂 `HarnessSdkServer` + 行式 JSON-RPC transport,disposal 走 `ctx.effect()`。是否服务由 `cordis.yml` 决定;一份 yml 没挂它就是一个不 serve 的合法进程。协议级退出归插件(`shutdown` 请求应答后 dispose 自身 fiber 再 `exit(0)`;HMR 式卸载只停服务不退进程)。 +- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md)(`@deepseek-ai/dsh-jsonrpc-agent`):薄 app bin——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`,boot 完即毕,server 由 yml 里的 `dsh-jsonrpc` 条目带起。依赖只有 app-boot。进程级退出归 bin(stdin EOF/SIGTERM → dispose 后 0,SIGINT → 130)。 + +配置发现两通道,缺失即报错:`DSH_CORDIS_CONFIG` 环境变量优先(SDK 客户端约定),argv 位置参数次之;无任何默认路径或内置回退——「实际启动的插件由外部 cordis.yml 决定」是硬语义。 + +### 插件解析:VFS 装真实包树,闭包清单即 deploy root + +exe 的 VFS 内是**构建产物形态的真实包树**(各包 `lib/` + 真实 `node_modules`),Loader 解析插件名走标准动态 `import()`:裸包名从 VFS 内 Loader 位置沿 `node_modules` 向上解析,天然落在 VFS 内。封闭集不需要白名单代码——集合就是 VFS 里装了什么,引用集合外的名字 import 失败。 + +deploy root 是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm workspace 成员、零代码纯依赖清单)——「exe 装什么插件」与「Python runtime 分发什么」的合一事实源。往 exe 加插件 = 清单加一行依赖再重打包。两条实测得来的闭包完整性规则:闭包成员以 peerDependency 引用的仓库内纯库(`dsh-brand`、`dsh-timeout`、`dsh-invariants`、`@cordisjs/plugin-timer`)必须显式列进清单 `dependencies`;deploy 按各包 `files` 打包,tsdown 拆出的共享 chunk 必须被 `files` 覆盖。 + +### 构建管线与产物 + +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):`pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 产物 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/`(CI artifact)并拷回 runtime 目录。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 + +CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅 `workflow_dispatch` 手动触发,linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,`~/.pkg-cache` 缓存,artifact 按平台上传;macOS ad-hoc 签名由 pkg 处理。Windows 是非目标。 + +### Python SDK 分发:双载体,exe 为生产、node 为开发 + +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk`(客户端)+ `python/sdk-runtime`(运行时载体包)。runtime 包数据目录三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe、构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 自动解析**只找 exe**;node 载体仅显式 `DSH_RUNTIME_MODE=node` 启用(跑 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需系统 node ≥22.19),定位是本仓库成员的开发验证通道,不进 wheel/sdist 分发物。 + +exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢复:调用方没给 `cordis`、没显式指定 runtime、环境无 `DSH_CORDIS_CONFIG` 时,客户端把检入的默认 `cordis.yml`(agent-core + 预载 llm-deepseek + JSONL 持久化 + bash-local + `dsh-jsonrpc` serving 条目,`!!js` 环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 + +### 命名血统 + +`@deepseek-ai/dsh-jsonrpc-agent`(包)→ `dsh-jsonrpc-agent`(bin)→ `dsh-jsonrpc-agent-pkg`(闭包清单;无 scope 前缀,刻意避开 constraints 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。wire `serverInfo.name` 保持 `deepseek-harness-sdk-runtime`(协议稳定值);Python dist 名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 + +## worker 类插件的处置 + +`dsh-workflow-workerthread` 与 `dsh-code-runtime-worker` 以 `new Worker(new URL('./worker.js', import.meta.url))` 依赖磁盘兄弟文件。PoC 实测:pkg 的 Worker 补丁只拦截**字符串路径**,URL 对象形态在 VFS 内找不到文件——修法已明确(`fileURLToPath()` 转字符串),但本期评审决策是**不验证、不承诺、不处理**:它们随全量集合编译进 exe,外部 `cordis.yml` 引用时行为未定义。 + +## 测试 + +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:`python/sdk/tests` 的 28 例 pytest 覆盖客户端协议(18 例,keyless、假运行时对端)、双载体启动(6 例,产物缺失时独立 skip)与载体解析规则(4 例)。端到端层:验收物料(keyless 五项:env 通道启动、缺配置 fail-loud、坏插件名 fail-loud、配置通道、mock 端点完整回合 + JSONL 落盘;带 key 两项:真实回合、bash 工具写 marker 文件的世界校验 + 干净退出)由真实 Python SDK 客户端驱动 exe。当前欠账:上述套件对当前产物的运行与 built-exe e2e。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 + +手工驱动注意:bin 视 stdin EOF 为「客户端已走」并立即 dispose,短命管道会中止在飞回合——管道驱动必须保持 stdin 打开到回合结束。 + +## 曾考虑的替代方案 + +**Node 原生 SEA 裸用。** 注入主脚本必须是 CJS 单文件、blob 内无文件系统与模块解析,动态 import 裸包名无从解析,只能把插件静态编译进主脚本并手工注册——绕过标准模块解析、插件集合被硬编码,与「配置决定一切」相悖。最终路线实为「官方 SEA 地基 + pkg 的 VFS/模块钩子层」,否掉的是裸用而非 SEA 本身。 + +**pkg standard 模式。** PoC 判死,非取舍:它把 ESM 经 esbuild 转 CJS + V8 字节码,运行时 vm 编译未接动态 import 回调,任何 `import()` 一律抛 `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`,`--options experimental-require-module` 无效;且依赖社区补丁 Node 二进制(macos-arm64 无预编译,现场源码编译约 10 分钟)。对本仓库架构零可行性。 + +**每包 ESM→CJS 预打包进 VFS。** 保持真实解析语义、只降级模块格式的折中;`--sea` 直接通过实测,这层构建复杂度无需引入。 + +**jsonrpc-agent 背全量闭包依赖。** app bin 声明 53+ 个它不 import 的依赖,「打包清单」冒充真实依赖关系,且迫使 constraints 为其开 cordis-in-dependencies 与 files-通配两个例外。闭包清单落在 python 侧的清单包上,constraints 无需任何例外,bin 保持与 acp-agent 同构的正常包形状。 + +**开放插件集(磁盘加载用户插件)。** 本期封闭集;PoC 顺带证实 VFS 外磁盘 ESM import 可行(经 `ctx.baseUrl` 相对路径通道),列为后续演进,需另解外部插件与 exe 内 cordis 实例的共享问题。 + +## 后果 + +**买到的**:目标平台零依赖单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译无注册表);serving 面、插件集、配置三者全部收敛到 `cordis.yml` + 一份依赖清单两个事实源;exe 与 node 双载体同树同语义,开发验证不必等打包;官方 Node 二进制消除了补丁二进制供应链顾虑。 + +**付出的**:产物 174MB 级且源码原样进 blob(无字节码混淆,闭源分发诉求需另行评估);pkg 的 VFS/模块钩子层仍是社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级走显式改动);`--sea` 单 target 单次调用(与 CI 每平台一腿匹配,本地多平台构建串行);worker 类插件在 exe 内行为未定义;验收与覆盖率存在补跑欠账(见「测试」)。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index a92381502a..965aae795f 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -4,6 +4,7 @@ "docs/development.md", "docs/i18n/README.md", "docs/i18n/translation-rules.md", + "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md" ], "excluded": [ From 67fe6c10b66b00cddc39ebb725ef2119ed1fdd73 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:08:12 +0800 Subject: [PATCH 02/18] jsonrpc: SDK serving surface as plugins (dsh-jsonrpc + dsh-jsonrpc-agent) --- docs/config-catalog.md | 37 ++ docs/event-producer-consumer.md | 8 +- docs/module-graph.md | 9 + knip.json | 3 + packages/ui/README.md | 8 +- packages/ui/jsonrpc-agent/README.md | 17 + packages/ui/jsonrpc-agent/package.json | 41 ++ packages/ui/jsonrpc-agent/src/bin.ts | 75 ++++ packages/ui/jsonrpc-agent/src/index.ts | 14 + packages/ui/jsonrpc-agent/tsconfig.json | 21 + packages/ui/jsonrpc-agent/tsdown.config.ts | 19 + packages/ui/jsonrpc/README.md | 23 ++ packages/ui/jsonrpc/package.json | 46 +++ packages/ui/jsonrpc/src/index.ts | 142 +++++++ packages/ui/jsonrpc/src/server.ts | 236 +++++++++++ packages/ui/jsonrpc/src/transport.ts | 215 ++++++++++ .../ui/jsonrpc/tests/plugin-apply.spec.ts | 266 ++++++++++++ .../ui/jsonrpc/tests/plugin-shape.spec.ts | 32 ++ packages/ui/jsonrpc/tests/server.spec.ts | 378 ++++++++++++++++++ packages/ui/jsonrpc/tests/transport.spec.ts | 202 ++++++++++ packages/ui/jsonrpc/tsconfig.json | 33 ++ pnpm-lock.yaml | 60 +++ tsconfig.build.json | 2 + tsconfig.json | 2 + 24 files changed, 1882 insertions(+), 7 deletions(-) create mode 100644 packages/ui/jsonrpc-agent/README.md create mode 100644 packages/ui/jsonrpc-agent/package.json create mode 100644 packages/ui/jsonrpc-agent/src/bin.ts create mode 100644 packages/ui/jsonrpc-agent/src/index.ts create mode 100644 packages/ui/jsonrpc-agent/tsconfig.json create mode 100644 packages/ui/jsonrpc-agent/tsdown.config.ts create mode 100644 packages/ui/jsonrpc/README.md create mode 100644 packages/ui/jsonrpc/package.json create mode 100644 packages/ui/jsonrpc/src/index.ts create mode 100644 packages/ui/jsonrpc/src/server.ts create mode 100644 packages/ui/jsonrpc/src/transport.ts create mode 100644 packages/ui/jsonrpc/tests/plugin-apply.spec.ts create mode 100644 packages/ui/jsonrpc/tests/plugin-shape.spec.ts create mode 100644 packages/ui/jsonrpc/tests/server.spec.ts create mode 100644 packages/ui/jsonrpc/tests/transport.spec.ts create mode 100644 packages/ui/jsonrpc/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1e8bd9e508..0183b44d37 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -328,6 +328,42 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts) +## `@deepseek-ai/dsh-jsonrpc` + +Requires: `agents` + +```ts config-catalog +/** + * Plugin config. Every field is a runtime-only test seam — none is part of the + * schemastery {@link Config}, so nothing here is settable from a `cordis.yml` + * (production always serves the process stdio and exits via `process.exit`). + */ +export interface JsonRpcConfig { + /** + * Transport input override. Production omits this (the plugin reads + * `process.stdin`); tests inject an in-memory `Readable` to drive the server + * without a subprocess. + */ + input?: Readable + /** + * Transport output override. Production omits this (the plugin writes + * `process.stdout` — the protocol channel); tests inject an in-memory + * `Writable` to capture frames. + */ + output?: Writable + /** + * Process-exit override for the `shutdown` request path. Production omits + * this (`process.exit`); tests inject a recorder so a driven shutdown does + * not kill the test process. + */ + exit?: (code: number) => void +} +``` + +Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`) + +Source: [`packages/ui/jsonrpc/src/index.ts:55`](../packages/ui/jsonrpc/src/index.ts) + ## `@deepseek-ai/dsh-llm-deepseek` Requires: `llm` @@ -1168,6 +1204,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) +- `@deepseek-ai/dsh-jsonrpc-agent` ([`packages/ui/jsonrpc-agent/src/index.ts`](../packages/ui/jsonrpc-agent/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a8810a01fe..5b0a82673e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | @@ -25,13 +25,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 2acee6ffc5..e4d0b3367d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -92,6 +92,8 @@ flowchart TD pkg_acp["acp"] pkg_acp_agent["acp-agent"] pkg_app_boot["app-boot"] + pkg_jsonrpc["jsonrpc"] + pkg_jsonrpc_agent["jsonrpc-agent"] pkg_stdio_agent["stdio-agent"] pkg_tool_ask_user["tool-ask-user"] pkg_user_approval["user-approval"] @@ -284,6 +286,11 @@ flowchart TD pkg_subagent_mock --> pkg_agent pkg_subagent_mock --> pkg_llm pkg_subagent_mock --> pkg_subagent + pkg_jsonrpc --> pkg_agent + pkg_jsonrpc --> pkg_llm + pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_session + pkg_jsonrpc --> pkg_subagent pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_brand pkg_workflow_workerthread --> pkg_llm @@ -323,6 +330,7 @@ flowchart TD | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | +| [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | @@ -377,6 +385,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/knip.json b/knip.json index cf43b90e34..f37f1d7636 100644 --- a/knip.json +++ b/knip.json @@ -81,6 +81,9 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/jsonrpc-agent": { + "project": ["src/**/*.ts"] + }, "packages/subagent/subagent-spawn": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/ui/README.md b/packages/ui/README.md index 604eff6521..4f5a6dc920 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -10,10 +10,12 @@ Integrations that expose the agent to an external editor or client. These are ** | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | -| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | +| `jsonrpc/` | Stdio JSON-RPC SDK server plugin: serves `HarnessSdkServer` to out-of-process SDK clients (the Python SDK) on the process stdio | (drives `ctx.agents`) | +| `jsonrpc-agent/` | JSON-RPC SDK server APP: a bin-only boot of an external `cordis.yml` whose `jsonrpc` entry is the serving face; the single-exe runtime entrypoint | (`bin` only) | +| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. -`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention. +`stdio-agent` and `acp-agent` are the two composing **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. `jsonrpc-agent` is the third app but bin-only — no composition plugin, because the SDK runtime's hard semantic is that the external `cordis.yml` composes everything, the serving `jsonrpc` entry included. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention. diff --git a/packages/ui/jsonrpc-agent/README.md b/packages/ui/jsonrpc-agent/README.md new file mode 100644 index 0000000000..d1836c737d --- /dev/null +++ b/packages/ui/jsonrpc-agent/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-jsonrpc-agent + +The **JSON-RPC SDK server app bin** (`dsh-jsonrpc-agent`): boot a harness from an externally supplied `cordis.yml` and let its [`@deepseek-ai/dsh-jsonrpc`](../jsonrpc/README.md) entry serve SDK clients over newline-delimited JSON-RPC on stdio. Structurally the SDK-runtime sibling of [`acp-agent`](../acp-agent/README.md)'s bin, but bin-only: there is no composition plugin here, because "the plugins that actually start come from the external config" is the SDK runtime's hard semantic — the leaf `cordis.yml` composes the spine, the backends, AND the serving face. This package is the entrypoint of the single-exe distribution (its `lib/bin.js` is what the packaged executable runs) — see [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + +## Config discovery + +Two channels, environment first: `$DSH_CORDIS_CONFIG` (the existing SDK-client convention, wins), then the `argv[2]` positional path (`dsh-jsonrpc-agent `, the human channel, isomorphic to `dsh-acp-agent`). An empty value counts as absent on either channel. Neither given, or the path missing on disk: the bin prints a one-line usage naming both channels to stderr and exits 1 — there is no default `./cordis.yml` and no built-in fallback config. A config that names a plugin which fails to load fails loud through the shared [`dsh-app-boot`](../app-boot/README.md) guards (`assertEntriesLoaded` + the unhandled-rejection handler), never a silent half-boot. There is no `DSH_SNAPSHOT` handling: this protocol is not part of the ACP snapshot tier. + +Note the deliberate flip side of config-decides-everything: a config that loads no `dsh-jsonrpc` entry boots fine and serves nothing — the bin cannot know which plugin is "the server". + +## Exit lifecycle + +The bin owns the PROCESS-level exits: stdin EOF (the SDK client is gone — an in-flight turn is deliberately cut off, see the risk note in docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) and `SIGTERM` dispose the root context to quiescence and exit 0; `SIGINT` does the same but exits 130. The PROTOCOL-level exit — a `shutdown` JSON-RPC request answered first, then exit 0 — is owned by the `dsh-jsonrpc` plugin, which holds the server and transport; the two paths are individually idempotent and safe to race. + +## stdout is the protocol + +stdout carries only JSON-RPC frames; the bin and the app-boot guards write diagnostics to stderr only, and the booted config must load no stdout logger (see the `dsh-jsonrpc` README). diff --git a/packages/ui/jsonrpc-agent/package.json b/packages/ui/jsonrpc-agent/package.json new file mode 100644 index 0000000000..aebff4ab3a --- /dev/null +++ b/packages/ui/jsonrpc-agent/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-jsonrpc-agent", + "description": "JSON-RPC SDK server app bin: boots an externally supplied cordis.yml (DSH_CORDIS_CONFIG or argv, no built-in fallback) whose dsh-jsonrpc entry serves SDK clients over stdio; the single-exe runtime entrypoint", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "bin": { + "dsh-jsonrpc-agent": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./bin": { + "types": "./lib/types/bin.d.ts", + "default": "./lib/bin.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/bin.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-app-boot": "workspace:^" + }, + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/jsonrpc-agent/src/bin.ts b/packages/ui/jsonrpc-agent/src/bin.ts new file mode 100644 index 0000000000..b2c9f0c3d2 --- /dev/null +++ b/packages/ui/jsonrpc-agent/src/bin.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * The `dsh-jsonrpc-agent` bin: boot a harness from an externally supplied + * `cordis.yml` whose `@deepseek-ai/dsh-jsonrpc` entry serves SDK clients over + * newline-delimited JSON-RPC on stdio. The shared boot glue — `.env` loading, + * the fail-loud Loader guards, the settle-the-tree boot sequence — lives in + * {@link @deepseek-ai/dsh-app-boot}, shared with the stdio/ACP bins; this bin + * owns only config discovery and the process-level exit lifecycle: + * + * - Config discovery is `$DSH_CORDIS_CONFIG` (the existing SDK-client + * convention, wins) or the `argv[2]` positional path (the human channel, + * isomorphic to `dsh-acp-agent`); an empty value counts as absent. Neither + * given, or the path missing on disk, prints the one-line usage to stderr + * and exits 1. No built-in fallback — the external config IS the deployment + * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * No `DSH_SNAPSHOT` handling: this + * protocol is not part of the ACP snapshot tier. + * - stdin EOF (the SDK client is gone) and SIGTERM dispose the root context + * to quiescence and exit 0; SIGINT does the same but exits 130. The + * `shutdown` JSON-RPC request's answer-then-exit-0 path is owned by the + * `dsh-jsonrpc` plugin, which holds the server (see its README). + * + * IMPORTANT: stdout is the JSON-RPC channel. Diagnostics go to STDERR only (a + * stray stdout write corrupts the protocol frames), which the app-boot guards + * already honor. + * + * @module @deepseek-ai/dsh-jsonrpc-agent/bin + */ + +import { existsSync } from 'node:fs' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' + +const NAME = 'dsh-jsonrpc-agent' + +/* v8 ignore start -- thin self-executing composition over the unit-tested + dsh-app-boot helpers; the serving lifecycle it boots is unit-tested in + @deepseek-ai/dsh-jsonrpc, and the composed artifact is exercised by the + single-exe acceptance drive (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) */ +installFailLoud(NAME) +loadEnv(NAME) + +// Env wins over the positional argument; an empty value on either channel +// counts as absent. There is deliberately NO default `./cordis.yml`: "the +// plugins that actually start come from an explicit external config" is a +// hard semantic of the SDK runtime. +const fromEnv = process.env['DSH_CORDIS_CONFIG'] +const fromArgv = process.argv[2] +const requested = fromEnv !== undefined && fromEnv !== '' + ? fromEnv + : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined +const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) +if (configPath === undefined || !existsSync(configPath)) { + process.stderr.write( + `usage: ${NAME} (or set DSH_CORDIS_CONFIG=, which wins); the config is required — there is no built-in fallback\n`, + ) + process.exit(1) +} + +const ctx = await boot(NAME, configPath) +let exiting = false + +async function disposeAndExit(code: number): Promise { + if (exiting) return + exiting = true + try { + await ctx.fiber.dispose() + } finally { + process.exit(code) + } +} + +process.stdin.on('end', () => { void disposeAndExit(0) }) +process.on('SIGTERM', () => { void disposeAndExit(0) }) +process.on('SIGINT', () => { void disposeAndExit(130) }) +/* v8 ignore stop */ diff --git a/packages/ui/jsonrpc-agent/src/index.ts b/packages/ui/jsonrpc-agent/src/index.ts new file mode 100644 index 0000000000..39a2206dca --- /dev/null +++ b/packages/ui/jsonrpc-agent/src/index.ts @@ -0,0 +1,14 @@ +/** + * The `dsh-jsonrpc-agent` app package IS its bin (see `./bin.ts`): config + * discovery plus the process-level exit lifecycle around a booted + * `cordis.yml`. This module deliberately exports nothing — unlike the + * stdio/ACP app packages there is no composition plugin here, because the + * serving face is the {@link @deepseek-ai/dsh-jsonrpc} plugin the external + * config loads like any other entry (which plugins actually start is the + * config's decision, the hard semantic of the SDK runtime; see + * docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * + * @module @deepseek-ai/dsh-jsonrpc-agent + */ + +export {} diff --git a/packages/ui/jsonrpc-agent/tsconfig.json b/packages/ui/jsonrpc-agent/tsconfig.json new file mode 100644 index 0000000000..e76defe8e2 --- /dev/null +++ b/packages/ui/jsonrpc-agent/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../app-boot" + } + ] +} diff --git a/packages/ui/jsonrpc-agent/tsdown.config.ts b/packages/ui/jsonrpc-agent/tsdown.config.ts new file mode 100644 index 0000000000..5b09ae2704 --- /dev/null +++ b/packages/ui/jsonrpc-agent/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown' + +/** + * jsonrpc-agent ships TWO entries: the doc-only module (`index`) and the CLI + * `bin` (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. + * The root tsdown builds only `lib/types/index.js`, so this override adds + * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), + * matching every package. + */ +export default defineConfig({ + entry: ['lib/types/index.js', 'lib/types/bin.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md new file mode 100644 index 0000000000..dbc3fddcd0 --- /dev/null +++ b/packages/ui/jsonrpc/README.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-jsonrpc + +The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC server that lets an out-of-process SDK client (e.g. the Python `deepseek_harness` package) drive DeepSeek Harness agents without touching Cordis. The client speaks newline-delimited JSON-RPC on the process stdin/stdout ([`HarnessSdkServer`](src/server.ts): `initialize` → `session/prompt` → `shutdown`, with `session.event` / `session.finished` / `subagent.*` notifications over [`JsonRpcLineTransport`](src/transport.ts)). The SDK-client analogue of the [`acp`](../acp/README.md) bridge, split the same way: this package is the protocol plugin, [`jsonrpc-agent`](../jsonrpc-agent/README.md) is the app bin that boots a `cordis.yml` around it — which process serves this protocol is a config decision, not a hardcoded bin. This plugin is the serving face of the single-exe distribution plan — see [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + +## Wiring + +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`) and demuxes `subagent/end` through the registry. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. + +## Config + +No `cordis.yml`-settable keys. The `JsonRpcConfig` fields (`input`, `output`, `exit`) are runtime-only test seams so a spec can drive the server over in-memory streams without a subprocess or a killed test process; production always serves the process stdio and exits via `process.exit`. + +## stdout is the protocol + +The process stdout this plugin runs in carries only JSON-RPC frames. The tree that loads it must load NO stdout logger (a console logger corrupts the frames) — the guarantee is config-only, same as the ACP bridge. Diagnostics go to stderr. + +## Shutdown and exit semantics + +The plugin owns the PROTOCOL-level exit: a `shutdown` request is answered first (the response frame flushes), then the plugin disposes its own fiber — running the effect disposer: an idempotent `server.shutdown()` (every SDK-created agent disposed to quiescence, event subscriptions detached) plus `transport.close()` — and exits the process with code 0. Own-fiber disposal is deliberate: the request's `server.shutdown()` already flushed all SDK-owned session state, and the process exit that follows is the teardown of the rest of the tree. Process-level exits (stdin EOF → 0, SIGTERM → 0, SIGINT → 130) belong to the app bin, which disposes the whole root context. Fiber disposal WITHOUT a `shutdown` request (HMR-style unload) just stops serving — it never exits the process. + +## Wire notes + +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). The `initialize` params `sessionRoot`, `systemPrompt`, and `clientInfo`, and the `session/prompt` param `profile`, are accepted for wire compatibility but currently unused — persistence roots and the deployment persona come from the `cordis.yml` (see the TODO in [`src/server.ts`](src/server.ts)). diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json new file mode 100644 index 0000000000..be19ab7217 --- /dev/null +++ b/packages/ui/jsonrpc/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-jsonrpc", + "description": "Stdio JSON-RPC SDK server plugin: serves HarnessSdkServer over newline-delimited JSON-RPC on the process stdio, letting an out-of-process SDK client (e.g. the Python SDK) drive DeepSeek Harness agents", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.17.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts new file mode 100644 index 0000000000..9147f63e34 --- /dev/null +++ b/packages/ui/jsonrpc/src/index.ts @@ -0,0 +1,142 @@ +/** + * The SDK-facing stdio JSON-RPC server plugin: mounting it wires a + * {@link JsonRpcLineTransport} over the process stdio and serves + * {@link HarnessSdkServer} (`initialize` → `session/prompt`* → `shutdown`, + * plus the `session.*`/`subagent.*` notifications) to an out-of-process SDK + * client (e.g. the Python `deepseek_harness` package). The structured + * SDK-client analogue of the `acp` bridge: a client-driver plugin over + * `ctx.agents`, not a loop change and not a capability seam. Which process + * actually serves this protocol is a `cordis.yml` decision — the tree that + * loads this plugin IS the SDK server (the `dsh-jsonrpc-agent` bin boots such + * a tree for the single-exe distribution; see + * docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * + * stdout is the protocol: this plugin must run in a tree that loads NO stdout + * logger (the console logger writes to stdout and would corrupt the JSON-RPC + * frames). The guarantee is config-only — see the package README. + * + * Exit-lifecycle split: this plugin owns the PROTOCOL-level exit (the + * `shutdown` request answers first, then the plugin disposes its own fiber and + * exits 0 — see {@link apply}); process-level exits (stdin EOF, SIGTERM, + * SIGINT) belong to the app bin (`dsh-jsonrpc-agent`), which disposes the + * whole root context. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default + * export — the cordis Loader's `unwrapExports` does `exports.default ?? + * exports`, so a stray default would collapse the module to the bare `apply` + * and silently drop `inject`/`name`/`Config` (see docs/postmortem/0001). + * + * @module @deepseek-ai/dsh-jsonrpc + */ + +import type { Context } from 'cordis' +import type { Readable, Writable } from 'node:stream' +import Schema from 'schemastery' +import { HarnessSdkServer } from './server.ts' +import { JsonRpcLineTransport } from './transport.ts' + +export * from './server.ts' +export * from './transport.ts' + +export const name = 'jsonrpc' +// The server programs against the agent factory only: `agents` is read on +// every `session/prompt` (get-or-create) and on `subagent/end` demux. The LLM +// seam is deliberately NOT injected — `initialize` reads it opportunistically +// via `ctx.get('llm')` (the topology-independent lookup for a non-injected +// service, per packages/AGENTS.md) to decide whether to lazily mount the +// DeepSeek adapter for the requested model. +export const inject = ['agents'] + +/** + * Plugin config. Every field is a runtime-only test seam — none is part of the + * schemastery {@link Config}, so nothing here is settable from a `cordis.yml` + * (production always serves the process stdio and exits via `process.exit`). + */ +export interface JsonRpcConfig { + /** + * Transport input override. Production omits this (the plugin reads + * `process.stdin`); tests inject an in-memory `Readable` to drive the server + * without a subprocess. + */ + input?: Readable + /** + * Transport output override. Production omits this (the plugin writes + * `process.stdout` — the protocol channel); tests inject an in-memory + * `Writable` to capture frames. + */ + output?: Writable + /** + * Process-exit override for the `shutdown` request path. Production omits + * this (`process.exit`); tests inject a recorder so a driven shutdown does + * not kill the test process. + */ + exit?: (code: number) => void +} + +export const Config: Schema = Schema.object({}) + +/** + * Mount the SDK server on the process stdio: build the line transport and + * {@link HarnessSdkServer}, dispatch incoming requests, and start reading + * frames. Disposal is an effect: disposing this plugin's fiber runs + * `server.shutdown()` (disposes every SDK-created agent to quiescence and + * detaches the event subscriptions) and `transport.close()`. + * + * The `shutdown` request's process-exit semantics live HERE, because the + * plugin owns the server and transport: the request is answered first + * (`setImmediate` lets the response frame flush), then the plugin disposes its + * OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the + * request's `server.shutdown()` already brought every SDK-created agent to + * quiescence (their session logs are flushed by the awaited agent-handle + * disposes), the fiber's effect disposer re-runs the idempotent shutdown and + * closes the transport, and the process exit that follows IS the teardown of + * the rest of the tree (the bin's EOF/signal handlers own root-context + * disposal for the process-level exits). + */ +export function apply(ctx: Context, config: JsonRpcConfig): void { + // Capture the fiber handle NOW, during apply(): the shutdown path runs LATER, + // from the transport's read loop, and must dispose exactly this plugin's + // fiber (cf. the injection-scope capture note in the acp bridge). + const fiber = ctx.fiber + /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ + const input = config.input ?? process.stdin + /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ + const output = config.output ?? process.stdout + /* v8 ignore next -- production exit wiring; tests always inject the runtime seams */ + const exit = config.exit ?? ((code: number): void => { process.exit(code) }) + + const transport = new JsonRpcLineTransport(input, output) + const server = new HarnessSdkServer(ctx, transport) + + // The shutdown-request exit path, exactly once (a second `shutdown` frame + // racing the dispose must not re-enter). `exit(0)` runs even if the dispose + // throws — the client was already answered, so exiting is the honest outcome. + let exiting = false + const disposeAndExit = async (): Promise => { + if (exiting) return + exiting = true + try { + await fiber.dispose() + } finally { + exit(0) + } + } + + transport.onRequest(async (method, params) => { + const result = await server.handleRequest(method, params) + if (method === 'shutdown') { + // Answer the request first (setImmediate lets the response frame + // flush), then dispose this plugin's fiber and exit 0 (see apply's doc). + setImmediate(() => { void disposeAndExit() }) + } + return result + }) + + ctx.effect(() => { + transport.start() + return async () => { + await server.shutdown() + transport.close() + } + }, 'jsonrpc.serve') +} diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts new file mode 100644 index 0000000000..1b403aac49 --- /dev/null +++ b/packages/ui/jsonrpc/src/server.ts @@ -0,0 +1,236 @@ +/** + * `HarnessSdkServer`: the JSON-RPC method surface the `dsh-jsonrpc` plugin + * serves to out-of-process SDK clients (e.g. the Python `deepseek_harness` + * package). Requests: `initialize` → `session/prompt`* → `shutdown`. + * Notifications pushed to the host: `session.event` (every durable session + * event, verbatim), `session.finished` (per prompt turn settle), + * `subagent.started` / `subagent.finished` (child-session lineage and run + * outcomes). The server owns only the SDK-facing session map — the harness + * itself is the context the plugin mounts in; plugins, persistence, and + * the LLM adapter set all come from the external `cordis.yml`. + * + * @module @deepseek-ai/dsh-jsonrpc/server + */ + +import type { Context } from 'cordis' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import type { JsonRpcTransportPeer } from './transport.ts' + +/** Parameters of the `initialize` request (once per process, before any prompt). */ +export interface InitializeParams { + /** Working directory recorded on every SDK-created session's header. */ + cwd: string + /** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */ + model: string + /** Accepted for SDK wire compatibility; unused — persistence roots come from the `cordis.yml`. */ + sessionRoot?: string + /** + * Accepted for SDK wire compatibility; currently NOT applied — the deployment + * persona comes from the `cordis.yml` system-prompt config. TODO(jsonrpc): + * map this onto a per-runtime system-prompt section once a per-agent override + * seam exists. + */ + systemPrompt?: string + /** Accepted for SDK wire compatibility; unused diagnostic client identity. */ + clientInfo?: { name?: string; version?: string } +} + +/** Result of the `initialize` request: the server's identity for the SDK handshake. */ +export interface InitializeResult { + /** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */ + serverInfo: { name: string; version: string } +} + +/** Parameters of a `session/prompt` request (one user turn on one SDK session). */ +export interface SessionPromptParams { + /** The SDK-side session id; an unknown id lazily creates the agent+session pair. */ + sessionId: string + /** The prompt content blocks, sent verbatim as the user message. */ + contentBlocks: ContentBlock[] + /** Accepted for SDK wire compatibility; unused — profiles are not a harness concept. */ + profile?: string +} + +/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */ +export interface SessionPromptResult { + /** Always `true`; the turn outcome is the paired `session.finished` notification. */ + accepted: true +} + +interface SessionRecord { + handle: AgentHandle + lastTurnEnd: TurnEndReason | undefined +} + +interface SubagentRecord { + childSessionId: string + parentSessionId: string | undefined +} + +/** + * The SDK server over a booted harness context. Constructing it subscribes to + * the context's `session/event`, `session/created`, `agent/created`, and + * `subagent/end` events and forwards them to the host as notifications; the + * subscriptions live until {@link shutdown}. One instance serves one transport + * peer for the process lifetime — there is no re-`initialize`. + */ +export class HarnessSdkServer { + private cwd = process.cwd() + private model = 'deepseek' + private llmFiber: { dispose(): Promise } | undefined + private readonly sessions = new Map() + private readonly subagentSessions = new Map() + private readonly disposers: (() => void)[] = [] + + constructor( + private readonly ctx: Context, + private readonly transport: JsonRpcTransportPeer, + ) { + this.disposers.push(ctx.on('session/event', (session, event) => { + if (event.type === 'turn/end') { + const rec = this.sessions.get(String(session.id)) + if (rec) rec.lastTurnEnd = event.data.reason + } + this.transport.notify('session.event', { sessionId: String(session.id), event }) + })) + this.disposers.push(ctx.on('session/created', (session) => { + const parentSession = session.header.parentSession + if (parentSession === undefined) return + this.transport.notify('subagent.started', { + parentSessionId: String(parentSession), + childSessionId: String(session.id), + }) + })) + // Cache agent → session lineage on creation: by the time `subagent/end` + // fires the child agent may already be disposed and gone from the registry. + this.disposers.push(ctx.on('agent/created', (agent) => { + this.subagentSessions.set(String(agent.id), { + childSessionId: String(agent.session.id), + parentSessionId: agent.session.header.parentSession === undefined + ? undefined + : String(agent.session.header.parentSession), + }) + })) + this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { + const rec = this.subagentSessions.get(String(info.id)) + const agent = this.ctx.agents.get(info.id) + const childSessionId = rec?.childSessionId ?? (agent === undefined ? undefined : String(agent.session.id)) + const parentSessionId = rec?.parentSessionId ?? ( + agent?.session.header.parentSession === undefined ? undefined : String(agent.session.header.parentSession) + ) + if (childSessionId === undefined) return + this.transport.notify('subagent.finished', { + provider: info.provider, + agentId: String(info.id), + ...(parentSessionId === undefined ? {} : { parentSessionId }), + childSessionId, + status: info.stopReason === 'completed' || info.stopReason === 'max-tokens' ? 'ok' : 'error', + stopReason: info.stopReason, + ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), + }) + })) + } + + /** + * Handle `initialize`: record the SDK deployment facts (cwd, model) and, when + * no registered adapter serves `params.model`, mount the DeepSeek adapter for + * it (credentials from `$DEEPSEEK_API_KEY`/`$DEEPSEEK_BASE_URL`) — a config + * that already registered an adapter for the model wins. + * @param params - the SDK handshake parameters. + * @returns the server identity for the handshake. + */ + async initialize(params: InitializeParams): Promise { + this.cwd = params.cwd + this.model = params.model + if (!this.llmFiber && !this.hasAdapterFor(this.model)) { + this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { models: [this.model] }) + } + return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } + } + + /** + * Handle `session/prompt`: get-or-create the session's agent, send the + * content as the user message, await turn settle (quiescence), then notify + * `session.finished` with the settled turn's outcome. + * @param params - the target session id and prompt content. + * @returns `{ accepted: true }` after the turn settled. + */ + async prompt(params: SessionPromptParams): Promise { + const rec = this.getOrCreateSession(params.sessionId) + rec.lastTurnEnd = undefined + rec.handle.agent.send(params.contentBlocks) + await rec.handle.agent.whenIdle() + const status = this.finishedStatus(rec.lastTurnEnd) + this.transport.notify('session.finished', { + sessionId: params.sessionId, + status, + reason: rec.lastTurnEnd, + }) + return { accepted: true } + } + + /** + * Handle `shutdown`: dispose every SDK-created agent handle (awaiting loop + * quiescence), unmount the adapter fiber this server mounted (if any), and + * detach the event subscriptions. The CONTEXT stays up — the bin disposes it + * as part of process exit. + * @returns an empty object (the JSON-RPC result). + */ + async shutdown(): Promise> { + const records = [...this.sessions.values()] + this.sessions.clear() + await Promise.all(records.map(rec => rec.handle.dispose())) + await this.llmFiber?.dispose() + this.llmFiber = undefined + while (this.disposers.length > 0) this.disposers.pop()?.() + return {} + } + + /** + * Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a + * JSON-RPC error response) on an unknown method. + * @param method - the JSON-RPC method name. + * @param params - the raw params object from the wire. + * @returns the handler's result, to be serialized as the response. + */ + async handleRequest(method: string, params: Record | undefined): Promise { + switch (method) { + case 'initialize': + return this.initialize(params as unknown as InitializeParams) + case 'session/prompt': + return this.prompt(params as unknown as SessionPromptParams) + case 'shutdown': + return this.shutdown() + default: + throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`) + } + } + + private getOrCreateSession(sessionId: string): SessionRecord { + const existing = this.sessions.get(sessionId) + if (existing) return existing + const handle = this.ctx.agents.create({ + agentId: AgentId(sessionId), + sessionId: SessionId(sessionId), + meta: { cwd: this.cwd }, + agentOptions: { model: this.model }, + }) + const rec: SessionRecord = { handle, lastTurnEnd: undefined } + this.sessions.set(sessionId, rec) + return rec + } + + private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' { + if (!reason) return 'error' + return reason.kind === 'completed' || reason.kind === 'max-tokens' ? 'ok' : 'error' + } + + private hasAdapterFor(model: string): boolean { + return this.ctx.get('llm')?.models().includes(model) ?? false + } +} diff --git a/packages/ui/jsonrpc/src/transport.ts b/packages/ui/jsonrpc/src/transport.ts new file mode 100644 index 0000000000..003c78f4e5 --- /dev/null +++ b/packages/ui/jsonrpc/src/transport.ts @@ -0,0 +1,215 @@ +/** + * Newline-delimited JSON-RPC 2.0 transport over a byte stream pair (the SDK + * server's stdio channel). One JSON frame per line; a frame with `id`+`method` + * is an incoming request, `id` alone matches a pending outgoing request, and + * `method` alone is a notification. Malformed lines are ignored (a resilient + * wire reader, not a validator); handler failures become JSON-RPC error + * responses, never a crashed transport. + * + * @module @deepseek-ai/dsh-jsonrpc/transport + */ + +import { randomUUID } from 'node:crypto' +import type { Readable, Writable } from 'node:stream' + +type JsonRpcId = string | number +type RequestHandler = (method: string, params: Record) => Promise +type NotificationHandler = (method: string, params: Record) => void + +/** + * The outbound half of a JSON-RPC peer — what {@link HarnessSdkServer} needs + * to talk BACK to the host: awaited `request`s and fire-and-forget `notify`s. + * Narrow on purpose so tests substitute a recording fake without a stream pair. + */ +export interface JsonRpcTransportPeer { + /** + * Send a request to the remote peer and await its response. + * @param method - the JSON-RPC method name. + * @param params - the request parameters object. + * @returns the remote peer's `result`; rejects on a JSON-RPC `error` + * response, a write failure, or transport/input closure. + */ + request(method: string, params: Record): Promise + /** + * Send a notification (no response expected). An omitted `params` sends no + * `params` member at all. + * @param method - the JSON-RPC method name. + * @param params - the optional notification parameters object. + */ + notify(method: string, params?: Record): void +} + +interface PendingRequest { + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +/** + * Line-delimited JSON-RPC 2.0 endpoint over a `Readable`/`Writable` pair. + * Inert until {@link start} attaches the input listeners; {@link close} + * detaches them and rejects every pending outgoing request (dispose-safe: the + * streams themselves are not destroyed — the caller owns them). Incoming + * requests are dispatched to the single {@link onRequest} handler (a missing + * handler answers `-32601 method not found`; a throwing handler answers + * `-32603` with the message); incoming notifications go to {@link + * onNotification} and are dropped without one. + */ +export class JsonRpcLineTransport implements JsonRpcTransportPeer { + private buffer = '' + private started = false + private requestHandler: RequestHandler | undefined + private notificationHandler: NotificationHandler | undefined + private readonly pending = new Map() + + constructor( + private readonly input: Readable, + private readonly output: Writable, + ) {} + + /** Attach the input listeners and begin reading frames. Idempotent. */ + start(): void { + if (this.started) return + this.started = true + this.input.on('data', this.onData) + this.input.on('error', this.onInputError) + this.input.on('end', this.onInputEnd) + } + + /** + * Detach the input listeners and reject every pending outgoing request with + * "JSON-RPC transport closed". Safe to call without a prior {@link start}. + */ + close(): void { + this.input.off('data', this.onData) + this.input.off('error', this.onInputError) + this.input.off('end', this.onInputEnd) + this.failPending(new Error('JSON-RPC transport closed')) + } + + /** + * Install THE handler for incoming requests (a later call replaces it). + * @param handler - resolves to the response `result`; a rejection becomes a + * `-32603` error response carrying the message. + */ + onRequest(handler: RequestHandler): void { + this.requestHandler = handler + } + + /** + * Install THE handler for incoming notifications (a later call replaces it). + * @param handler - invoked per notification with the method and normalized + * params object. + */ + onNotification(handler: NotificationHandler): void { + this.notificationHandler = handler + } + + request(method: string, params: Record): Promise { + const id = `req_${randomUUID().replaceAll('-', '')}` + const message = { jsonrpc: '2.0', id, method, params } + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + try { + this.write(message) + } catch (error) { + this.pending.delete(id) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + notify(method: string, params?: Record): void { + this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params }) + } + + private readonly onData = (chunk: Buffer | string): void => { + this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8') + for (;;) { + const newline = this.buffer.indexOf('\n') + if (newline < 0) break + const line = this.buffer.slice(0, newline).trim() + this.buffer = this.buffer.slice(newline + 1) + if (!line) continue + void this.handleLine(line) + } + } + + private readonly onInputError = (error: Error): void => { + this.failPending(error) + } + + private readonly onInputEnd = (): void => { + this.failPending(new Error('JSON-RPC input closed')) + } + + private async handleLine(line: string): Promise { + let message: unknown + try { + message = JSON.parse(line) + } catch { + // Swallows ONLY JSON.parse syntax errors: a malformed wire line is a + // peer bug this resilient reader skips; nothing else runs in the try. + return + } + if (!message || typeof message !== 'object') return + const frame = message as Record + const id = frame.id + const method = frame.method + if ((typeof id === 'string' || typeof id === 'number') && typeof method === 'string') { + await this.handleIncomingRequest(id, method, objectParams(frame.params)) + return + } + if (typeof id === 'string' || typeof id === 'number') { + this.handleIncomingResponse(id, frame) + return + } + if (typeof method === 'string') { + this.notificationHandler?.(method, objectParams(frame.params)) + } + } + + private async handleIncomingRequest(id: JsonRpcId, method: string, params: Record): Promise { + const handler = this.requestHandler + if (!handler) { + this.writeError(id, -32601, `method not found: ${method}`) + return + } + try { + const result = await handler(method, params) + this.write({ jsonrpc: '2.0', id, result }) + } catch (error) { + this.writeError(id, -32603, error instanceof Error ? error.message : String(error)) + } + } + + private handleIncomingResponse(id: JsonRpcId, frame: Record): void { + const pending = this.pending.get(id) + if (!pending) return + this.pending.delete(id) + if (frame.error && typeof frame.error === 'object') { + const error = frame.error as Record + pending.reject(new Error(typeof error.message === 'string' ? error.message : 'JSON-RPC error')) + return + } + pending.resolve(frame.result) + } + + private writeError(id: JsonRpcId, code: number, message: string): void { + this.write({ jsonrpc: '2.0', id, error: { code, message } }) + } + + private write(message: Record): void { + this.output.write(`${JSON.stringify(message)}\n`) + } + + private failPending(error: Error): void { + const pending = [...this.pending.values()] + this.pending.clear() + for (const waiter of pending) waiter.reject(error) + } +} + +/** Normalize JSON-RPC `params` to a plain object (arrays and scalars collapse to `{}`). */ +function objectParams(params: unknown): Record { + return params && typeof params === 'object' && !Array.isArray(params) ? params as Record : {} +} diff --git a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts new file mode 100644 index 0000000000..50821767f9 --- /dev/null +++ b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts @@ -0,0 +1,266 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { PassThrough, Writable } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import * as agentCore from '@deepseek-ai/dsh-agent-core' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as jsonrpc from '../src/index.ts' + +/** + * apply()-level lifecycle coverage for the @deepseek-ai/dsh-jsonrpc plugin: + * the plugin is mounted through the REAL namespace mount path — + * `ctx.plugin(jsonrpc, config)` over the module namespace object, exactly what + * the Loader hands cordis after `unwrapExports` (plugin-shape.spec pins that + * identity) — with the runtime-only `input`/`output`/`exit` seams from + * {@link jsonrpc.JsonRpcConfig} replacing the process stdio, so the whole + * pipeline (line transport → HarnessSdkServer → notifications back onto the + * wire) runs in-process. The scenarios pin the plugin's exit-lifecycle split: + * a `shutdown` REQUEST answers first, then disposes the plugin's own fiber and + * calls `exit(0)` exactly once (a racing second `shutdown` must not re-exit); + * a bare fiber dispose (HMR-style unload, no request) only stops serving and + * never touches `exit`. + */ + +/** One ordered observation on the plugin's outward-facing seams: a JSON-RPC frame written to `output`, or an `exit(code)` call. */ +type WireEvent = + | { kind: 'frame'; frame: Record } + | { kind: 'exit'; code: number } + +interface ApplyHarness { + ctx: Context + /** The jsonrpc plugin's own fiber (NOT the root), for the HMR-style dispose scenario. */ + fiber: Awaited> + /** Every output frame and exit call, in observation order — ordering assertions read this. */ + events: WireEvent[] + send(frame: Record): void + sendRaw(text: string): void + frames(): Record[] + exits(): number[] + waitForFrame(predicate: (frame: Record) => boolean, description: string): Promise> + dispose(): Promise +} + +/** Poll `get` until it yields a value (5s cap) — the output side is fed asynchronously from the transport's read loop. */ +async function waitFor(get: () => T | undefined, description: string): Promise { + const deadline = Date.now() + 5000 + for (;;) { + const value = get() + if (value !== undefined) return value + if (Date.now() > deadline) throw new Error(`timed out waiting for ${description}`) + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +/** Let pending microtasks, setImmediate callbacks, and stream events drain — for asserting that something did NOT happen. */ +async function settle(): Promise { + await new Promise(resolve => setTimeout(resolve, 25)) +} + +/** + * Boot a minimal harness context (agent-core bundle + JSONL persistence, the + * server.spec recipe) and mount the jsonrpc plugin on it through the real + * namespace mount path, with in-memory seams standing in for stdio/exit. + */ +async function mountPlugin(storageDir: string): Promise { + const ctx = new Context() + await ctx.plugin(agentCore) + await ctx.plugin(SessionPersistenceJsonl, { root: storageDir }) + await new Promise(resolve => setTimeout(resolve, 50)) + + const input = new PassThrough() + const events: WireEvent[] = [] + let pendingOutput = '' + // A hand-rolled Writable (not a PassThrough): _write records each decoded + // frame synchronously, so `events` preserves the true frame-vs-exit order. + const output = new Writable({ + write(chunk: Buffer, _encoding, callback) { + pendingOutput += chunk.toString('utf8') + for (;;) { + const newline = pendingOutput.indexOf('\n') + if (newline < 0) break + const line = pendingOutput.slice(0, newline).trim() + pendingOutput = pendingOutput.slice(newline + 1) + if (line) events.push({ kind: 'frame', frame: JSON.parse(line) as Record }) + } + callback() + }, + }) + const exit = (code: number): void => { events.push({ kind: 'exit', code }) } + + const fiber = await ctx.plugin(jsonrpc, { input, output, exit }) + + const frames = (): Record[] => + events.flatMap(event => event.kind === 'frame' ? [event.frame] : []) + return { + ctx, + fiber, + events, + send: (frame) => { input.write(`${JSON.stringify(frame)}\n`) }, + sendRaw: (text) => { input.write(text) }, + frames, + exits: () => events.flatMap(event => event.kind === 'exit' ? [event.code] : []), + waitForFrame: (predicate, description) => waitFor(() => frames().find(predicate), description), + dispose: async () => { await ctx.fiber.dispose() }, + } +} + +const servers: Server[] = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + vi.unstubAllEnvs() +}) + +/** The server.spec mock OpenAI-compatible SSE endpoint, so a prompt turn completes without a real key. */ +async function mockCompletionServer(): Promise<{ url: string; requests: unknown[] }> { + const requests: unknown[] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + requests.push(JSON.parse(body)) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + response.write('data: [DONE]\n\n') + response.end() + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { url: `http://127.0.0.1:${address.port}`, requests } +} + +describe('dsh-jsonrpc plugin apply', () => { + it('serves initialize over the injected stdio pair', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-init-')) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const harness = await mountPlugin(storageDir) + try { + harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, model: 'apply-model' } }) + + const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response') + expect(response).toEqual({ + jsonrpc: '2.0', + id: 'init-1', + result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }, + }) + expect(harness.exits()).toEqual([]) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('drives a session/prompt turn end-to-end and forwards session notifications as output frames', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-prompt-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const harness = await mountPlugin(storageDir) + try { + harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, model: 'dsagent-model' } }) + await harness.waitForFrame(frame => frame.id === 1, 'initialize response') + + harness.send({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }] }, + }) + const response = await harness.waitForFrame(frame => frame.id === 2, 'prompt response') + expect(response.result).toEqual({ accepted: true }) + + expect(llmServer.requests).toHaveLength(1) + const body = llmServer.requests[0] as { model: string; messages: { role: string }[] } + expect(body.model).toBe('dsagent-model') + expect(body.messages.at(-1)?.role).toBe('user') + + // The server's notify() path rides the SAME transport apply() built: + // session.event / session.finished arrive as id-less frames on output. + const notifications = harness.frames().filter(frame => frame.id === undefined) + expect(notifications.some(frame => frame.method === 'session.event')).toBe(true) + expect(notifications.find(frame => frame.method === 'session.finished')).toMatchObject({ + jsonrpc: '2.0', + params: { sessionId: 'main', status: 'ok' }, + }) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-')) + const harness = await mountPlugin(storageDir) + try { + // Two shutdown frames in ONE chunk: both are dispatched from the same + // read-loop pass, so both setImmediate exit callbacks get scheduled and + // the second must hit the `exiting` guard instead of re-entering. + const first = { jsonrpc: '2.0', id: 'sd-1', method: 'shutdown' } + const second = { jsonrpc: '2.0', id: 'sd-2', method: 'shutdown' } + harness.sendRaw(`${JSON.stringify(first)}\n${JSON.stringify(second)}\n`) + + await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit recorder call') + expect(harness.exits()).toEqual([0]) + + // Response-then-exit ordering: both shutdown responses were flushed to + // output BEFORE exit(0) ran (the setImmediate in the request handler). + const exitIndex = harness.events.findIndex(event => event.kind === 'exit') + const firstResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-1') + const secondResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-2') + expect(firstResponse).toBeGreaterThanOrEqual(0) + expect(secondResponse).toBeGreaterThanOrEqual(0) + expect(exitIndex).toBeGreaterThan(firstResponse) + expect(exitIndex).toBeGreaterThan(secondResponse) + + // Idempotent: the racing second shutdown never produces a second exit. + await settle() + expect(harness.exits()).toEqual([0]) + + // The plugin fiber is disposed: the transport reads no further frames. + const before = harness.frames().length + harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, model: 'x' } }) + await settle() + expect(harness.frames().length).toBe(before) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('stops serving on a bare fiber dispose (HMR-style unload) without calling exit', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-dispose-')) + const harness = await mountPlugin(storageDir) + try { + // Prove the pipeline is live first (an unknown method still answers, as + // a JSON-RPC error frame — the transport's handler-rejection path). + harness.send({ jsonrpc: '2.0', id: 'probe-1', method: 'nope/unknown' }) + const error = await harness.waitForFrame(frame => frame.id === 'probe-1', 'error response for unknown method') + expect(error.error).toMatchObject({ + code: -32603, + message: 'unknown DeepSeek Harness SDK runtime method: nope/unknown', + }) + + await harness.fiber.dispose() + + // The effect disposer shut the server and closed the transport — later + // frames are never read — and the exit seam was never touched. + const before = harness.frames().length + harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, model: 'x' } }) + await settle() + expect(harness.frames().length).toBe(before) + expect(harness.exits()).toEqual([]) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/ui/jsonrpc/tests/plugin-shape.spec.ts b/packages/ui/jsonrpc/tests/plugin-shape.spec.ts new file mode 100644 index 0000000000..3edbf1514f --- /dev/null +++ b/packages/ui/jsonrpc/tests/plugin-shape.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import Loader from '@cordisjs/plugin-loader' +import * as jsonrpc from '../src/index.ts' + +/** + * REAL-export-path guard for the @deepseek-ai/dsh-jsonrpc namespace plugin + * (the packages/AGENTS.md red line: a plugin shipped via `cordis.yml` needs a + * test through the real Loader/export path). A hand-built `ctx.plugin({...})` + * mount bypasses `unwrapExports` — the exact path that once collapsed a + * namespace plugin with a stray `export default` and silently dropped its + * `inject` (docs/postmortem/0001) — so this spec drives the REAL + * `Loader.unwrapExports` over the module namespace and asserts the + * `name`/`inject`/`Config`/`apply` shape survives it intact. + */ +describe('dsh-jsonrpc plugin export shape', () => { + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { + // A stray `export default` would make `unwrapExports` (`exports.default ?? + // exports`) collapse the module to the bare default, dropping `inject` — + // the plugin would then throw "cannot get property … without inject" at + // its first `ctx.agents` read. Adding `export default` fails this test. + expect('default' in jsonrpc).toBe(false) + expect(typeof jsonrpc.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(jsonrpc) as Record + expect(unwrapped).toBe(jsonrpc) + expect(unwrapped.name).toBe('jsonrpc') + expect(unwrapped.inject).toEqual(['agents']) + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts new file mode 100644 index 0000000000..a3ea6e3c1a --- /dev/null +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -0,0 +1,378 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as agentCore from '@deepseek-ai/dsh-agent-core' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts' + +class FakeTransport implements JsonRpcTransportPeer { + notifications: { method: string; params?: Record }[] = [] + + async request(method: string, params: Record): Promise { + throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`) + } + + notify(method: string, params?: Record): void { + this.notifications.push(params === undefined ? { method } : { method, params }) + } +} + +const servers: Server[] = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + vi.unstubAllEnvs() +}) + +async function mockCompletionServer(): Promise<{ url: string; requests: unknown[]; headers: IncomingMessage['headers'][] }> { + const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + requests.push(JSON.parse(body)) + headers.push(request.headers) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + response.write('data: [DONE]\n\n') + response.end() + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { url: `http://127.0.0.1:${address.port}`, requests, headers } +} + +async function makeHarness(storageDir: string) { + const ctx = new Context() + await ctx.plugin(agentCore) + await ctx.plugin(SessionPersistenceJsonl, { root: storageDir }) + await new Promise(resolve => setTimeout(resolve, 50)) + return ctx +} + +describe('HarnessSdkServer', () => { + it('creates a harness agent and calls the configured OpenAI-compatible endpoint', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + + const init = await server.handleRequest('initialize', { + cwd: storageDir, + model: 'dsagent-model', + sessionRoot: storageDir, + systemPrompt: 'Custom SDK instructions.', + }) as { serverInfo: { name: string } } + expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime') + + await server.handleRequest('session/prompt', { + sessionId: 'main', + contentBlocks: [{ type: 'text', text: 'fix it' }], + profile: 'build', + }) + + expect(llmServer.requests).toHaveLength(1) + const body = llmServer.requests[0] as { model: string; messages: { role: string }[] } + expect(body.model).toBe('dsagent-model') + expect(body.messages[0]?.role).toBe('system') + expect(body.messages.at(-1)?.role).toBe('user') + expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key') + expect(transport.notifications.some(n => n.method === 'session.event')).toBe(true) + expect(transport.notifications.at(-1)).toMatchObject({ + method: 'session.finished', + params: { sessionId: 'main', status: 'ok' }, + }) + + await server.handleRequest('session/prompt', { + sessionId: 'main', + contentBlocks: [{ type: 'text', text: 'again' }], + }) + expect(llmServer.requests).toHaveLength(2) + + const orphanHandle = ctx.agents.create({ + agentId: AgentId('orphan-agent'), + sessionId: SessionId('orphan-session'), + meta: { cwd: storageDir }, + agentOptions: { model: 'dsagent-model' }, + }) + orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }]) + await orphanHandle.agent.whenIdle() + await orphanHandle.dispose() + expect(llmServer.requests).toHaveLength(3) + + await server.handleRequest('shutdown', undefined) + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('notifies the host when a child session is created with parent lineage', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + + ctx.sessions.create(SessionId('root-session'), { + meta: { cwd: storageDir }, + }) + ctx.sessions.create(SessionId('child-session'), { + meta: { cwd: storageDir, parentSession: SessionId('main') }, + }) + + expect(transport.notifications).toContainEqual({ + method: 'subagent.started', + params: { + parentSessionId: 'main', + childSessionId: 'child-session', + }, + }) + + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('creates an SDK session without an optional system prompt', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const ctx = await makeHarness(storageDir) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) + + await server.initialize({ cwd: storageDir, model: 'plain-model' }) + await server.prompt({ + sessionId: 'plain', + contentBlocks: [{ type: 'text', text: 'hello' }], + }) + + expect(llmServer.requests).toHaveLength(1) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('notifies the host when a subagent run settles', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-end-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + + const handle = ctx.agents.create({ + agentId: AgentId('child-agent'), + sessionId: SessionId('child-session'), + meta: { cwd: storageDir, parentSession: SessionId('main') }, + }) + ctx.emit('subagent/end', { + provider: 'spawn', + id: AgentId('child-agent'), + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'child done' }], + }) + + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'spawn', + agentId: 'child-agent', + parentSessionId: 'main', + childSessionId: 'child-session', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'child done' }], + }, + }) + + await handle.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('falls back to live agent lineage for uncached subagent end events', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) + const ctx = await makeHarness(storageDir) + let handle: ReturnType | undefined + let failedHandle: ReturnType | undefined + try { + handle = ctx.agents.create({ + agentId: AgentId('fallback-child-agent'), + sessionId: SessionId('fallback-child-session'), + meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, + }) + failedHandle = ctx.agents.create({ + agentId: AgentId('failed-child-agent'), + sessionId: SessionId('failed-child-session'), + meta: { cwd: storageDir }, + }) + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + + ctx.emit('subagent/end', { + provider: 'fork', + id: AgentId('fallback-child-agent'), + stopReason: 'max-tokens', + }) + ctx.emit('subagent/end', { + provider: 'fork', + id: AgentId('failed-child-agent'), + stopReason: 'error', + }) + ctx.emit('subagent/end', { + provider: 'fork', + id: AgentId('missing-child-agent'), + stopReason: 'error', + }) + + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'fork', + agentId: 'fallback-child-agent', + parentSessionId: 'fallback-parent', + childSessionId: 'fallback-child-session', + status: 'ok', + stopReason: 'max-tokens', + }, + }) + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'fork', + agentId: 'failed-child-agent', + childSessionId: 'failed-child-session', + status: 'error', + stopReason: 'error', + }, + }) + expect(transport.notifications.some(n => + n.method === 'subagent.finished' + && n.params?.agentId === 'missing-child-agent', + )).toBe(false) + + await server.shutdown() + } finally { + await handle?.dispose() + await failedHandle?.dispose() + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('does not re-register an LLM adapter that already exists', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-')) + const ctx = await makeHarness(storageDir) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + await ctx.plugin(LlmDeepSeek, { models: ['preinstalled-model'] }) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) + const inspect = server as unknown as { hasAdapterFor(model: string): boolean } + + expect(inspect.hasAdapterFor('preinstalled-model')).toBe(true) + expect(inspect.hasAdapterFor('missing-model')).toBe(false) + await server.initialize({ cwd: storageDir, model: 'preinstalled-model' }) + + expect(ctx.get('llm')?.models().filter(model => model === 'preinstalled-model')).toEqual(['preinstalled-model']) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('registers a missing model when an LLM service already exists', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-')) + const ctx = await makeHarness(storageDir) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + await ctx.plugin(LlmDeepSeek, { models: ['other-model'] }) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) + + await server.initialize({ cwd: storageDir, model: 'new-model' }) + + expect(ctx.get('llm')?.models()).toEqual(expect.arrayContaining(['other-model', 'new-model'])) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('classifies defensive finish states', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-')) + const ctx = await makeHarness(storageDir) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + finishedStatus(reason: unknown): 'ok' | 'error' + shutdown(): Promise> + } + + expect(server.finishedStatus(undefined)).toBe('error') + expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok') + expect(server.finishedStatus({ kind: 'error' })).toBe('error') + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('reports no adapter when the LLM service is absent', async () => { + const ctx = new Context() + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + hasAdapterFor(model: string): boolean + shutdown(): Promise> + } + + expect(server.hasAdapterFor('missing-model')).toBe(false) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + } + }) + + it('rejects unknown JSON-RPC runtime methods', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unknown-')) + const ctx = await makeHarness(storageDir) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) + + await expect(server.handleRequest('does/not/exist', {})) + .rejects + .toThrow('unknown DeepSeek Harness SDK runtime method: does/not/exist') + + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/ui/jsonrpc/tests/transport.spec.ts b/packages/ui/jsonrpc/tests/transport.spec.ts new file mode 100644 index 0000000000..0e98a4af10 --- /dev/null +++ b/packages/ui/jsonrpc/tests/transport.spec.ts @@ -0,0 +1,202 @@ +import { once } from 'node:events' +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { JsonRpcLineTransport } from '../src/index.ts' + +function transportPair() { + const aToB = new PassThrough() + const bToA = new PassThrough() + const a = new JsonRpcLineTransport(bToA, aToB) + const b = new JsonRpcLineTransport(aToB, bToA) + return { a, b, aToB, bToA } +} + +describe('JsonRpcLineTransport', () => { + it('supports bidirectional requests and notifications over newline-delimited JSON-RPC', async () => { + const { a, b } = transportPair() + const notifications: Record[] = [] + + a.onRequest(async (method, params) => { + expect(method).toBe('echo') + return { echoed: params } + }) + b.onNotification((method, params) => { + notifications.push({ method, params }) + }) + a.start() + b.start() + + const response = await b.request('echo', { value: 42 }) + expect(response).toEqual({ echoed: { value: 42 } }) + + a.notify('session.finished', { sessionId: 'main', status: 'ok' }) + a.notify('heartbeat') + await new Promise(resolve => setTimeout(resolve, 10)) + expect(notifications).toEqual([ + { method: 'session.finished', params: { sessionId: 'main', status: 'ok' } }, + { method: 'heartbeat', params: {} }, + ]) + + a.close() + b.close() + }) + + it('reports JSON-RPC request errors from the remote peer', async () => { + const { a, b } = transportPair() + a.onRequest(async () => { + throw new Error('handler boom') + }) + a.start() + b.start() + + await expect(b.request('explode', {})).rejects.toThrow('handler boom') + + a.close() + b.close() + }) + + it('stringifies non-Error request handler failures', async () => { + const { a, b } = transportPair() + a.onRequest(async () => { + throw 'string boom' + }) + a.start() + b.start() + + await expect(b.request('explode-string', {})).rejects.toThrow('string boom') + + a.close() + b.close() + }) + + it('reports method-not-found when no request handler is installed', async () => { + const { a, b } = transportPair() + a.start() + b.start() + + await expect(b.request('missing', {})).rejects.toThrow('method not found: missing') + + a.close() + b.close() + }) + + it('normalizes non-object request params and ignores notifications without a handler', async () => { + const { aToB, bToA, b } = transportPair() + const seen: Record[] = [] + b.onRequest(async (method, params) => { + seen.push({ method, params }) + return { ok: true } + }) + b.start() + + aToB.write('{"jsonrpc":"2.0","method":"ignored"}\n') + aToB.write('{"jsonrpc":"2.0","id":7,"method":"array-params","params":[]}\n') + const chunk = (await once(bToA, 'data'))[0] as Buffer | string + + expect(seen).toEqual([{ method: 'array-params', params: {} }]) + expect(JSON.parse(String(chunk))).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } }) + b.close() + }) + + it('ignores malformed frames and accepts notifications without params', async () => { + const { aToB, b } = transportPair() + const notifications: Record[] = [] + b.onNotification((method, params) => { + notifications.push({ method, params }) + }) + b.start() + b.start() + + aToB.write('not json\n') + aToB.write('\n') + aToB.write('null\n') + aToB.write('{"jsonrpc":"2.0","params":{}}\n') + aToB.write('{"jsonrpc":"2.0","method":"tick"}\n') + aToB.emit('data', '{"jsonrpc":"2.0","method":"string-chunk"}\n') + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(notifications).toEqual([ + { method: 'tick', params: {} }, + { method: 'string-chunk', params: {} }, + ]) + b.close() + }) + + it('rejects pending requests when the input closes', async () => { + const { aToB, b } = transportPair() + b.start() + + const pending = b.request('never-replies', {}) + aToB.end() + + await expect(pending).rejects.toThrow('JSON-RPC input closed') + b.close() + }) + + it('rejects pending requests when the input errors', async () => { + const { aToB, b } = transportPair() + b.start() + + const pending = b.request('never-replies', {}) + aToB.emit('error', new Error('input broke')) + + await expect(pending).rejects.toThrow('input broke') + b.close() + }) + + it('rejects pending requests when the transport closes', async () => { + const { b } = transportPair() + + const pending = b.request('never-replies', {}) + b.close() + + await expect(pending).rejects.toThrow('JSON-RPC transport closed') + }) + + it('rejects a request when writing the frame throws', async () => { + const input = new PassThrough() + const output = { + write() { + throw new Error('write exploded') + }, + } + const transport = new JsonRpcLineTransport(input, output as never) + + await expect(transport.request('write-fails', {})).rejects.toThrow('write exploded') + }) + + it('stringifies non-Error write failures', async () => { + const input = new PassThrough() + const output = { + write() { + throw 'write string' + }, + } + const transport = new JsonRpcLineTransport(input, output as never) + + await expect(transport.request('write-fails', {})).rejects.toThrow('write string') + }) + + it('uses a fallback message for malformed JSON-RPC error responses', async () => { + const { aToB, bToA, b } = transportPair() + b.start() + + const pending = b.request('remote-error', {}) + const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string + const request = JSON.parse(String(requestChunk)) as { id: string } + aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: {} })}\n`) + + await expect(pending).rejects.toThrow('JSON-RPC error') + b.close() + }) + + it('ignores responses that do not match a pending request', async () => { + const { aToB, b } = transportPair() + b.start() + + aToB.write('{"jsonrpc":"2.0","id":"unknown","result":{"ignored":true}}\n') + await new Promise(resolve => setTimeout(resolve, 10)) + + b.close() + }) +}) diff --git a/packages/ui/jsonrpc/tsconfig.json b/packages/ui/jsonrpc/tsconfig.json new file mode 100644 index 0000000000..dcd57ef9af --- /dev/null +++ b/packages/ui/jsonrpc/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../llm/llm-deepseek" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../subagent/subagent" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 649a986385..1d868ae049 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1252,6 +1252,50 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/jsonrpc: + dependencies: + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-core': + specifier: workspace:^ + version: link:../../core/agent-core + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + + packages/ui/jsonrpc-agent: + dependencies: + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../app-boot + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/stdio-agent: devDependencies: '@cordisjs/plugin-include': @@ -4869,6 +4913,14 @@ snapshots: cosmokit: 1.8.1 js-yaml: 4.2.0 + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.6)': + dependencies: + '@cordisjs/plugin-loader': link:vendor/loader + cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + cosmokit: 1.8.1 + js-yaml: 4.2.0 + optional: true + '@cordisjs/plugin-loader@1.0.0-rc.4(cordis@4.0.0-rc.6)': dependencies: cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -5881,6 +5933,14 @@ snapshots: '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6) '@cordisjs/plugin-loader': 1.0.0-rc.4(cordis@4.0.0-rc.6) + cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader): + dependencies: + '@standard-schema/spec': 1.1.0 + cosmokit: 1.8.1 + optionalDependencies: + '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.6) + '@cordisjs/plugin-loader': link:vendor/loader + cordis@4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): dependencies: '@standard-schema/spec': 1.1.0 diff --git a/tsconfig.build.json b/tsconfig.build.json index 9c236fe02a..b3d904aa9b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -56,6 +56,8 @@ { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, { "path": "./packages/ui/app-boot" }, + { "path": "./packages/ui/jsonrpc" }, + { "path": "./packages/ui/jsonrpc-agent" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, diff --git a/tsconfig.json b/tsconfig.json index 2ab1a5da30..a52f21a86e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -67,6 +67,8 @@ { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, { "path": "./packages/ui/app-boot" }, + { "path": "./packages/ui/jsonrpc" }, + { "path": "./packages/ui/jsonrpc-agent" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, From ade150b71943882f16dffdcee5e9ef3dbdafb1be Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:08:24 +0800 Subject: [PATCH 03/18] python: deepseek-harness SDK and runtime carrier packages --- .gitignore | 2 + docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- pytest.ini | 8 + python/README.i18n.yaml | 6 + python/README.md | 68 ++ python/README.zh.md | 68 ++ python/sdk-runtime/README.i18n.yaml | 6 + python/sdk-runtime/README.md | 27 + python/sdk-runtime/README.zh.md | 27 + python/sdk-runtime/pyproject.toml | 21 + .../src/deepseek_harness_runtime/__init__.py | 151 +++++ .../deepseek-harness-runtime.json | 1 + .../runtime/cordis.yml | 50 ++ python/sdk/README.i18n.yaml | 6 + python/sdk/README.md | 27 + python/sdk/README.zh.md | 21 + python/sdk/pyproject.toml | 31 + python/sdk/src/deepseek_harness/__init__.py | 17 + python/sdk/src/deepseek_harness/api.py | 223 +++++++ python/sdk/src/deepseek_harness/client.py | 481 ++++++++++++++ python/sdk/src/deepseek_harness/errors.py | 19 + python/sdk/src/deepseek_harness/models.py | 32 + python/sdk/tests/manual_sdk_agent_smoke.py | 127 ++++ python/sdk/tests/test_bundled_runtime.py | 120 ++++ python/sdk/tests/test_client.py | 592 ++++++++++++++++++ python/sdk/tests/test_runtime_resolution.py | 43 ++ python/sdk/uv.lock | 321 ++++++++++ scripts/translation-pairing.manifest.json | 8 +- scripts/verify-translation-pairing.ts | 4 +- 31 files changed, 2507 insertions(+), 8 deletions(-) create mode 100644 pytest.ini create mode 100644 python/README.i18n.yaml create mode 100644 python/README.md create mode 100644 python/README.zh.md create mode 100644 python/sdk-runtime/README.i18n.yaml create mode 100644 python/sdk-runtime/README.md create mode 100644 python/sdk-runtime/README.zh.md create mode 100644 python/sdk-runtime/pyproject.toml create mode 100644 python/sdk-runtime/src/deepseek_harness_runtime/__init__.py create mode 100644 python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json create mode 100644 python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml create mode 100644 python/sdk/README.i18n.yaml create mode 100644 python/sdk/README.md create mode 100644 python/sdk/README.zh.md create mode 100644 python/sdk/pyproject.toml create mode 100644 python/sdk/src/deepseek_harness/__init__.py create mode 100644 python/sdk/src/deepseek_harness/api.py create mode 100644 python/sdk/src/deepseek_harness/client.py create mode 100644 python/sdk/src/deepseek_harness/errors.py create mode 100644 python/sdk/src/deepseek_harness/models.py create mode 100644 python/sdk/tests/manual_sdk_agent_smoke.py create mode 100644 python/sdk/tests/test_bundled_runtime.py create mode 100644 python/sdk/tests/test_client.py create mode 100644 python/sdk/tests/test_runtime_resolution.py create mode 100644 python/sdk/uv.lock diff --git a/.gitignore b/.gitignore index df36ca9214..6faccc8b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ tmp/ .DS_Store .idea mise.toml +python/**/__pycache__/ +python/**/.pytest_cache/ diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 7a2407ed36..2c81577523 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/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 -README.md: 6e2bbd27c3288037bafeb6cc71b801d56b956ab4 -README.zh.md: 04c99ae336cf1e96cbc185f0ccbd063ef8977944 +README.md: 81e958c8ae9552222f27b0f9674d4fa0ff2a481c +README.zh.md: 3764a9ade69f3ad99e36ccccbaed1d19d8bbb1f0 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 6e2bbd27c3..81e958c8ae 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -35,7 +35,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co ## Scope, exclusions, and rollout -**Scope**: the root `README.md` and everything under `docs/**`. Package READMEs (`packages/**`) join the scope in a later batch. +**Scope**: the root `README.md`, everything under `docs/**`, and everything under `python/**`. Package READMEs (`packages/**`) join the scope in a later batch. **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 04c99ae336..3764a9ade6 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -35,7 +35,7 @@ ## 范围、排除与推进 -**范围**:根 `README.md` 与 `docs/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 +**范围**:根 `README.md`、`docs/**` 下的全部内容,以及 `python/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000..696fcc2bf3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +# Root pytest config: pin collection to the Python SDK's real test tree. +# Without this, a bare `pytest` from the repo root recursively collects the +# whole worktree — including gitignored residue such as scratch checkouts or +# venvs — and same-basename test modules collide with an "import file +# mismatch" collection error. +[pytest] +testpaths = python/sdk/tests +norecursedirs = node_modules .git dist-exe diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml new file mode 100644 index 0000000000..29c3b03096 --- /dev/null +++ b/python/README.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 +README.md: 56199c1daaac4d55a29dae8852277f0ba77238b2 +README.zh.md: e34f3afd455926bd6b09d3451509594cee78ad88 diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000000..56199c1daa --- /dev/null +++ b/python/README.md @@ -0,0 +1,68 @@ +# DeepSeek Harness Python SDK + +English | [中文](README.zh.md) + +Python packages for driving DeepSeek Harness as a subprocess: a client SDK that spawns the `dsh-jsonrpc-agent` binary and talks newline-delimited JSON-RPC over stdio. The runtime carrier is the single-file executable produced by this repo; design, build, and acceptance details live in [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + +## Packages + +| Directory | Dist / module | Role | +|---|---|---| +| [sdk](sdk/) | `deepseek-harness` / `deepseek_harness` | Client SDK: the `DeepSeekHarness` high-level turns API and the lower-level `HarnessClient` JSON-RPC client | +| [sdk-runtime](sdk-runtime/) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Runtime carrier: locates the bundled runtime binaries and ships the default agent configuration | + +## Building the runtime executable + +The platform executables are build artifacts, not checked into git. From the repo root: + +```sh +pnpm install +pnpm exec tsx scripts/build-exe-for-python-sdk.ts # host platform, ~2 min +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifacts already built +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 +``` + +Products land in `dist-exe/` (CI artifact shape) and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a build the SDK finds the executable with no further setup. Alternatively, download the platform artifact from the `build-exe-for-python-sdk` CI workflow (manual dispatch) and place it at that same path. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). + +## Validating the SDK against the executable + +```sh +export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" # keep the venv out of python/ +uv sync --project python/sdk --group test +uv run --project python/sdk pytest python/sdk/tests/test_bundled_runtime.py # boots the real carriers +uv run --project python/sdk pytest # full suite; keyless tests included +``` + +For an interactive check (needs `DEEPSEEK_API_KEY` in the environment or the repo-root `.env`): + +```python +from deepseek_harness import DeepSeekHarness +print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks the bundled exe +``` + +## Running the SDK against the Node source (no executable) + +Two flavors, both for repo members: + +- **Built node carrier** — set `DSH_RUNTIME_MODE=node` and the SDK runs `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on the system Node (>= 22.19). The tree is refreshed on every build-script run and is the same dependency closure the exe snapshots, so plugin semantics are identical. Never auto-selected, never distributed. +- **Unbuilt source (tsx)** — point the client straight at the bin's TypeScript source for edit-run loops and debugging: `launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")` with `cwd` at the repo root, plus a config via `cordis=...` (or rely on the default-config injection). [sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) is the worked example. + +## Distributing the Python packages + +Build one wheel per package; the runtime wheel embeds whatever executables are present under `runtime/` at build time (and always the default `cordis.yml`), and excludes `runtime/node/`: + +```sh +(cd python/sdk-runtime && uv build) # or: hatch build +(cd python/sdk && uv build) +pip install dist/deepseek_harness_runtime_bin-*.whl dist/deepseek_harness-*.whl +``` + +Once the binaries are in place (built or downloaded), installing the two wheels (or `pip install ./python/sdk-runtime ./python/sdk` straight from the directories) gives a working zero-config `DeepSeekHarness()`. Two pre-release caveats: the runtime wheel is currently tagged `py3-none-any` while embedding platform-specific binaries — build it with only the matching platform's exe in place (one wheel per platform), or drop all three exes in for a fat universal wheel (~500 MB); and versioning/publishing policy (`0.0.0-dev`, no registry) is deliberately unset until the first tagged release. + +## Zero-config semantics + +The runtime binary itself always requires an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as the first argv argument), has no built-in fallback, and boots only what the config lists. Zero-config is SDK wrapper behavior: when the caller uses no explicit channel, the client injects the runtime package's checked-in default configuration ([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml)) via `DSH_CORDIS_CONFIG`; any explicit channel wins and disables the injection. The full injection conditions live in the [sdk README](sdk/README.md); the default config's contents and the hard semantic in the [sdk-runtime README](sdk-runtime/README.md). + +## Test layout + +`test_client.py` is fully keyless (a Python fake runtime is the peer). `test_bundled_runtime.py` boots each bundled carrier and skips per carrier when its artifact is missing. `test_runtime_resolution.py` covers the carrier-resolution rules without spawning anything. diff --git a/python/README.zh.md b/python/README.zh.md new file mode 100644 index 0000000000..e34f3afd45 --- /dev/null +++ b/python/README.zh.md @@ -0,0 +1,68 @@ +# DeepSeek Harness Python SDK + +[English](README.md) | 中文 + +以子进程方式驱动 DeepSeek Harness 的 Python 包:客户端 SDK spawn `dsh-jsonrpc-agent` 二进制,在 stdio 上以换行分隔的 JSON-RPC 与之通信。运行时载体是本仓库产出的单文件可执行文件;设计、构建与验收细节见 [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)。 + +## 包 + +| 目录 | Dist / 模块 | 职责 | +|---|---|---| +| [sdk](sdk/) | `deepseek-harness` / `deepseek_harness` | 客户端 SDK:高层回合 API `DeepSeekHarness` 与低层 JSON-RPC 客户端 `HarnessClient` | +| [sdk-runtime](sdk-runtime/) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 运行时载体:定位内置的运行时二进制,并携带默认的 agent(智能体)配置 | + +## 构建运行时可执行文件 + +各平台可执行文件是构建产物,不检入 git。在仓库根目录执行: + +```sh +pnpm install +pnpm exec tsx scripts/build-exe-for-python-sdk.ts # host platform, ~2 min +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifacts already built +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 +``` + +产物落入 `dist-exe/`(CI 产物形态),并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),构建完成后 SDK 不需要额外设置就能找到可执行文件。也可以从 `build-exe-for-python-sdk` CI workflow(手动触发)下载对应平台的产物放到同一路径。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发用的 node 载体(见下文「对着 Node 源码运行」)。 + +## 用可执行文件验证 SDK + +```sh +export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" # keep the venv out of python/ +uv sync --project python/sdk --group test +uv run --project python/sdk pytest python/sdk/tests/test_bundled_runtime.py # boots the real carriers +uv run --project python/sdk pytest # full suite; keyless tests included +``` + +交互式验证(需要环境变量或仓库根 `.env` 中的 `DEEPSEEK_API_KEY`): + +```python +from deepseek_harness import DeepSeekHarness +print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks the bundled exe +``` + +## 对着 Node 源码运行 SDK(不用可执行文件) + +两种方式,均面向仓库成员: + +- **已构建的 node 载体**——设置 `DSH_RUNTIME_MODE=node`,SDK 会用系统 Node(>= 22.19)运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`。这棵树每次跑构建脚本都会刷新,与 exe 打进 VFS 的是同一份依赖闭包,插件语义一致。不会被自动选中,也不进入分发物。 +- **未构建的源码(tsx)**——把客户端直接指向 bin 的 TypeScript 源码,用于编辑-运行循环与调试:`launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")`,`cwd` 设为仓库根,另经 `cordis=...` 传入配置(或依赖默认配置注入)。[sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) 是现成范例。 + +## 分发 Python 包 + +每个包各构建一个 wheel;runtime wheel 会内嵌构建时 `runtime/` 下存在的全部可执行文件(以及始终包含的默认 `cordis.yml`),并排除 `runtime/node/`: + +```sh +(cd python/sdk-runtime && uv build) # or: hatch build +(cd python/sdk && uv build) +pip install dist/deepseek_harness_runtime_bin-*.whl dist/deepseek_harness-*.whl +``` + +二进制就位(构建或下载)后,安装这两个 wheel(或直接 `pip install ./python/sdk-runtime ./python/sdk` 从目录安装),就得到零配置可用的 `DeepSeekHarness()`。两个 pre-release 注意事项:runtime wheel 目前的标签是 `py3-none-any`,内容却是平台相关的二进制——构建时只放匹配平台的 exe(每平台一个 wheel),或把三个 exe 全部放入做成通用胖 wheel(约 500 MB);版本与发布策略(`0.0.0-dev`、无 registry)刻意留待首个 tagged release 再定。 + +## 零配置语义 + +运行时二进制本身始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为首个 argv 参数的配置路径),没有内置兜底,也只启动配置里列出的东西。零配置是 SDK 包装层的行为:调用方没有走任何显式通道时,客户端把 runtime 包检入的默认配置([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml))注入 `DSH_CORDIS_CONFIG`;任一显式通道存在即胜出并禁用注入。注入条件的完整定义见 [sdk README](sdk/README.md),默认配置的内容与硬语义见 [sdk-runtime README](sdk-runtime/README.md)。 + +## 测试布局 + +`test_client.py` 完全无需 key(对端是 Python 假运行时)。`test_bundled_runtime.py` 逐个启动内置载体,某载体产物缺失时对应用例跳过。`test_runtime_resolution.py` 覆盖载体解析规则,不 spawn 任何进程。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml new file mode 100644 index 0000000000..d82d6cfa12 --- /dev/null +++ b/python/sdk-runtime/README.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 +README.md: 5543b0ba00816c230dbe4e5bba11b7bf4b38096d +README.zh.md: 163b6d8e8373f0f770a5d95be688785907108b8a diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md new file mode 100644 index 0000000000..5543b0ba00 --- /dev/null +++ b/python/sdk-runtime/README.md @@ -0,0 +1,27 @@ +# DeepSeek Harness Runtime Wheel + +English | [中文](README.zh.md) + +Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness` client spawns, and ships the default configuration behind zero-config runs. + +## Runtime carriers + +Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: + +- **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel/sdist distributions. +- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. + +Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. + +Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow and place it in the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. + +## Resolution API + +- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; the node carrier has no single-path equivalent and launches via the argv tuple above). +- `bundled_default_config_path() -> Path` — the checked-in default config (see below). +- `bundled_package_dir() -> Path` — the installed package data root. + +## Zero-config design + +The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` (the JSON-RPC serving entry, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash, each parameterized by the `DSH_*` env vars the SDK sets); when the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md new file mode 100644 index 0000000000..163b6d8e83 --- /dev/null +++ b/python/sdk-runtime/README.zh.md @@ -0,0 +1,27 @@ +# DeepSeek Harness 运行时 wheel + +[English](README.md) | 中文 + +Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 + +## 运行时载体 + +两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 gitignore: + +- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一进入 wheel/sdist 分发物的载体。 +- **node(仅限开发)**——`runtime/node/` 下的完整 deploy 闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 + +两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的 deploy root——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 + +载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物并放入 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 + +## 解析 API + +- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体;node 载体没有单一路径的等价物,经由上面的 argv 元组启动)。 +- `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 +- `bundled_package_dir() -> Path`——已安装包的数据根目录。 + +## 零配置设计 + +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。bin(`dsh-jsonrpc-agent`)只启动配置里列出的插件;服务面(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它启动出的 agent 没有对外通道。本包检入 `runtime/cordis.yml`(JSON-RPC 服务条目、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash,各项由 SDK 设置的 `DSH_*` 环境变量参数化);调用方未走任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。零配置因此是 wrapper 里一次显式、可见的参数传递,不是运行时里的隐藏回退。 diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml new file mode 100644 index 0000000000..ed1e147494 --- /dev/null +++ b/python/sdk-runtime/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "deepseek-harness-runtime-bin" +version = "0.0.0-dev" +description = "Pinned DeepSeek Harness runtime for the Python SDK" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "BSD-3-Clause" } + +# Distributions carry the platform executables (build-injected, VCS-ignored — +# hence `artifacts`) and the checked-in runtime/cordis.yml; the dev-only node +# closure under runtime/node/ is explicitly excluded from wheel and sdist. +[tool.hatch.build] +artifacts = ["src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*"] +exclude = ["src/deepseek_harness_runtime/runtime/node"] + +[tool.hatch.build.targets.wheel] +packages = ["src/deepseek_harness_runtime"] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py new file mode 100644 index 0000000000..bd14ed0211 --- /dev/null +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -0,0 +1,151 @@ +"""Locate the bundled DeepSeek Harness SDK runtime shipped with this package. + +Two runtime carriers coexist under ``runtime/``, both injected by the repo's +``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): + +- **exe (production)**: single-file executables named + ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in + {x64, arm64}); the target machine needs no Node installation. +- **node (dev-only)**: the full deploy closure under ``runtime/node/`` + (``package.json`` + ``node_modules/``), executed as ``node + runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`` on a + system Node >= 22.19. It is the current checkout's source build, never + selected automatically, and excluded from wheel/sdist distributions. + +``runtime/cordis.yml`` IS checked in: it is the default agent configuration +the client SDK injects via ``$DSH_CORDIS_CONFIG`` for zero-config runs — the +runtime itself always requires an explicit config and has no built-in +fallback. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import sys +from pathlib import Path + +PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" + +RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" + +_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos"} +_ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"} + +_EXE_ACQUISITION_HINT = ( + "Two ways to get the executable: run `scripts/build-exe-for-python-sdk.ts` (via tsx) in a " + "deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` " + "CI workflow; place the file in this package's runtime/ directory. For local development " + "against a repo source build, explicitly select the dev-only node carrier with " + f"{RUNTIME_MODE_ENV_VAR}=node (or resolve_bundled_launch_args('node'))." +) + + +def bundled_package_dir() -> Path: + """Root directory of the installed runtime package data (the directory of this module).""" + root = Path(__file__).resolve().parent + metadata = root / PACKAGE_METADATA_FILENAME + if not metadata.is_file(): + raise FileNotFoundError(f"deepseek-harness-runtime-bin is missing {metadata}") + return root + + +def bundled_default_config_path() -> Path: + """Path of the checked-in default runtime configuration (``runtime/cordis.yml``). + + The client SDK injects this path via ``$DSH_CORDIS_CONFIG`` when the caller + supplies no config and the launch resolves to the bundled runtime — the + runtime binary itself always demands an explicit config. + """ + path = bundled_package_dir() / "runtime" / "cordis.yml" + if not path.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the default runtime config at {path}" + ) + return path + + +def bundled_runtime_path() -> Path: + """Absolute path of the bundled single-file runtime executable for the current platform. + + Raises FileNotFoundError when the platform is unsupported or the executable + has not been placed into this package; the message names the acquisition + routes (acquisition strategy is deliberately separate from this lookup + interface, so an on-demand download can replace it without touching + callers). + """ + tag = _current_platform_tag() + path = bundled_package_dir() / "runtime" / f"dsh-jsonrpc-agent-pkg-{tag}" + if not path.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + + _EXE_ACQUISITION_HINT + ) + return path + + +def resolve_bundled_launch_args(mode: str | None = None) -> tuple[str, ...]: + """The argv tuple that launches the bundled runtime. + + Mode selection: the explicit ``mode`` argument wins, then the + ``DSH_RUNTIME_MODE`` environment variable (``exe`` | ``node``), then + automatic resolution. Automatic resolution finds the production exe ONLY — + the dev-only node carrier must be selected explicitly so a production + deployment can never silently ride on a source build. Returns + ``(exe_path,)`` in exe mode and ``(node_path, bin_js_path)`` in node mode; + raises FileNotFoundError when the selected carrier is unavailable and + ValueError for an unknown mode value. + """ + selected = mode if mode is not None else os.environ.get(RUNTIME_MODE_ENV_VAR) + if selected is None or selected == "exe": + return (str(bundled_runtime_path()),) + if selected == "node": + return _node_launch_args() + raise ValueError( + f"unsupported DeepSeek Harness runtime mode {selected!r}: expected 'exe' or 'node' " + f"(explicit argument or ${RUNTIME_MODE_ENV_VAR})" + ) + + +def _current_platform_tag() -> str: + plat = _PLATFORM_TAGS.get(sys.platform) + arch = _ARCH_TAGS.get(platform.machine().lower()) + if plat is None or arch is None: + raise FileNotFoundError( + "no bundled dsh-jsonrpc-agent executable exists for this platform " + f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " + "linux/macos on x64/arm64. " + _EXE_ACQUISITION_HINT + ) + return f"{plat}-{arch}" + + +def _node_launch_args() -> tuple[str, str]: + node_root = bundled_package_dir() / "runtime" / "node" + bin_js = ( + node_root / "node_modules" / "@deepseek-ai" / "dsh-jsonrpc-agent" / "lib" / "bin.js" + ) + if not bin_js.is_file(): + raise FileNotFoundError( + f"the dev-only node runtime closure is missing at {node_root} " + f"(no {bin_js}); run `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness " + "checkout, which builds and copies the deploy closure here. The node carrier " + "is for repo-local development only — production uses the single-file exe." + ) + node = shutil.which("node") + if node is None: + raise FileNotFoundError( + "the node runtime mode needs a system `node` (>=22.19) on PATH; " + "install Node.js or use the exe mode" + ) + return (node, str(bin_js)) + + +__all__ = [ + "PACKAGE_METADATA_FILENAME", + "RUNTIME_MODE_ENV_VAR", + "bundled_default_config_path", + "bundled_package_dir", + "bundled_runtime_path", + "resolve_bundled_launch_args", +] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json b/python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json new file mode 100644 index 0000000000..e4d02d8a3d --- /dev/null +++ b/python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json @@ -0,0 +1 @@ +{"name":"deepseek-harness-runtime-bin","version":"0.0.0-dev"} diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml new file mode 100644 index 0000000000..76ae18b20e --- /dev/null +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -0,0 +1,50 @@ +# Default runtime configuration for the bundled dsh-jsonrpc-agent. The runtime +# binary has NO built-in fallback — it always requires an explicit config via +# `$DSH_CORDIS_CONFIG` (wins) or an argv positional path. The Python client SDK +# injects THIS file's path via `$DSH_CORDIS_CONFIG` when the caller supplies +# no config and the launch resolves to the bundled runtime; that explicit +# injection is what restores the zero-config experience. The runtime bin only +# boots this config; the serving surface (the stdio JSON-RPC server) comes +# from the @deepseek-ai/dsh-jsonrpc entry below. +# +# $DSH_SESSION_ROOT and $DSH_CWD are set by the SDK per launch; the `!!js` +# fallbacks keep this file usable when the runtime is driven manually. + +# The serving surface: HarnessSdkServer + line-delimited JSON-RPC transport on +# stdio. Without this entry the runtime boots an agent nobody can talk to. +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +# The agent spine bundle: session store, system prompt, tool registry, agent +# registry, and the agent loop. No pre-created agents — the SDK server creates +# one per session/prompt sessionId. +- id: agent-core + name: '@deepseek-ai/dsh-agent-core' + +# The DeepSeek adapter, preloaded for the stock models. The adapter fails loud +# at load without an API key, so keyless boots must still export a dummy +# DEEPSEEK_API_KEY (initialize/shutdown never call the model). baseURL falls +# back to the public endpoint when unset. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-flash + - deepseek-v4-pro + +# JSONL session persistence. $DSH_SESSION_ROOT (set by the SDK whenever +# `session_root` is configured) wins; otherwise ./.sessions relative to the +# runtime process cwd. +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + +# Local bash executor behind the spine's `bash` tool. $DSH_CWD (always set by +# the SDK) wins; otherwise the runtime process cwd. +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml new file mode 100644 index 0000000000..c21759e4b7 --- /dev/null +++ b/python/sdk/README.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 +README.md: 67dbd04520ac01318c1f21393c12b7bf4965b4cb +README.zh.md: 36ea4df4ff73e12ce3fda5bbee4108584dd5ad92 diff --git a/python/sdk/README.md b/python/sdk/README.md new file mode 100644 index 0000000000..67dbd04520 --- /dev/null +++ b/python/sdk/README.md @@ -0,0 +1,27 @@ +# DeepSeek Harness Python SDK + +English | [中文](README.zh.md) + +Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The +runtime inherits normal DeepSeek Harness environment variables such as +`DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model +endpoints directly or point those variables at a local proxy during +benchmark runs. + +By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path. + +```py +from deepseek_harness import DeepSeekHarness + +with DeepSeekHarness( + model="deepseek-v4-flash", + cordis="examples/dsbench-coding-agent/cordis.yml", +) as harness: + result = harness.run("Make the requested code change.") +``` + +`TurnResult.final_response` is the text content from the last +`assistant/message` event in the turn. Use `TurnResult.events` for the complete +event stream, including intermediate assistant messages and tool activity. + +The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. If neither `cordis` nor `DSH_CORDIS_CONFIG` is set and the launch resolves to the bundled runtime, the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md new file mode 100644 index 0000000000..36ea4df4ff --- /dev/null +++ b/python/sdk/README.zh.md @@ -0,0 +1,21 @@ +# DeepSeek Harness Python SDK + +[English](README.md) | 中文 + +通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接用真实模型端点,也可以在跑基准测试时把它们指向本地代理。 + +默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己用插件组合需要在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 + +```py +from deepseek_harness import DeepSeekHarness + +with DeepSeekHarness( + model="deepseek-v4-flash", + cordis="examples/dsbench-coding-agent/cordis.yml", +) as harness: + result = harness.run("Make the requested code change.") +``` + +`TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 + +同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。若 `cordis` 与 `DSH_CORDIS_CONFIG` 均未设置,且启动解析到内置运行时,则使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml new file mode 100644 index 0000000000..68760f7c61 --- /dev/null +++ b/python/sdk/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "deepseek-harness" +version = "0.0.0-dev" +description = "Python SDK for DeepSeek Harness" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "BSD-3-Clause" } +dependencies = [ + "pydantic>=2.12", + "deepseek-harness-runtime-bin==0.0.0-dev", +] + +[dependency-groups] +test = ["pytest>=8.0"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/deepseek_harness"] + +# Editable: the runtime package's executables are injected into its source +# tree AFTER install (by scripts/build-exe-for-python-sdk.ts or a manual copy); an +# editable install sees them immediately instead of freezing a wheel snapshot. +[tool.uv.sources] +deepseek-harness-runtime-bin = { path = "../sdk-runtime", editable = true } diff --git a/python/sdk/src/deepseek_harness/__init__.py b/python/sdk/src/deepseek_harness/__init__.py new file mode 100644 index 0000000000..fab791d4f6 --- /dev/null +++ b/python/sdk/src/deepseek_harness/__init__.py @@ -0,0 +1,17 @@ +from .api import DeepSeekHarness, DeepSeekHarnessConfig, Session, TurnResult +from .client import HarnessClient, HarnessConfig +from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo + +__all__ = [ + "DeepSeekHarness", + "DeepSeekHarnessConfig", + "Session", + "TurnResult", + "HarnessClient", + "HarnessConfig", + "IncomingRequest", + "InitializeResponse", + "JsonObject", + "Notification", + "ServerInfo", +] diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py new file mode 100644 index 0000000000..ab3b2f764e --- /dev/null +++ b/python/sdk/src/deepseek_harness/api.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import os +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from .client import HarnessClient, HarnessConfig +from .models import JsonObject, Notification + + +@dataclass(slots=True) +class DeepSeekHarnessConfig: + """Configuration for launching the local DeepSeek Harness SDK runtime. + + The runtime inherits the caller's environment by default, so existing + DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL settings keep working. Use ``env`` to + intentionally override or inject variables for a subprocess. + """ + + model: str = "deepseek-v4-flash" + cwd: str | None = None + runtime_cwd: str | None = None + session_root: str | None = None + cordis: str | None = None + system_prompt: str | None = None + env: dict[str, str] = field(default_factory=dict) + runtime_bin: str | None = None + launch_args_override: tuple[str, ...] | None = None + request_timeout_seconds: float | None = None + shutdown_timeout_seconds: float | None = 1.0 + client_name: str = "deepseek_harness_python_sdk" + client_version: str = "0.0.0-dev" + base_url: str | None = None + api_key: str | None = None + + +@dataclass(slots=True) +class TurnResult: + session_id: str + status: str + final_response: str + events: list[JsonObject] + notifications: list[Notification] + session_root: str | None = None + + +class DeepSeekHarness: + """Synchronous high-level SDK for running DeepSeek Harness agent turns.""" + + def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None: + if config is not None and kwargs: + raise TypeError("pass either DeepSeekHarnessConfig or keyword options, not both") + self.config = config or DeepSeekHarnessConfig(**kwargs) + cwd = self.config.cwd or str(Path.cwd()) + runtime_cwd = self.config.runtime_cwd or cwd + env = dict(self.config.env) + if self.config.session_root is not None: + env["DSH_SESSION_ROOT"] = self.config.session_root + if self.config.cordis is not None: + env["DSH_CORDIS_CONFIG"] = self.config.cordis + else: + self._inject_bundled_default_config(env) + env["DSH_CWD"] = cwd + if self.config.base_url is not None: + env["DEEPSEEK_BASE_URL"] = self.config.base_url + if self.config.api_key is not None: + env["DEEPSEEK_API_KEY"] = self.config.api_key + + self._client = HarnessClient( + HarnessConfig( + runtime_bin=self.config.runtime_bin, + launch_args_override=self.config.launch_args_override, + cwd=runtime_cwd, + env=env, + request_timeout_seconds=self.config.request_timeout_seconds, + shutdown_timeout_seconds=self.config.shutdown_timeout_seconds, + client_name=self.config.client_name, + client_version=self.config.client_version, + ) + ) + self._initialized = False + + def __enter__(self) -> "DeepSeekHarness": + self.start() + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + @property + def client(self) -> HarnessClient: + return self._client + + def start(self) -> None: + if self._initialized: + return + self._client.start() + self._client.initialize( + cwd=self.config.cwd or str(Path.cwd()), + model=self.config.model, + session_root=self.config.session_root, + system_prompt=self.config.system_prompt, + ) + self._initialized = True + + def close(self) -> None: + self._client.close() + self._initialized = False + + def _inject_bundled_default_config(self, env: dict[str, str]) -> None: + """Restore the zero-config experience over the config-mandatory bundled runtime. + + The bundled runtime (single-file exe or the dev-only node closure) + always demands an explicit config. When the caller neither provided + ``cordis`` nor selected a runtime explicitly (``runtime_bin`` / + ``launch_args_override``), and no ambient ``DSH_CORDIS_CONFIG`` exists, + inject the runtime package's checked-in default cordis.yml. With an + explicit runtime or config channel the SDK stays out of the way. + """ + uses_bundled_runtime = self.config.runtime_bin is None and self.config.launch_args_override is None + if not uses_bundled_runtime or "DSH_CORDIS_CONFIG" in env or "DSH_CORDIS_CONFIG" in os.environ: + return + try: + from deepseek_harness_runtime import bundled_default_config_path + except ImportError: + # Only the runtime package's absence reaches here; swallow it so + # HarnessClient.start() reports the actionable install error. + return + env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) + + def start_session(self, session_id: str | None = None) -> "Session": + self.start() + return Session(self, session_id or f"session-{uuid.uuid4().hex}") + + def run( + self, + input: str | list[JsonObject], + *, + session_id: str | None = None, + profile: str | None = None, + on_notification: Callable[[Notification], None] | None = None, + ) -> TurnResult: + return self.start_session(session_id).run(input, profile=profile, on_notification=on_notification) + + +class Session: + def __init__(self, harness: DeepSeekHarness, session_id: str) -> None: + self.harness = harness + self.id = session_id + + def run( + self, + input: str | list[JsonObject], + *, + profile: str | None = None, + on_notification: Callable[[Notification], None] | None = None, + ) -> TurnResult: + content_blocks = normalize_input(input) + notifications: list[Notification] = [] + events: list[JsonObject] = [] + status = "error" + finished = False + + def collect(notification: Notification) -> None: + nonlocal finished, status + notifications.append(notification) + if on_notification is not None: + on_notification(notification) + if notification.method == "session.event": + event = notification.payload.get("event") + if isinstance(event, dict): + events.append(event) + if notification.method == "session.finished" and notification.payload.get("sessionId") == self.id: + status = str(notification.payload.get("status") or "ok") + finished = True + + with self.harness.client.subscribe_session_notifications(self.id) as subscription: + self.harness.client.session_prompt( + self.id, + content_blocks, + profile=profile, + on_notification=collect, + notification_subscription=subscription, + ) + + while not finished: + notification = subscription.next() + collect(notification) + + return TurnResult( + session_id=self.id, + status=status, + final_response=final_response(events), + events=events, + notifications=notifications, + session_root=self.harness.config.session_root, + ) + + +def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]: + if isinstance(input, str): + return [{"type": "text", "text": input}] + return input + + +def final_response(events: list[JsonObject]) -> str: + for event in reversed(events): + if event.get("type") != "assistant/message": + continue + data = event.get("data") + if not isinstance(data, dict): + continue + content = data.get("content") + if not isinstance(content, list): + continue + parts: list[str] = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(str(block.get("text") or "")) + return "".join(parts) + return "" diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py new file mode 100644 index 0000000000..541c1b47c8 --- /dev/null +++ b/python/sdk/src/deepseek_harness/client.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +import json +import os +import queue +import subprocess +import threading +import time +import uuid +from collections import deque +from dataclasses import dataclass +from typing import Callable, Literal, TypeAlias, TypeVar + +from pydantic import BaseModel + +from .errors import JsonRpcError, TransportClosedError +from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification + +ModelT = TypeVar("ModelT", bound=BaseModel) +NotificationFilter: TypeAlias = Callable[[Notification], bool] + + +@dataclass(slots=True) +class HarnessConfig: + """Configuration for launching the local DeepSeek Harness SDK runtime.""" + + runtime_bin: str | None = None + bridge_bin: str | None = None + launch_args_override: tuple[str, ...] | None = None + cwd: str | None = None + env: dict[str, str] | None = None + request_timeout_seconds: float | None = None + shutdown_timeout_seconds: float | None = 1.0 + client_name: str = "deepseek_harness_python_sdk" + client_version: str = "0.0.0-dev" + + +class HarnessClient: + """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio.""" + + def __init__(self, config: HarnessConfig | None = None) -> None: + self.config = config or HarnessConfig() + self._proc: subprocess.Popen[str] | None = None + self._lock = threading.Lock() + self._write_lock = threading.Lock() + self._responses: dict[str, queue.Queue[JsonValue | BaseException]] = {} + self._notifications: queue.Queue[Notification | BaseException] = queue.Queue() + self._notification_subscribers: dict[ + str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None] + ] = {} + self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue() + self._stderr_lines: deque[str] = deque(maxlen=400) + self._reader_thread: threading.Thread | None = None + self._stderr_thread: threading.Thread | None = None + + def __enter__(self) -> "HarnessClient": + self.start() + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + def start(self) -> None: + if self._proc is not None: + return + args = list(self.config.launch_args_override or self._default_launch_args()) + env = os.environ.copy() + if self.config.env: + env.update(self.config.env) + self._proc = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + cwd=self.config.cwd, + env=env, + bufsize=1, + ) + self._start_reader_thread() + self._start_stderr_thread() + + def close(self) -> None: + proc = self._proc + if proc is None: + return + try: + self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds) + except Exception as exc: + self._stderr_lines.append(f"shutdown request failed: {exc}") + self._proc = None + if proc.stdin: + try: + proc.stdin.close() + except Exception as exc: + self._stderr_lines.append(f"stdin close failed: {exc}") + try: + if proc.poll() is None: + proc.terminate() + proc.wait(timeout=2) + except Exception: + proc.kill() + self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed")) + if self._reader_thread and self._reader_thread.is_alive(): + self._reader_thread.join(timeout=0.5) + if self._stderr_thread and self._stderr_thread.is_alive(): + self._stderr_thread.join(timeout=0.5) + + def initialize( + self, + *, + cwd: str, + model: str, + session_root: str | None = None, + system_prompt: str | None = None, + ) -> InitializeResponse: + payload: JsonObject = { + "clientInfo": { + "name": self.config.client_name, + "version": self.config.client_version, + }, + "cwd": cwd, + "model": model, + } + if session_root is not None: + payload["sessionRoot"] = session_root + if system_prompt is not None: + payload["systemPrompt"] = system_prompt + return self.request("initialize", payload, response_model=InitializeResponse) + + def session_prompt( + self, + session_id: str, + content_blocks: list[JsonObject], + *, + profile: str | None = None, + on_notification: Callable[[Notification], None] | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> None: + payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks} + if profile is not None: + payload["profile"] = profile + self.request( + "session/prompt", + payload, + response_model=_SessionPromptResponse, + on_notification=on_notification, + notification_filter=_notification_belongs_to_session(session_id), + notification_subscription=notification_subscription, + ) + + def request( + self, + method: str, + params: JsonObject | None, + *, + response_model: type[ModelT], + timeout_seconds: float | None = None, + on_notification: Callable[[Notification], None] | None = None, + notification_filter: NotificationFilter | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> ModelT: + result = self._request_raw( + method, + params, + timeout_seconds=timeout_seconds, + on_notification=on_notification, + notification_filter=notification_filter, + notification_subscription=notification_subscription, + ) + if not isinstance(result, dict): + raise TypeError(f"{method} response must be a JSON object") + return response_model.model_validate(result) + + def notify(self, method: str, params: JsonObject | None = None) -> None: + message: JsonObject = {"jsonrpc": "2.0", "method": method} + if params is not None: + message["params"] = params + self._write_message(message) + + def next_notification(self) -> Notification: + item = self._notifications.get() + if isinstance(item, BaseException): + raise item + return item + + def subscribe_notifications( + self, + notification_filter: NotificationFilter | None = None, + ) -> "NotificationSubscription": + subscription_id = str(uuid.uuid4()) + notifications: queue.Queue[Notification | BaseException] = queue.Queue() + with self._lock: + self._notification_subscribers[subscription_id] = (notifications, notification_filter) + return NotificationSubscription(self, subscription_id, notifications) + + def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription": + return self.subscribe_notifications(_notification_belongs_to_session(session_id)) + + def next_request(self) -> IncomingRequest: + item = self._requests.get() + if isinstance(item, BaseException): + raise item + return item + + def respond(self, request_id: str | int, result: JsonValue) -> None: + self._write_message({"jsonrpc": "2.0", "id": request_id, "result": result}) + + def respond_error( + self, + request_id: str | int, + *, + code: int, + message: str, + data: JsonValue | None = None, + ) -> None: + error: JsonObject = {"code": code, "message": message} + if data is not None: + error["data"] = data + self._write_message({"jsonrpc": "2.0", "id": request_id, "error": error}) + + def _request_raw( + self, + method: str, + params: JsonObject | None = None, + *, + timeout_seconds: float | None = None, + on_notification: Callable[[Notification], None] | None = None, + notification_filter: NotificationFilter | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> JsonValue: + request_id = str(uuid.uuid4()) + waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1) + temp_subscription: NotificationSubscription | None = None + subscription = notification_subscription + with self._lock: + self._responses[request_id] = waiter + if on_notification is not None and subscription is None: + temp_subscription = self.subscribe_notifications(notification_filter) + subscription = temp_subscription + try: + message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + message["params"] = params + self._write_message(message) + except BaseException: + with self._lock: + self._responses.pop(request_id, None) + if temp_subscription is not None: + temp_subscription.close() + raise + timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds + deadline = None if timeout is None else time.monotonic() + timeout + try: + while True: + if on_notification is not None and subscription is not None: + subscription.drain(on_notification) + wait_timeout = None + if on_notification is not None: + wait_timeout = 0.05 + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + with self._lock: + self._responses.pop(request_id, None) + raise TimeoutError(f"{method} timed out waiting for DeepSeek Harness runtime") + wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining) + try: + item = waiter.get(timeout=wait_timeout) + if on_notification is not None and subscription is not None: + subscription.drain(on_notification) + break + except queue.Empty: + continue + except BaseException: + with self._lock: + self._responses.pop(request_id, None) + if temp_subscription is not None: + temp_subscription.close() + raise + finally: + if temp_subscription is not None: + temp_subscription.close() + if isinstance(item, BaseException): + raise item + return item + + def _write_message(self, message: JsonObject) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + raise TransportClosedError("DeepSeek Harness runtime is not running") + try: + payload = json.dumps(message, separators=(",", ":")) + "\n" + with self._write_lock: + proc.stdin.write(payload) + proc.stdin.flush() + except Exception as exc: + raise self._runtime_closed_error("Failed to write to DeepSeek Harness runtime") from exc + + def _start_reader_thread(self) -> None: + self._reader_thread = threading.Thread(target=self._reader_loop, name="dsh-runtime-reader", daemon=True) + self._reader_thread.start() + + def _start_stderr_thread(self) -> None: + self._stderr_thread = threading.Thread(target=self._stderr_loop, name="dsh-runtime-stderr", daemon=True) + self._stderr_thread.start() + + def _reader_loop(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + for line in proc.stdout: + if not line.strip(): + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + self._handle_message(message) + except BaseException as exc: + self._fail_waiters(exc) + finally: + self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime stdout closed")) + + def _stderr_loop(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + for line in proc.stderr: + self._stderr_lines.append(line.rstrip()) + + def _handle_message(self, message: object) -> None: + if not isinstance(message, dict): + return + msg_id = message.get("id") + method = message.get("method") + if isinstance(msg_id, (str, int)) and isinstance(method, str): + params = message.get("params") + self._requests.put(IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {})) + return + if isinstance(msg_id, (str, int)): + with self._lock: + waiter = self._responses.pop(str(msg_id), None) + if waiter is None: + return + if isinstance(message.get("error"), dict): + err = message["error"] + waiter.put(JsonRpcError(_int_or_none(err.get("code")), str(err.get("message", "JSON-RPC error")), err.get("data"))) + else: + waiter.put(message.get("result")) + return + if isinstance(method, str): + params = message.get("params") + notification = Notification(method=method, payload=params if isinstance(params, dict) else {}) + with self._lock: + subscribers = list(self._notification_subscribers.values()) + delivered = False + for subscriber, predicate in subscribers: + if predicate is None or predicate(notification): + subscriber.put(notification) + delivered = True + if not delivered: + self._notifications.put(notification) + + def _fail_waiters(self, exc: BaseException) -> None: + with self._lock: + waiters = list(self._responses.values()) + self._responses.clear() + subscribers = list(self._notification_subscribers.values()) + self._notification_subscribers.clear() + for waiter in waiters: + waiter.put(exc) + for subscriber, _predicate in subscribers: + subscriber.put(exc) + self._notifications.put(exc) + self._requests.put(exc) + + def _runtime_closed_error(self, reason: str) -> TransportClosedError: + proc = self._proc + if ( + proc is not None + and proc.poll() is not None + and self._stderr_thread is not None + and self._stderr_thread.is_alive() + and threading.current_thread() is not self._stderr_thread + ): + self._stderr_thread.join(timeout=0.1) + + parts = [reason] + if proc is not None: + exit_code = proc.poll() + if exit_code is not None: + parts.append(f"exit code: {exit_code}") + if self._stderr_lines: + parts.append("stderr tail:\n" + "\n".join(self._stderr_lines)) + return TransportClosedError("\n".join(parts)) + + def _default_launch_args(self) -> tuple[str, ...]: + if self.config.runtime_bin is not None: + return (self.config.runtime_bin,) + if self.config.bridge_bin is not None: + return (self.config.bridge_bin,) + try: + from deepseek_harness_runtime import resolve_bundled_launch_args + except ImportError as exc: + raise FileNotFoundError( + "Unable to locate the bundled DeepSeek Harness SDK runtime. " + "Install deepseek-harness-runtime-bin or set HarnessConfig.runtime_bin." + ) from exc + return resolve_bundled_launch_args() + + def _unsubscribe_notifications(self, subscription_id: str) -> None: + with self._lock: + self._notification_subscribers.pop(subscription_id, None) + + +class NotificationSubscription: + def __init__( + self, + client: HarnessClient, + subscription_id: str, + notifications: queue.Queue[Notification | BaseException], + ) -> None: + self._client = client + self._subscription_id = subscription_id + self._notifications = notifications + self._closed = False + + def __enter__(self) -> "NotificationSubscription": + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._client._unsubscribe_notifications(self._subscription_id) + + def next(self) -> Notification: + item = self._notifications.get() + if isinstance(item, BaseException): + raise item + return item + + def drain(self, on_notification: Callable[[Notification], None]) -> None: + while True: + try: + item = self._notifications.get_nowait() + except queue.Empty: + return + if isinstance(item, BaseException): + raise item + on_notification(item) + + +class _SessionPromptResponse(BaseModel): + accepted: Literal[True] + + +class _ShutdownResponse(BaseModel): + pass + + +def _int_or_none(value: object) -> int | None: + return value if isinstance(value, int) else None + + +def _notification_belongs_to_session(session_id: str) -> NotificationFilter: + def belongs(notification: Notification) -> bool: + payload = notification.payload + return ( + payload.get("sessionId") == session_id + or payload.get("parentSessionId") == session_id + or payload.get("childSessionId") == session_id + ) + + return belongs diff --git a/python/sdk/src/deepseek_harness/errors.py b/python/sdk/src/deepseek_harness/errors.py new file mode 100644 index 0000000000..84cac8d3ce --- /dev/null +++ b/python/sdk/src/deepseek_harness/errors.py @@ -0,0 +1,19 @@ +from __future__ import annotations + + +class HarnessError(Exception): + """Base exception for SDK and runtime failures.""" + + +class TransportClosedError(HarnessError): + """Raised when the runtime subprocess exits or closes stdout.""" + + +class JsonRpcError(HarnessError): + """Raised when the runtime returns a JSON-RPC error response.""" + + def __init__(self, code: int | None, message: str, data: object | None = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.data = data diff --git a/python/sdk/src/deepseek_harness/models.py b/python/sdk/src/deepseek_harness/models.py new file mode 100644 index 0000000000..f398bb422d --- /dev/null +++ b/python/sdk/src/deepseek_harness/models.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +from pydantic import BaseModel + +JsonScalar: TypeAlias = str | int | float | bool | None +JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"] +JsonObject: TypeAlias = dict[str, JsonValue] + + +@dataclass(slots=True) +class Notification: + method: str + payload: JsonObject + + +@dataclass(slots=True) +class IncomingRequest: + id: str | int + method: str + payload: JsonObject + + +class ServerInfo(BaseModel): + name: str | None = None + version: str | None = None + + +class InitializeResponse(BaseModel): + serverInfo: ServerInfo | None = None diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py new file mode 100644 index 0000000000..81ee36cfc9 --- /dev/null +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -0,0 +1,127 @@ +"""Manual keyless smoke: drive the repo-source jsonrpc-agent bin (node + tsx). + +Runs the SDK against `packages/ui/jsonrpc-agent/src/bin.ts` executed from the +repo checkout (requires `pnpm install`; no build, no API key — the model +endpoint is a local mock SSE server). The bin only boots the supplied +cordis.yml — the stdio JSON-RPC server itself comes from the config's +`@deepseek-ai/dsh-jsonrpc` entry — so the runtime package's default cordis.yml +is passed explicitly. Not collected by pytest; run it directly: +`python tests/manual_sdk_agent_smoke.py`. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from deepseek_harness import DeepSeekHarness +from deepseek_harness_runtime import bundled_default_config_path + + +class MockCompletionHandler(BaseHTTPRequestHandler): + requests: list[dict[str, Any]] = [] + + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(length).decode("utf-8") + self.requests.append({ + "path": self.path, + "authorization": self.headers.get("authorization"), + "body": json.loads(body), + }) + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.end_headers() + self.wfile.write(b'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') + self.wfile.write(b'data: {"choices":[{"delta":{"content":"SDK runtime reached the configured HTTP model endpoint."}}]}\n\n') + self.wfile.write(b'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":9}}\n\n') + self.wfile.write(b"data: [DONE]\n\n") + + def log_message(self, _format: str, *_args: object) -> None: + return + + +def run_smoke(repo_root: Path, keep_sessions: bool) -> None: + session_root = Path(tempfile.mkdtemp(prefix="dsh-sdk-smoke-sessions-")) + runtime_entry = repo_root / "packages/ui/jsonrpc-agent/src/bin.ts" + server = ThreadingHTTPServer(("127.0.0.1", 0), MockCompletionHandler) + thread = threading.Thread(target=server.serve_forever, name="mock-openai-compatible-server", daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + print(f"repo_root={repo_root}") + print(f"session_root={session_root}") + print(f"mock_base_url={base_url}") + + try: + with DeepSeekHarness( + model="sdk-smoke-model", + cwd=str(repo_root / "python/sdk"), + runtime_cwd=str(repo_root), + session_root=str(session_root), + cordis=str(bundled_default_config_path()), + system_prompt="You are running a Python SDK smoke test.", + launch_args_override=("node", "--import", "tsx", str(runtime_entry)), + env={ + "DEEPSEEK_BASE_URL": base_url, + "DEEPSEEK_API_KEY": "sdk-smoke-key", + }, + request_timeout_seconds=20, + shutdown_timeout_seconds=2, + ) as harness: + result = harness.run( + "Please reply with a short confirmation and do not call tools.", + session_id="sdk-smoke-main", + profile="build", + ) + print(f"turn_status={result.status}") + print(f"final_response={result.final_response}") + assert result.status == "ok", result + assert "configured HTTP model endpoint" in result.final_response + assert len(MockCompletionHandler.requests) == 1 + request = MockCompletionHandler.requests[0] + print(json.dumps(request, ensure_ascii=False, indent=2)[:4000]) + assert request["authorization"] == "Bearer sdk-smoke-key" + assert request["body"]["model"] == "sdk-smoke-model" + + jsonl_files = sorted(session_root.rglob("*.jsonl")) + assert jsonl_files, f"no jsonl sessions were written under {session_root}" + print("session_jsonl_files:") + for path in jsonl_files: + print(f" {path} bytes={path.stat().st_size}") + with path.open("r", encoding="utf-8") as handle: + first_line = handle.readline().strip() + if first_line: + print(f" first_line={first_line[:500]}") + finally: + server.shutdown() + server.server_close() + + if keep_sessions: + print(f"kept_session_root={session_root}") + else: + shutil.rmtree(session_root) + print("removed temporary session root") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[3], + help="Path to the deepseek-harness checkout.", + ) + parser.add_argument("--keep-sessions", action="store_true") + args = parser.parse_args() + run_smoke(args.repo_root.resolve(), args.keep_sessions) + + +if __name__ == "__main__": + main() diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py new file mode 100644 index 0000000000..51dfb668d8 --- /dev/null +++ b/python/sdk/tests/test_bundled_runtime.py @@ -0,0 +1,120 @@ +"""Smoke tests against the bundled dsh-jsonrpc-agent artifacts. + +These boot the runtime the way an installed SDK does, once per bundled +carrier: the platform single-file exe (production) and the dev-only node +closure under ``runtime/node`` driven by system ``node``. Each carrier skips +independently when its artifact is absent on this machine — build or fetch it +per the FileNotFoundError guidance quoted in the skip reason. Keyless: the +dummy DEEPSEEK_API_KEY only satisfies the adapter's load-time check; +initialize/shutdown never call a model. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig +from deepseek_harness.errors import TransportClosedError +from deepseek_harness_runtime import bundled_default_config_path, resolve_bundled_launch_args + +_MODES = ("exe", "node") + +# The serving surface is itself a plugin: without the dsh-jsonrpc entry the +# runtime boots an agent nobody can talk to and exits 0 on stdin EOF. +_CORDIS_YML = """\ +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' +- id: agent-core + name: '@deepseek-ai/dsh-agent-core' +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './sessions' +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: '.' +- id: todo + name: '@deepseek-ai/dsh-tool-todo' +""" + + +def _launch_args(mode: str) -> tuple[str, ...]: + try: + return resolve_bundled_launch_args(mode) + except FileNotFoundError as exc: + pytest.skip(f"bundled {mode}-mode runtime unavailable on this machine: {exc}") + + +def _client(tmp_path: Path, launch_args: tuple[str, ...]) -> HarnessClient: + return HarnessClient( + HarnessConfig( + launch_args_override=launch_args, + cwd=str(tmp_path), + env={ + "DSH_CORDIS_CONFIG": "./cordis.yml", + "DSH_SESSION_ROOT": str(tmp_path / "sessions"), + "DSH_CWD": str(tmp_path), + # initialize() lazily mounts the llm-deepseek adapter for the + # requested model; a dummy key keeps the keyless boot green + # (initialize/shutdown never call the model). + "DEEPSEEK_API_KEY": "sk-dummy-for-boot", + "DEEPSEEK_BASE_URL": "http://127.0.0.1:9", + }, + request_timeout_seconds=120, + ) + ) + + +@pytest.mark.parametrize("mode", _MODES) +def test_bundled_runtime_boots_a_cordis_config(tmp_path: Path, mode: str) -> None: + launch_args = _launch_args(mode) + (tmp_path / "cordis.yml").write_text(_CORDIS_YML) + + with _client(tmp_path, launch_args) as client: + init = client.initialize(cwd=str(tmp_path), model="deepseek-v4-pro") + + assert init.serverInfo is not None + assert init.serverInfo.name == "deepseek-harness-sdk-runtime" + + +@pytest.mark.parametrize("mode", _MODES) +def test_bundled_runtime_surfaces_unbundled_plugin_failure(tmp_path: Path, mode: str) -> None: + launch_args = _launch_args(mode) + (tmp_path / "cordis.yml").write_text( + "- id: missing\n name: '@deepseek-ai/dsh-does-not-exist'\n" + ) + + client = _client(tmp_path, launch_args) + client.start() + try: + with pytest.raises((TransportClosedError, TimeoutError)) as excinfo: + client.initialize(cwd=str(tmp_path), model="deepseek-v4-pro") + finally: + client.close() + + assert "@deepseek-ai/dsh-does-not-exist" in str(excinfo.value) + + +@pytest.mark.parametrize("mode", _MODES) +def test_zero_config_run_injects_bundled_default_cordis_config( + tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch +) -> None: + _launch_args(mode) # skip early when this carrier is unavailable + monkeypatch.setenv("DSH_RUNTIME_MODE", mode) + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + + harness = DeepSeekHarness( + model="deepseek-v4-pro", + cwd=str(tmp_path), + session_root=str(tmp_path / "sessions"), + api_key="sk-dummy-for-boot", + base_url="http://127.0.0.1:9", + request_timeout_seconds=120, + ) + assert harness.client.config.env is not None + assert harness.client.config.env["DSH_CORDIS_CONFIG"] == str(bundled_default_config_path()) + with harness: + pass # __enter__ boots the runtime on the injected default config; __exit__ shuts it down diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py new file mode 100644 index 0000000000..3b12a28865 --- /dev/null +++ b/python/sdk/tests/test_client.py @@ -0,0 +1,592 @@ +from __future__ import annotations + +import json +import sys +import threading +import time +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig + + +def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + env_dump = tmp_path / "env.json" + script.write_text( + """ +import json +import os +import sys + +env_dump = os.environ["ENV_DUMP"] +json.dump({ + "DEEPSEEK_API_KEY": os.environ.get("DEEPSEEK_API_KEY"), + "DEEPSEEK_BASE_URL": os.environ.get("DEEPSEEK_BASE_URL"), + "DSH_CWD": os.environ.get("DSH_CWD"), + "DSH_SESSION_ROOT": os.environ.get("DSH_SESSION_ROOT"), + "DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG"), +}, open(env_dump, "w")) + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({ + "jsonrpc": "2.0", + "method": "session.event", + "params": { + "sessionId": params["sessionId"], + "event": { + "type": "assistant/message", + "data": {"content": [{"type": "text", "text": "hello from runtime"}]}, + }, + }, + }), flush=True) + print(json.dumps({ + "jsonrpc": "2.0", + "method": "session.finished", + "params": {"sessionId": params["sessionId"], "status": "ok"}, + }), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness( + model="deepseek-v4-flash", + cwd=str(tmp_path), + cordis=str(tmp_path / "cordis.yml"), + session_root=str(tmp_path / "sessions"), + launch_args_override=(sys.executable, str(script)), + env={ + "ENV_DUMP": str(env_dump), + "DEEPSEEK_API_KEY": "env-key", + "DEEPSEEK_BASE_URL": "http://127.0.0.1:4321", + }, + ) as harness: + result = harness.run("say hello", session_id="main") + + assert result.status == "ok" + assert result.final_response == "hello from runtime" + assert result.events[0]["type"] == "assistant/message" + dumped_env = json.loads(env_dump.read_text()) + assert dumped_env["DEEPSEEK_API_KEY"] == "env-key" + assert dumped_env["DEEPSEEK_BASE_URL"] == "http://127.0.0.1:4321" + assert dumped_env["DSH_CWD"] == str(tmp_path) + assert dumped_env["DSH_SESSION_ROOT"] == str(tmp_path / "sessions") + assert dumped_env["DSH_CORDIS_CONFIG"] == str(tmp_path / "cordis.yml") + + +def test_session_run_invokes_notification_callback_before_returning(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "main", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + seen: list[str] = [] + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + session = harness.start_session("main") + result = session.run( + "spawn a helper", + on_notification=lambda notification: seen.append(notification.method), + ) + + assert result.status == "ok" + assert seen == ["subagent.started", "session.finished"] + + +def test_session_run_includes_subagent_finished_for_parent_session(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "main", "childSessionId": "child", "status": "ok", "stopReason": "completed"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "main", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + result = harness.run("spawn a helper", session_id="main") + + assert result.status == "ok" + assert [notification.method for notification in result.notifications] == [ + "subagent.started", + "subagent.finished", + "session.finished", + ] + + +def test_session_run_ignores_notifications_for_other_sessions(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "other", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "wrong session"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "other", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "right session"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": params["sessionId"], "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + result = harness.run("stay in your lane", session_id="main") + + assert result.status == "ok" + assert result.final_response == "right session" + assert [notification.payload.get("sessionId") for notification in result.notifications] == ["main", "main"] + + +def test_high_level_session_run_does_not_accumulate_global_notifications(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "ok"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": params["sessionId"], "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + result = harness.run("one turn", session_id="main") + assert result.status == "ok" + assert harness.client._notifications.qsize() == 0 + + +def test_session_run_waits_for_late_finished_without_replaying_stale_notifications(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys +import time + +turn = 0 +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + turn += 1 + params = msg.get("params") or {} + session_id = params["sessionId"] + if turn == 1: + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "first"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": session_id, "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + else: + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + time.sleep(0.05) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "second"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": session_id, "status": "ok"}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + first = harness.run("first turn", session_id="main") + second = harness.run("second turn", session_id="main") + + assert first.final_response == "first" + assert second.final_response == "second" + assert [notification.payload.get("sessionId") for notification in second.notifications] == ["main", "main"] + + +def test_client_starts_subprocess_sends_requests_and_routes_notifications(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "llm/request", "params": {"requestId": "req-1", "sessionId": params["sessionId"], "model": "dsagent", "messages": []}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig(launch_args_override=(sys.executable, str(script))) + ) as client: + init = client.initialize(cwd="/workspace", model="dsagent") + assert init.serverInfo.name == "fake-dsh" + + client.session_prompt("main", [{"type": "text", "text": "fix it"}], profile="build") + notification = client.next_notification() + assert notification.method == "llm/request" + assert notification.payload["requestId"] == "req-1" + assert notification.payload["sessionId"] == "main" + + +def test_client_keeps_unmatched_notifications_available_globally_while_subscribed() -> None: + client = HarnessClient() + with client.subscribe_session_notifications("main"): + client._handle_message({ + "jsonrpc": "2.0", + "method": "session.event", + "params": {"sessionId": "other", "event": {"type": "assistant/message"}}, + }) + + assert client._notifications.qsize() == 1 + notification = client._notifications.get_nowait() + assert not isinstance(notification, BaseException) + assert notification.method == "session.event" + assert notification.payload["sessionId"] == "other" + + +def test_client_rejects_unaccepted_session_prompt_response(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": False}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: + client.initialize(cwd="/workspace", model="dsagent") + with pytest.raises(ValueError): + client.session_prompt("main", [{"type": "text", "text": "fix it"}], profile="build") + + +def test_client_routes_bridge_requests_and_sends_responses(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": "bridge-req-1", "method": "llm.request", "params": {"requestId": "req-1", "sessionId": "main", "model": "dsagent", "messages": []}}), flush=True) + elif "id" in msg and "method" not in msg: + print(json.dumps({"jsonrpc": "2.0", "method": "response/seen", "params": {"result": msg.get("result")}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig(launch_args_override=(sys.executable, str(script))) + ) as client: + client.initialize(cwd="/workspace", model="dsagent") + + request = client.next_request() + assert request.id == "bridge-req-1" + assert request.method == "llm.request" + assert request.payload["requestId"] == "req-1" + + client.respond(request.id, {"content_blocks": [{"type": "text", "text": "done"}]}) + notification = client.next_notification() + assert notification.method == "response/seen" + assert notification.payload["result"]["content_blocks"][0]["text"] == "done" + + +def test_client_ignores_non_json_stdout_lines(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +print("node warning: experimental loader", flush=True) +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig(launch_args_override=(sys.executable, str(script))) + ) as client: + init = client.initialize(cwd="/workspace", model="dsagent") + assert init.serverInfo.name == "fake-dsh" + + +def test_client_request_times_out_when_bridge_does_not_respond(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import time + +time.sleep(60) +""".strip() + ) + + with HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + request_timeout_seconds=0.1, + ) + ) as client: + start = time.monotonic() + try: + client.initialize(cwd="/workspace", model="dsagent") + except TimeoutError: + assert time.monotonic() - start < 2 + else: + raise AssertionError("initialize should time out") + + +def test_client_close_times_out_when_shutdown_does_not_respond(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys +import time + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + time.sleep(60) +""".strip() + ) + + client = HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + shutdown_timeout_seconds=0.1, + ) + ) + client.start() + client.initialize(cwd="/workspace", model="dsagent") + start = time.monotonic() + client.close() + assert time.monotonic() - start < 2 + + +def test_client_close_is_idempotent_before_and_after_start(tmp_path: Path) -> None: + HarnessClient().close() + + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) + client.start() + client.initialize(cwd="/workspace", model="dsagent") + client.close() + client.close() + + +def test_runtime_closed_error_includes_stderr_tail(tmp_path: Path) -> None: + script = tmp_path / "crashing_runtime.py" + script.write_text( + """ +import sys + +print("fatal bridge exploded", file=sys.stderr, flush=True) +sys.exit(42) +""".strip() + ) + + with HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + request_timeout_seconds=2, + ) + ) as client: + with pytest.raises(Exception, match="fatal bridge exploded"): + client.initialize(cwd="/workspace", model="dsagent") + + +def test_client_serializes_concurrent_writes(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + output = tmp_path / "seen.jsonl" + script.write_text( + """ +import json +import os +import sys + +with open(os.environ["SEEN"], "w") as seen: + for line in sys.stdin: + seen.write(line) + seen.flush() + msg = json.loads(line) + if "id" in msg and msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif "id" in msg and msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + env={"SEEN": str(output)}, + ) + ) as client: + client.initialize(cwd="/workspace", model="dsagent") + threads = [ + threading.Thread(target=client.notify, args=(f"notice-{index}", {"index": index})) + for index in range(50) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + for line in output.read_text().splitlines(): + json.loads(line) + + +def test_client_uses_bundled_runtime_package_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + runtime = tmp_path / "dsh-jsonrpc-agent" + runtime.write_text( + """#!/usr/bin/env python3 +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "bundled-runtime"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + runtime.chmod(0o755) + + module_dir = tmp_path / "deepseek_harness_runtime" + module_dir.mkdir() + (module_dir / "__init__.py").write_text( + f""" +def resolve_bundled_launch_args(mode=None): + return ({str(runtime)!r},) +""".strip() + ) + + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) + + with HarnessClient() as client: + init = client.initialize(cwd="/workspace", model="deepseek-v4-pro") + + assert init.serverInfo.name == "bundled-runtime" + + +def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) + monkeypatch.setattr(sys, "path", []) + + with pytest.raises(FileNotFoundError, match="Install deepseek-harness-runtime-bin"): + HarnessClient().start() diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py new file mode 100644 index 0000000000..4549d4dd29 --- /dev/null +++ b/python/sdk/tests/test_runtime_resolution.py @@ -0,0 +1,43 @@ +"""Keyless tests for the deepseek_harness_runtime resolution API. + +These never launch a runtime, so they run everywhere regardless of which +bundled artifacts are present; the launch-and-boot coverage lives in +``test_bundled_runtime.py``. +""" + +from __future__ import annotations + +import pytest + +from deepseek_harness_runtime import ( + RUNTIME_MODE_ENV_VAR, + bundled_default_config_path, + bundled_package_dir, + resolve_bundled_launch_args, +) + + +def test_default_config_is_shipped_with_the_package() -> None: + path = bundled_default_config_path() + assert path == bundled_package_dir() / "runtime" / "cordis.yml" + assert "@deepseek-ai/dsh-agent-core" in path.read_text() + + +def test_unknown_explicit_mode_fails_loud() -> None: + with pytest.raises(ValueError, match="expected 'exe' or 'node'"): + resolve_bundled_launch_args("bogus") + + +def test_unknown_env_mode_fails_loud(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(RUNTIME_MODE_ENV_VAR, "bogus") + with pytest.raises(ValueError, match="expected 'exe' or 'node'"): + resolve_bundled_launch_args() + + +def test_explicit_mode_wins_over_env_mode(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(RUNTIME_MODE_ENV_VAR, "bogus") + try: + args = resolve_bundled_launch_args("exe") + except FileNotFoundError: + return # explicit 'exe' was honored; only the artifact is missing + assert args[0].endswith(("-x64", "-arm64")) diff --git a/python/sdk/uv.lock b/python/sdk/uv.lock new file mode 100644 index 0000000000..94219b95ad --- /dev/null +++ b/python/sdk/uv.lock @@ -0,0 +1,321 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "deepseek-harness" +version = "0.0.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "deepseek-harness-runtime-bin" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "deepseek-harness-runtime-bin", editable = "../sdk-runtime" }, + { name = "pydantic", specifier = ">=2.12" }, +] + +[package.metadata.requires-dev] +test = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "deepseek-harness-runtime-bin" +version = "0.0.0.dev0" +source = { editable = "../sdk-runtime" } + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 965aae795f..b03bd4d619 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -5,7 +5,10 @@ "docs/i18n/README.md", "docs/i18n/translation-rules.md", "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", - "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md" + "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", + "python/README.md", + "python/sdk-runtime/README.md", + "python/sdk/README.md" ], "excluded": [ "docs/AGENTS.md", @@ -14,6 +17,7 @@ "docs/tool-catalog.md", "docs/persistence-catalog.md", "docs/cordis-catalog/", - "docs/i18n/terminology.md" + "docs/i18n/terminology.md", + "python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/" ] } diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 3d80572e7c..b27fcfe885 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -55,8 +55,8 @@ const root = resolve(import.meta.dirname, '..') const listMode = process.argv.includes('--list') const writeMode = process.argv.includes('--write') -/** Scope of the bilingual contract: the root README and the docs tree. */ -const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml'] +/** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */ +const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml'] /** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */ interface Manifest { From bd831db80b770a5c99e9a0f73429a50f3bbfd875 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:08:26 +0800 Subject: [PATCH 04/18] single-exe: closure manifest, build pipeline, and CI workflow --- .../workflows/build-exe-for-python-sdk.yml | 200 ++++++++ .gitignore | 3 + knip.json | 2 +- pnpm-lock.yaml | 180 +++++++ pnpm-workspace.yaml | 3 + python/sdk-runtime/package.json | 68 +++ scripts/build-exe-for-python-sdk.ts | 457 ++++++++++++++++++ 7 files changed, 912 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-exe-for-python-sdk.yml create mode 100644 python/sdk-runtime/package.json create mode 100644 scripts/build-exe-for-python-sdk.ts diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml new file mode 100644 index 0000000000..46eab512bd --- /dev/null +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -0,0 +1,200 @@ +name: Build single-exe + +# Single-file executable (single-exe) builds of the DeepSeek Harness SDK +# runtime. The build pipeline and target platforms are specified in +# docs/rfc/implemented/architecture/2026-07-10-single-exe-sdk-runtime.md: each target is +# built natively on a runner of its own platform (no cross-compilation) by +# scripts/build-exe-for-python-sdk.ts, which deploys the dsh-jsonrpc-agent-pkg closure manifest +# closure with @yao-pkg/pkg into dist-exe/. +# +# Each build leg uploads two artifacts: +# - dsh-jsonrpc-agent-pkg- — the bare single-file exe, for +# consumers that want just the binary. +# - deepseek-harness-python- — the whole python/ directory as a +# tar.gz with that exe already embedded (the build script syncs it into +# the Python runtime package): unpack and both packages pip install +# as-is, the checked-in default runtime/cordis.yml is editable in +# place, and the embedded exe also runs directly. +# +# workflow_dispatch ONLY — deliberately not triggered by push/pull_request: +# the exe is a release-style deliverable, and the build (full pnpm build + +# pnpm deploy + pkg across a 3-platform matrix, ~100MB per artifact) is far +# too expensive to run as a per-commit CI signal. Dispatch it from the +# Actions tab when artifacts are needed. There is no `ref` input on purpose: +# actions/checkout already checks out the branch/tag the run was dispatched +# on. +on: + workflow_dispatch: + inputs: + targets: + description: >- + Comma-separated pkg targets to build. Any subset of: + node24-linux-x64, node24-linux-arm64, node24-macos-arm64. + type: string + required: false + default: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 + +# Manual runs on the same ref supersede each other. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: the jobs only read the repo; artifact upload needs no +# extra scope. +permissions: + contents: read + +jobs: + # Turn the `targets` input into the build matrix. The `matrix` context is + # not available in a job-level `if:` (jobs..if only sees + # github/needs/vars/inputs), so target selection happens here instead of + # skipping matrix legs; an unknown target name fails the whole run loudly + # instead of being silently ignored. + plan: + name: plan targets + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Compute matrix from targets input + id: plan + env: + TARGETS: ${{ inputs.targets }} + run: | + set -euo pipefail + matrix='[]' + IFS=',' read -r -a targets <<< "$TARGETS" + for raw in "${targets[@]}"; do + t="$(echo "$raw" | xargs)" # trim surrounding whitespace + [ -z "$t" ] && continue + # Native builds only — each target maps to a runner of its own + # platform: linux-arm64 uses GitHub's hosted arm64 label + # ubuntu-24.04-arm (there is no ubuntu-latest-arm), macos-arm64 + # uses macos-latest (Apple Silicon since macos-14). + case "$t" in + node24-linux-x64) runner=ubuntu-latest ;; + node24-linux-arm64) runner=ubuntu-24.04-arm ;; + node24-macos-arm64) runner=macos-latest ;; + *) + echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64." + exit 1 + ;; + esac + matrix="$(jq -c --arg target "$t" --arg runner "$runner" '. + [{target: $target, runner: $runner}]' <<< "$matrix")" + done + if [ "$matrix" = '[]' ]; then + echo "::error::The targets input selected nothing to build." + exit 1 + fi + echo "Matrix: $matrix" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + build: + needs: plan + name: ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + # Unlike ci.yml (x64-only), this matrix spans two Linux architectures + # that share runner.os, so runner.arch is part of the key. + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm- + + # The first run per target has pkg-fetch download yao-pkg's patched + # Node binary into ~/.pkg-cache; cache it so later runs skip the + # download. The target string pins Node major + platform + arch; + # pnpm-lock.yaml rolls the key when @yao-pkg/pkg (and with it the + # pinned patched-binary version) is bumped, with restore-keys still + # seeding from the previous cache. + - uses: actions/cache@v4 + with: + path: ~/.pkg-cache + key: pkg-fetch-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pkg-fetch-${{ matrix.target }}- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + # The script runs the whole pipeline itself (pnpm run build → pnpm + # deploy --prod → pkg) and writes its output to dist-exe/ by default. + - name: Build single-exe + run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} + + - uses: actions/upload-artifact@v6 + with: + name: dsh-jsonrpc-agent-pkg-${{ matrix.target }} + path: dist-exe/ + if-no-files-found: error + + # After the build step, python/ is already in its complete + # distributable shape — the script synced this leg's exe into + # python/sdk-runtime/src/deepseek_harness_runtime/runtime/ next to the + # checked-in default cordis.yml — so packing is all that is left. + # Everything goes under one top-level deepseek-harness-python/ + # directory so unpacking never scatters files. Shipping a tar (rather + # than uploading the tree bare) preserves the exe's executable bit — + # tar keeps file modes; upload-artifact's zip does not. + # + # Excluded: runtime/node/ (dev-only node-mode carrier, ~140MB) plus + # __pycache__ / .pytest_cache / .venv / node_modules anywhere (install + # or test leftovers); uv.lock stays in. + # + # Portability: GNU tar (ubuntu) and bsdtar (macos) both accept + # `tar -czf out.tar.gz --exclude=… -C ` and both treat + # an excluded directory as pruned (no descent). The top-level rename + # is done by copying into a temp dir first — GNU --transform / BSD -s + # are single-implementation flags. + - name: Pack Python SDK bundle + id: pack + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + platform_arch="${TARGET#*-}" # node24-macos-arm64 -> macos-arm64 + exe="python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg-${platform_arch}" + if [ ! -x "$exe" ]; then + echo "::error::$exe missing or not executable — the build step did not sync this leg's exe into the Python runtime package; refusing to pack a half-empty bundle." + exit 1 + fi + staging="$(mktemp -d)" + cp -R python "$staging/deepseek-harness-python" + bundle="deepseek-harness-python-${platform_arch}.tar.gz" + tar -czf "$bundle" \ + --exclude='deepseek-harness-python/sdk-runtime/src/deepseek_harness_runtime/runtime/node' \ + --exclude='__pycache__' \ + --exclude='.pytest_cache' \ + --exclude='.venv' \ + --exclude='node_modules' \ + -C "$staging" deepseek-harness-python + rm -rf "$staging" + ls -lh "$bundle" + echo "bundle=$bundle" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v6 + with: + name: deepseek-harness-python-${{ matrix.target }} + path: ${{ steps.pack.outputs.bundle }} + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 6faccc8b6d..90709e81c0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,8 @@ tmp/ .DS_Store .idea mise.toml +dist-exe/ +python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-* +python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/ python/**/__pycache__/ python/**/.pytest_cache/ diff --git a/knip.json b/knip.json index f37f1d7636..825980f205 100644 --- a/knip.json +++ b/knip.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], "ignoreBinaries": ["bwrap", "sandbox-exec"], - "ignoreWorkspaces": ["vendor/*"], + "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"], "workspaces": { ".": { "entry": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d868ae049..164bcbb17f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1620,6 +1620,186 @@ importers: specifier: ^4.19.2 version: 4.22.4 + python/sdk-runtime: + dependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../vendor/loader + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../vendor/timer + '@deepseek-ai/dsh-acp': + specifier: workspace:^ + version: link:../../packages/ui/acp + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../packages/core/agent + '@deepseek-ai/dsh-agent-core': + specifier: workspace:^ + version: link:../../packages/core/agent-core + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../packages/core/agent-loop + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../packages/ui/app-boot + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../packages/bash/bash + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../packages/bash/bash-local + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../packages/util/brand + '@deepseek-ai/dsh-code-runtime': + specifier: workspace:^ + version: link:../../packages/code-runtime/code-runtime + '@deepseek-ai/dsh-code-runtime-worker': + specifier: workspace:^ + version: link:../../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../packages/compact/compact + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../packages/fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../packages/fs/fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-hook-protocol': + specifier: workspace:^ + version: link:../../packages/hooks/hook-protocol + '@deepseek-ai/dsh-hooks-claude': + specifier: workspace:^ + version: link:../../packages/hooks/hooks-claude + '@deepseek-ai/dsh-hooks-codex': + specifier: workspace:^ + version: link:../../packages/hooks/hooks-codex + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../packages/support/invariants + '@deepseek-ai/dsh-jsonrpc': + specifier: workspace:^ + version: link:../../packages/ui/jsonrpc + '@deepseek-ai/dsh-jsonrpc-agent': + specifier: workspace:^ + version: link:../../packages/ui/jsonrpc-agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../packages/llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:^ + version: link:../../packages/llm/llm-pi-ai + '@deepseek-ai/dsh-repeat-tool-guard': + specifier: workspace:^ + version: link:../../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../packages/core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-persistence-sqlite': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-acp': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-fork + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-inprocess + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subagent-subprocess': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-subprocess + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../packages/core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../packages/util/timeout + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../packages/bash/tool-bash + '@deepseek-ai/dsh-tool-cordis': + specifier: workspace:^ + version: link:../../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../packages/web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/tool-workflow + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../packages/core/tools + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../../packages/ui/user-interaction + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../../packages/web/web + '@deepseek-ai/dsh-web-fetch-local': + specifier: workspace:^ + version: link:../../packages/web/web-fetch-local + '@deepseek-ai/dsh-web-search-deepseek': + specifier: workspace:^ + version: link:../../packages/web/web-search-deepseek + '@deepseek-ai/dsh-web-search-exa': + specifier: workspace:^ + version: link:../../packages/web/web-search-exa + '@deepseek-ai/dsh-web-search-perplexity': + specifier: workspace:^ + version: link:../../packages/web/web-search-perplexity + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/workflow + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../packages/workflow/workflow-workerthread + cordis: + specifier: workspace:^ + version: link:../../vendor/cordis + vendor/cordis: dependencies: '@cordisjs/plugin-include': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6407a99f52..6dc85a6079 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,9 @@ packages: - vendor/* - packages/*/* + # Deploy root of the single-exe build: a pure dependency manifest whose + # closure is what the exe bundles and what the Python runtime distributes. + - python/sdk-runtime peerDependencyRules: allowedVersions: diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json new file mode 100644 index 0000000000..7f179f3941 --- /dev/null +++ b/python/sdk-runtime/package.json @@ -0,0 +1,68 @@ +{ + "name": "dsh-jsonrpc-agent-pkg", + "description": "Deploy root of the single-exe pipeline and the single source of truth unifying 'which plugins the exe bundles' and 'what the Python runtime distributes': the dependency list below IS the exe closure. Pure manifest — no code; a deploy materializes only this package.json plus node_modules.", + "version": "0.0.1", + "private": true, + "type": "module", + "dependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-hooks-claude": "workspace:^", + "@deepseek-ai/dsh-hooks-codex": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jsonrpc": "workspace:^", + "@deepseek-ai/dsh-jsonrpc-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-acp": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-web-fetch-local": "workspace:^", + "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", + "@deepseek-ai/dsh-web-search-exa": "workspace:^", + "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "cordis": "workspace:^" + } +} diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts new file mode 100644 index 0000000000..0cc7b0c731 --- /dev/null +++ b/scripts/build-exe-for-python-sdk.ts @@ -0,0 +1,457 @@ +/** + * Build the single-file SDK runtime executables + * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * + * Every settled decision is hardcoded — the PoC judged @yao-pkg/pkg's + * standard mode unusable for this architecture (its ESM→CJS transform breaks + * every runtime `import()`), so the pipeline is fixed on `--sea` mode, plain + * ESM entry, plain-source assets, and a hoisted (symlink-free) staged tree. + * + * Pipeline — every step fails loud with the command it ran: + * + * 1. `pnpm run build` — all packages emit `lib/` (skippable via --skip-build). + * 2. `pnpm --filter dsh-jsonrpc-agent-pkg deploy` — materialize the + * closure-manifest package (python/sdk-runtime/package.json — the single + * source of truth for the exe's plugin set) into the staging dir + * (cleared first; pnpm refuses a non-empty deploy target). Flags, all + * verified against pnpm 11.7: `--legacy` because the workspace does not + * set `inject-workspace-packages=true`; `node-linker=hoisted` for a plain + * file tree with zero symlinks (the safe shape for pkg's VFS, and it + * physically guarantees a single cordis copy); `auto-install-peers=false` + * so transitive `^0.0.x` peers on unpublished packages never hit the + * registry; `link-workspace-packages=true` so the closure resolves to + * workspace/vendor sources. + * 3. Inject the pkg config into the staged package.json: `bin` = the ESM + * `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` (SEA mode + * hands it to Node's default ESM loader — no CJS shim), plus whole-tree + * asset globs. The cordis Loader resolves plugins + * through runtime dynamic `import()` of bare package names, so pkg's + * static analysis discovers none of them — the entire staged tree must be + * globbed in explicitly. + * 4. `pnpm dlx @yao-pkg/pkg@ --sea --targets --output + * /dsh-jsonrpc-agent-pkg--` — once per target (SEA mode + * packs a single target per invocation), so each product gets its + * canonical name directly. + * 5. Sync into the Python runtime package + * (python/sdk-runtime/src/deepseek_harness_runtime/runtime/, + * created if missing): each product under its canonical filename (exe + * mode), plus the whole staged closure into runtime/node/ (node mode — + * `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` + * runs it directly; the injected pkg + * fields are harmless to node). dist-exe/ keeps the originals for CI + * artifact upload. + * + * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` → host-platform exe into dist-exe/ + * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64` + * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --dry-run` → print the plan without executing + */ + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, statSync } from 'node:fs' +import { copyFile, readFile, rm, writeFile } from 'node:fs/promises' +import { basename, join, resolve, sep } from 'node:path' +import { parseArgs } from 'node:util' + +const root = resolve(import.meta.dirname, '..') + +/** + * The deploy root: the closure-manifest package (python/sdk-runtime) whose + * dependencies define the exe's contents; the runnable entry inside the + * closure is {@link ENTRY_BIN}. + */ +const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' +/** The bin entry inside the deployed closure (the dsh-jsonrpc-agent app bin). */ +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js' +/** Basename of every product; the canonical name appends `--`. */ +const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' +/** Default exe Node major; SEA mode requires >= node22, the repo tracks node24. */ +const DEFAULT_NODE_RANGE = 'node24' +/** Pinned pkg version (the one the PoC and acceptance ran on) for reproducible builds. */ +const PKG_SPEC = '@yao-pkg/pkg@6.21.0' +/** Staging dir for the deployed closure — cleared on every run (gitignored). */ +// (No external staging dir: the deploy target IS the Python runtime's +// node-mode carrier — see PYTHON_RUNTIME_DIR/PYTHON_NODE_SUBDIR.) +/** Product output dir (gitignored). */ +const OUT_DIR = 'dist-exe' +/** + * Python runtime package dir the products are synced into. A parallel change + * owns the directory and its .gitignore; this script's only contract is the + * destination path, so a missing dir is created, never an error. + */ +const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' +/** Subdir of {@link PYTHON_RUNTIME_DIR} carrying the staged closure for node-mode execution. */ +const PYTHON_NODE_SUBDIR = 'node' + +/** + * Whole-tree asset globs. The cordis Loader dynamic-imports bare package names + * at runtime, invisible to pkg's static analysis, so every runtime file in the + * closure is listed; SEA mode ships them as plain source in the VFS. Every + * package.json must ride along — bare-name resolution dies without them (the + * json glob would already match, but the manifests are resolution-critical, so + * they get their own explicit entry). + */ +const ASSET_GLOBS = [ + 'package.json', + 'node_modules/**/*.js', + 'node_modules/**/*.cjs', + 'node_modules/**/*.mjs', + 'node_modules/**/package.json', + 'node_modules/**/*.json', + 'node_modules/**/*.node', + 'node_modules/**/*.wasm', +] + +const PLATFORMS = ['linux', 'macos'] as const +const ARCHES = ['x64', 'arm64'] as const +type Platform = (typeof PLATFORMS)[number] +type Arch = (typeof ARCHES)[number] + +/** True when `value` is a supported pkg platform tag. */ +function isPlatform(value: string): value is Platform { + return (PLATFORMS as readonly string[]).includes(value) +} + +/** True when `value` is a supported pkg CPU tag. */ +function isArch(value: string): value is Arch { + return (ARCHES as readonly string[]).includes(value) +} + +/** + * One pkg target triple, e.g. `node24-linux-x64`, as an immutable value. + * Construction goes through {@link Target.parse} (a `--targets` entry) or + * {@link Target.host} (the default), which own all validation. + */ +class Target { + private constructor( + /** pkg Node range (`node`); pins the official base binary pkg pulls. */ + readonly nodeRange: string, + /** + * pkg platform tag. Windows is a documented non-goal + * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + */ + readonly platform: Platform, + /** pkg CPU tag. */ + readonly arch: Arch, + ) {} + + /** The pkg `--targets` spec string `--`. */ + get spec(): string { + return `${this.nodeRange}-${this.platform}-${this.arch}` + } + + /** + * Parse and validate one target spec; throws on any malformed component. + * @param spec - the raw triple, e.g. `node24-linux-x64`. + * @returns the parsed target. + */ + static parse(spec: string): Target { + const parts = spec.split('-') + const [nodeRange, platform, arch] = parts + if (parts.length !== 3 || nodeRange === undefined || platform === undefined || arch === undefined) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)} must be --, e.g. node24-linux-x64.`) + } + if (!/^node\d+$/.test(nodeRange)) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`) + } + if (!isPlatform(platform)) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')} (Windows is a docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md non-goal), got ${JSON.stringify(platform)}.`) + } + if (!isArch(arch)) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`) + } + return new Target(nodeRange, platform, arch) + } + + /** + * The default target when --targets is omitted: the host platform on node24. + * @returns the host target; throws on an unsupported host platform or arch. + */ + static host(): Target { + const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined + if (platform === undefined) { + throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`) + } + const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined + if (arch === undefined) { + throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`) + } + return new Target(DEFAULT_NODE_RANGE, platform, arch) + } +} + +/** + * Parsed CLI configuration. {@link BuildCli.parse} is the only constructor + * path — it owns flag parsing, target validation, and the --help / bad-flag + * process exits, so an instance always holds a valid plan. + */ +class BuildCli { + private constructor( + /** Build targets; defaults to the host platform only. */ + readonly targets: readonly Target[], + /** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */ + readonly skipBuild: boolean, + /** Print every command and config patch instead of executing. */ + readonly dryRun: boolean, + ) {} + + /** + * Parse argv into a validated configuration. Exits the process for --help + * (code 0, usage) and for unknown/malformed flags (code 1, usage on + * stderr); throws on invalid or colliding targets. + * @param argv - the raw arguments (`process.argv.slice(2)`). + * @returns the parsed, validated configuration. + */ + static parse(argv: string[]): BuildCli { + let values: ReturnType + try { + values = BuildCli.parseRaw(argv) + } catch (error) { + console.error(`build-exe-for-python-sdk: ${error instanceof Error ? error.message : String(error)}\n`) + console.error(BuildCli.usage()) + process.exit(1) + } + if (values.help) { + console.log(BuildCli.usage()) + process.exit(0) + } + const targets = values.targets === undefined + ? [Target.host()] + : values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec)) + if (targets.length === 0) throw new Error('build-exe-for-python-sdk: --targets is empty.') + const seen = new Set() + for (const target of targets) { + const key = `${target.platform}-${target.arch}` + if (seen.has(key)) { + throw new Error(`build-exe-for-python-sdk: duplicate platform-arch ${key} in --targets; canonical product names would collide.`) + } + seen.add(key) + } + return new BuildCli(targets, values['skip-build'], values['dry-run']) + } + + /** The flag grammar in one place; parseArgs throws on any unknown flag. */ + private static parseRaw(argv: string[]) { + return parseArgs({ + args: argv, + options: { + 'targets': { type: 'string' }, + 'skip-build': { type: 'boolean', default: false }, + 'dry-run': { type: 'boolean', default: false }, + 'help': { type: 'boolean', default: false }, + }, + }).values + } + + /** The --help text; also printed under flag-parse errors. */ + private static usage(): string { + return [ + 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]', + '', + ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.', + ' Default: the host platform only (on node24).', + ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).', + ' --dry-run print every command and config patch without executing.', + ' --help print this help.', + '', + 'Settled decisions are hardcoded (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md): pkg runs in --sea mode', + `(standard mode breaks runtime import()), pinned to ${PKG_SPEC}; the deploy tree is`, + `hoisted/symlink-free; the closure deploys straight into ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and products land in ${OUT_DIR}/.`, + ].join('\n') + } +} + +/** The pnpm executable name for the host OS. */ +function pnpmBin(): string { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +} + +/** + * Render a command line for logs and error messages, quoting arguments that + * contain spaces. + * @param command - the executable. + * @param args - its arguments. + * @returns the printable command line. + */ +function formatCommand(command: string, args: string[]): string { + return [command, ...args].map(part => (part.includes(' ') ? JSON.stringify(part) : part)).join(' ') +} + +/** + * The four-step build pipeline over one parsed CLI. Steps are sequential + * async methods; every subprocess inherits stdio and fails loud with the + * exact command it ran. In --dry-run the command/filesystem layer prints + * what it would do instead of executing. + */ +class SingleExeBuild { + /** + * Absolute staging dir — the Python runtime's node-mode carrier: step 2 + * deploys the closure DIRECTLY here (cleared first; it is a pure build + * product, the checked-in default `cordis.yml` lives one level up), step 4 + * reads it as the pkg input, and node mode runs it in place. + */ + readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR) + /** Absolute product output dir. */ + private readonly outDir = resolve(root, OUT_DIR) + + constructor(private readonly cli: BuildCli) {} + + /** Step 1: `pnpm run build` — all packages emit `lib/` (skipped via --skip-build). */ + async build(): Promise { + if (this.cli.skipBuild) { + console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)') + return + } + await this.run('build', pnpmBin(), ['run', 'build']) + } + + /** Step 2: clear the staging dir and deploy the bridge closure into it. */ + async deployStaging(): Promise { + if (this.staging === root || root.startsWith(this.staging + sep)) { + throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`) + } + if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`) + else await rm(this.staging, { recursive: true, force: true }) + await this.run('deploy', pnpmBin(), [ + '--filter', + DEPLOY_ROOT_PACKAGE, + 'deploy', + '--legacy', + '--prod', + '--config.node-linker=hoisted', + '--config.auto-install-peers=false', + '--config.link-workspace-packages=true', + this.staging, + ]) + } + + /** Step 3: patch the staged package.json with the bin entry + pkg asset globs. */ + async injectPkgConfig(): Promise { + const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } + const manifestPath = join(this.staging, 'package.json') + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`) + return + } + if (!existsSync(manifestPath)) { + throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`) + } + if (!existsSync(join(this.staging, ENTRY_BIN))) { + throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`) + } + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record + await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`) + console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`) + } + + /** + * Step 4: run @yao-pkg/pkg over the staged tree for ONE target (SEA mode + * packs a single target per invocation) and return the product path. + * @param target - the pkg target triple to build. + * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. + */ + async pack(target: Target): Promise { + const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) + await this.run(`pkg ${target.spec}`, pnpmBin(), [ + 'dlx', + PKG_SPEC, + this.staging, + '--sea', + '--targets', + target.spec, + '--output', + product, + ]) + if (!this.cli.dryRun && !existsSync(product)) { + throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) + } + return product + } + + /** + * Print each product path (and size, when it exists on disk). + * @param products - the product paths returned by {@link pack}. + */ + printProducts(products: string[]): void { + console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:') + for (const product of products) { + if (this.cli.dryRun) { + console.log(` ${product}`) + continue + } + const megabytes = statSync(product).size / (1024 * 1024) + console.log(` ${product} (${megabytes.toFixed(1)} MB)`) + } + } + + /** + * Step 5: copy every product into the Python runtime package under its + * canonical filename (exe mode). The node-mode carrier needs no sync — step + * 2 deployed the closure into it directly. dist-exe/ keeps the originals + * for CI artifact upload; the destination dir is created if missing. + * @param products - the product paths returned by {@link pack}. + */ + async syncToPythonRuntime(products: string[]): Promise { + const destDir = resolve(root, PYTHON_RUNTIME_DIR) + if (this.cli.dryRun) { + for (const product of products) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`) + } + return + } + mkdirSync(destDir, { recursive: true }) + for (const product of products) { + const destination = join(destDir, basename(product)) + await copyFile(product, destination) + console.log(`build-exe-for-python-sdk: synced ${destination}`) + } + } + + /** + * Run one pipeline step as a subprocess with inherited stdio; reject — + * carrying the printable command — on spawn failure and non-zero exit + * alike. In --dry-run, print the command instead of executing. + * @param label - the step name used in logs and error messages. + * @param command - the executable. + * @param args - its arguments. + */ + private async run(label: string, command: string, args: string[]): Promise { + const printable = formatCommand(command, args) + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] ${printable}`) + return + } + console.log(`build-exe-for-python-sdk: ${label}: ${printable}`) + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit' }) + child.once('error', (error) => { + reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`)) + }) + child.once('exit', (code, signal) => { + if (code === 0) { + resolvePromise() + return + } + const cause = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}` + reject(new Error(`build-exe-for-python-sdk: ${label} failed (${cause}): ${printable}`)) + }) + }) + } +} + +/** Entry point: parse the CLI, then await each pipeline step in order. */ +async function main(): Promise { + const cli = BuildCli.parse(process.argv.slice(2)) + const pipeline = new SingleExeBuild(cli) + console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`) + console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`) + await pipeline.build() + await pipeline.deployStaging() + await pipeline.injectPkgConfig() + const products: string[] = [] + for (const target of cli.targets) products.push(await pipeline.pack(target)) + pipeline.printProducts(products) + await pipeline.syncToPythonRuntime(products) +} + +await main() From 578e6345801c6af87611a02e8b82aed15d255ba6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:08:28 +0800 Subject: [PATCH 05/18] docs: fold the SDK serving surface into the repo maps --- AGENTS.md | 2 +- packages/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 843e09e8b2..84c5827502 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP bridge, app-boot glue, stdio/ACP app bins, user-approval and user-interaction seams, ask-user tool + ui/ ACP bridge, JSON-RPC SDK server, app-boot glue, stdio/ACP/SDK app bins, user-approval and user-interaction seams, ask-user tool support/ dev/test infrastructure packages util/ zero-dependency utilities examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md) diff --git a/packages/README.md b/packages/README.md index 35c4652a1f..7b6b1bb32a 100644 --- a/packages/README.md +++ b/packages/README.md @@ -25,7 +25,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | +| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | From 81f6aeca3b88896f1f454922374b8d7b7c26871f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:25:45 +0800 Subject: [PATCH 06/18] single-exe: build-exe PR label as a second explicit trigger for the exe workflow --- .../workflows/build-exe-for-python-sdk.yml | 41 ++++++++++++------- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- 7 files changed, 34 insertions(+), 23 deletions(-) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 46eab512bd..8e94476476 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -2,10 +2,11 @@ name: Build single-exe # Single-file executable (single-exe) builds of the DeepSeek Harness SDK # runtime. The build pipeline and target platforms are specified in -# docs/rfc/implemented/architecture/2026-07-10-single-exe-sdk-runtime.md: each target is -# built natively on a runner of its own platform (no cross-compilation) by -# scripts/build-exe-for-python-sdk.ts, which deploys the dsh-jsonrpc-agent-pkg closure manifest -# closure with @yao-pkg/pkg into dist-exe/. +# docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md: +# each target is built natively on a runner of its own platform (no +# cross-compilation) by scripts/build-exe-for-python-sdk.ts, which deploys +# the dsh-jsonrpc-agent-pkg closure manifest with @yao-pkg/pkg into +# dist-exe/. # # Each build leg uploads two artifacts: # - dsh-jsonrpc-agent-pkg- — the bare single-file exe, for @@ -16,13 +17,15 @@ name: Build single-exe # as-is, the checked-in default runtime/cordis.yml is editable in # place, and the embedded exe also runs directly. # -# workflow_dispatch ONLY — deliberately not triggered by push/pull_request: -# the exe is a release-style deliverable, and the build (full pnpm build + -# pnpm deploy + pkg across a 3-platform matrix, ~100MB per artifact) is far -# too expensive to run as a per-commit CI signal. Dispatch it from the -# Actions tab when artifacts are needed. There is no `ref` input on purpose: -# actions/checkout already checks out the branch/tag the run was dispatched -# on. +# Two explicit triggers, deliberately no per-commit CI: the exe is a +# release-style deliverable, and the build (full pnpm build + pnpm deploy + +# pkg across a 3-platform matrix, ~100MB per artifact) is far too expensive +# to run on every push. Either dispatch it from the Actions tab, or put the +# `build-exe` label on a pull request to build that PR's merge result +# (remove and re-apply the label to rerun); any other label leaves the jobs +# skipped. There is no `ref` input on purpose: actions/checkout already +# checks out the ref the run was triggered on — the dispatched branch/tag, +# or the PR merge ref. on: workflow_dispatch: inputs: @@ -30,11 +33,15 @@ on: description: >- Comma-separated pkg targets to build. Any subset of: node24-linux-x64, node24-linux-arm64, node24-macos-arm64. + Empty builds all three. type: string required: false - default: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 + default: '' + pull_request: + types: [labeled] -# Manual runs on the same ref supersede each other. +# Runs on the same ref supersede each other (per branch/tag for dispatch, +# per PR merge ref for label runs). concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -49,9 +56,11 @@ jobs: # not available in a job-level `if:` (jobs..if only sees # github/needs/vars/inputs), so target selection happens here instead of # skipping matrix legs; an unknown target name fails the whole run loudly - # instead of being silently ignored. + # instead of being silently ignored. The label gate lives here too: `build` + # needs this job, so skipping it skips the whole run. plan: name: plan targets + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe' runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -60,7 +69,9 @@ jobs: - name: Compute matrix from targets input id: plan env: - TARGETS: ${{ inputs.targets }} + # Empty on label runs and on dispatch with the input left blank — + # both mean "all three targets". + TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64' }} run: | set -euo pipefail matrix='[]' diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index bf09a201d4..2d879af81f 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: eab441e9b73d49d783a85f457ee84b7f479d86b4 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 4244d9e4e3af6cb04618c24cfa72ef77cd17335c +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 4fab5f89542753f2dd5977d4262e1310097f4545 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 56a93f2392bff655d32ec4947c357b33d9e6da5e diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index eab441e9b7..4fab5f8954 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -42,7 +42,7 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the artifacts `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` (the CI artifact) and are copied back into the runtime directory. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), manually triggered via `workflow_dispatch` only; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached and artifacts uploaded per platform; macOS ad-hoc signing is handled by pkg. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached and artifacts uploaded per platform; macOS ad-hoc signing is handled by pkg. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 4244d9e4e3..56a93f2392 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -42,7 +42,7 @@ deploy root 是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runti [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):`pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 产物 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/`(CI artifact)并拷回 runtime 目录。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 -CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅 `workflow_dispatch` 手动触发,linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,`~/.pkg-cache` 缓存,artifact 按平台上传;macOS ad-hoc 签名由 pkg 处理。Windows 是非目标。 +CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,`~/.pkg-cache` 缓存,artifact 按平台上传;macOS ad-hoc 签名由 pkg 处理。Windows 是非目标。 ### Python SDK 分发:双载体,exe 为生产、node 为开发 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 29c3b03096..b337d2c288 100644 --- a/python/README.i18n.yaml +++ b/python/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 -README.md: 56199c1daaac4d55a29dae8852277f0ba77238b2 -README.zh.md: e34f3afd455926bd6b09d3451509594cee78ad88 +README.md: 294cbd1eb0690de17fa70f3049bb4677e1fd135a +README.zh.md: f89d1612f6ac454b3a57515b5a1a0f3c59c579d1 diff --git a/python/README.md b/python/README.md index 56199c1daa..294cbd1eb0 100644 --- a/python/README.md +++ b/python/README.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -Products land in `dist-exe/` (CI artifact shape) and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a build the SDK finds the executable with no further setup. Alternatively, download the platform artifact from the `build-exe-for-python-sdk` CI workflow (manual dispatch) and place it at that same path. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). +Products land in `dist-exe/` (CI artifact shape) and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a build the SDK finds the executable with no further setup. Alternatively, download the platform artifact from the `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) and place it at that same path. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). ## Validating the SDK against the executable diff --git a/python/README.zh.md b/python/README.zh.md index e34f3afd45..f89d1612f6 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -产物落入 `dist-exe/`(CI 产物形态),并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),构建完成后 SDK 不需要额外设置就能找到可执行文件。也可以从 `build-exe-for-python-sdk` CI workflow(手动触发)下载对应平台的产物放到同一路径。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发用的 node 载体(见下文「对着 Node 源码运行」)。 +产物落入 `dist-exe/`(CI 产物形态),并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),构建完成后 SDK 不需要额外设置就能找到可执行文件。也可以从 `build-exe-for-python-sdk` CI workflow(手动触发,或给 PR 打 `build-exe` 标签)下载对应平台的产物放到同一路径。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发用的 node 载体(见下文「对着 Node 源码运行」)。 ## 用可执行文件验证 SDK From 8fb70c7d46d831b0a8dcb7c9369ceb575130bb32 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:54:42 +0800 Subject: [PATCH 07/18] review: client-owned default-config injection; tar the bare exe artifact Address the three ds-review-bot warnings on #253: - An empty DSH_CORDIS_CONFIG now counts as absent when deciding whether to inject the bundled default config, matching the runtime bin's config-discovery semantics. - The injection moves from DeepSeekHarness into HarnessClient.start(), so the low-level client's default bundled launch also boots without callers duplicating the env setup. - The bare single-file exe artifact ships inside a tar.gz like the Python bundle: upload-artifact's zip transport drops the executable bit. --- .../workflows/build-exe-for-python-sdk.yml | 34 +++++++++---- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- .../src/deepseek_harness_runtime/__init__.py | 3 +- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- python/sdk/src/deepseek_harness/api.py | 24 ---------- python/sdk/src/deepseek_harness/client.py | 27 +++++++++++ python/sdk/tests/test_bundled_runtime.py | 17 ++++--- python/sdk/tests/test_client.py | 48 ++++++++++++++++++- 12 files changed, 118 insertions(+), 51 deletions(-) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 8e94476476..5e055a1ee5 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -8,14 +8,16 @@ name: Build single-exe # the dsh-jsonrpc-agent-pkg closure manifest with @yao-pkg/pkg into # dist-exe/. # -# Each build leg uploads two artifacts: +# Each build leg uploads two artifacts (both tar.gz — tar keeps file modes, +# so the exe's executable bit survives; upload-artifact's zip transport +# does not preserve them): # - dsh-jsonrpc-agent-pkg- — the bare single-file exe, for # consumers that want just the binary. -# - deepseek-harness-python- — the whole python/ directory as a -# tar.gz with that exe already embedded (the build script syncs it into -# the Python runtime package): unpack and both packages pip install -# as-is, the checked-in default runtime/cordis.yml is editable in -# place, and the embedded exe also runs directly. +# - deepseek-harness-python- — the whole python/ directory with +# that exe already embedded (the build script syncs it into the Python +# runtime package): unpack and both packages pip install as-is, the +# checked-in default runtime/cordis.yml is editable in place, and the +# embedded exe also runs directly. # # Two explicit triggers, deliberately no per-commit CI: the exe is a # release-style deliverable, and the build (full pnpm build + pnpm deploy + @@ -154,10 +156,24 @@ jobs: - name: Build single-exe run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} + # The bare exe ships inside a tar.gz (the mode-preservation note in + # the header): uploading dist-exe/ bare would hand consumers a 0644 + # file that subprocess.Popen refuses to run. + - name: Pack bare exe + id: pack-exe + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + exe_tar="dsh-jsonrpc-agent-pkg-${TARGET}.tar.gz" + tar -czf "$exe_tar" -C dist-exe . + ls -lh "$exe_tar" + echo "tar=$exe_tar" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v6 with: name: dsh-jsonrpc-agent-pkg-${{ matrix.target }} - path: dist-exe/ + path: ${{ steps.pack-exe.outputs.tar }} if-no-files-found: error # After the build step, python/ is already in its complete @@ -165,9 +181,7 @@ jobs: # python/sdk-runtime/src/deepseek_harness_runtime/runtime/ next to the # checked-in default cordis.yml — so packing is all that is left. # Everything goes under one top-level deepseek-harness-python/ - # directory so unpacking never scatters files. Shipping a tar (rather - # than uploading the tree bare) preserves the exe's executable bit — - # tar keeps file modes; upload-artifact's zip does not. + # directory so unpacking never scatters files. # # Excluded: runtime/node/ (dev-only node-mode carrier, ~140MB) plus # __pycache__ / .pytest_cache / .venv / node_modules anywhere (install diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index d82d6cfa12..6ab0e66ea5 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-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 -README.md: 5543b0ba00816c230dbe4e5bba11b7bf4b38096d -README.zh.md: 163b6d8e8373f0f770a5d95be688785907108b8a +README.md: 4cf48c55f40ec1c8632582546dcbfb990bbaa660 +README.zh.md: 0c82569be45b5cefa2ca025728945be1bdefc541 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 5543b0ba00..4cf48c55f4 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -13,7 +13,7 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. -Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow and place it in the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. +Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow (a tar.gz — tar preserves the executable bit) and unpack it into the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. ## Resolution API diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 163b6d8e83..0c82569be4 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -13,7 +13,7 @@ Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的 deploy root——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 -载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物并放入 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 +载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物(tar.gz——tar 保留可执行位)并解包到 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 ## 解析 API diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index bd14ed0211..fdaa08f4ef 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -36,7 +36,8 @@ _ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm _EXE_ACQUISITION_HINT = ( "Two ways to get the executable: run `scripts/build-exe-for-python-sdk.ts` (via tsx) in a " "deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` " - "CI workflow; place the file in this package's runtime/ directory. For local development " + "CI workflow (a tar.gz — tar preserves the executable bit) and unpack it into this package's " + "runtime/ directory. For local development " "against a repo source build, explicitly select the dev-only node carrier with " f"{RUNTIME_MODE_ENV_VAR}=node (or resolve_bundled_launch_args('node'))." ) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index c21759e4b7..3ee5e33a32 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 -README.md: 67dbd04520ac01318c1f21393c12b7bf4965b4cb -README.zh.md: 36ea4df4ff73e12ce3fda5bbee4108584dd5ad92 +README.md: ef9f8703f3bdb7b6d2dca28639f3b794083f7905 +README.zh.md: 31b6e87d7b75cc10cb799862fe6eb0f1f26dccd6 diff --git a/python/sdk/README.md b/python/sdk/README.md index 67dbd04520..ef9f8703f3 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -24,4 +24,4 @@ with DeepSeekHarness( `assistant/message` event in the turn. Use `TurnResult.events` for the complete event stream, including intermediate assistant messages and tool activity. -The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. If neither `cordis` nor `DSH_CORDIS_CONFIG` is set and the launch resolves to the bundled runtime, the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. +The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 36ea4df4ff..31b6e87d7b 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -18,4 +18,4 @@ with DeepSeekHarness( `TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 -同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。若 `cordis` 与 `DSH_CORDIS_CONFIG` 均未设置,且启动解析到内置运行时,则使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 +同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此低层客户端的默认启动同样享有它:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值当作缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index ab3b2f764e..dca522bf5d 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import uuid from dataclasses import dataclass, field from pathlib import Path @@ -60,8 +59,6 @@ class DeepSeekHarness: env["DSH_SESSION_ROOT"] = self.config.session_root if self.config.cordis is not None: env["DSH_CORDIS_CONFIG"] = self.config.cordis - else: - self._inject_bundled_default_config(env) env["DSH_CWD"] = cwd if self.config.base_url is not None: env["DEEPSEEK_BASE_URL"] = self.config.base_url @@ -109,27 +106,6 @@ class DeepSeekHarness: self._client.close() self._initialized = False - def _inject_bundled_default_config(self, env: dict[str, str]) -> None: - """Restore the zero-config experience over the config-mandatory bundled runtime. - - The bundled runtime (single-file exe or the dev-only node closure) - always demands an explicit config. When the caller neither provided - ``cordis`` nor selected a runtime explicitly (``runtime_bin`` / - ``launch_args_override``), and no ambient ``DSH_CORDIS_CONFIG`` exists, - inject the runtime package's checked-in default cordis.yml. With an - explicit runtime or config channel the SDK stays out of the way. - """ - uses_bundled_runtime = self.config.runtime_bin is None and self.config.launch_args_override is None - if not uses_bundled_runtime or "DSH_CORDIS_CONFIG" in env or "DSH_CORDIS_CONFIG" in os.environ: - return - try: - from deepseek_harness_runtime import bundled_default_config_path - except ImportError: - # Only the runtime package's absence reaches here; swallow it so - # HarnessClient.start() reports the actionable install error. - return - env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) - def start_session(self, session_id: str | None = None) -> "Session": self.start() return Session(self, session_id or f"session-{uuid.uuid4().hex}") diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 541c1b47c8..5a6c28d3a2 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -67,6 +67,7 @@ class HarnessClient: env = os.environ.copy() if self.config.env: env.update(self.config.env) + self._inject_bundled_default_config(env) self._proc = subprocess.Popen( args, stdin=subprocess.PIPE, @@ -411,6 +412,32 @@ class HarnessClient: ) from exc return resolve_bundled_launch_args() + def _inject_bundled_default_config(self, env: dict[str, str]) -> None: + """Restore the zero-config experience over the config-mandatory bundled runtime. + + The bundled runtime (single-file exe or the dev-only node closure) + always demands an explicit config. When the launch resolves to the + bundled runtime (no ``runtime_bin`` / ``bridge_bin`` / + ``launch_args_override``) and the merged subprocess environment has no + non-empty ``DSH_CORDIS_CONFIG`` — the runtime bin treats an empty + value as absent, so this does too — inject the runtime package's + checked-in default cordis.yml. With an explicit runtime or config + channel the client stays out of the way. + """ + uses_bundled_runtime = ( + self.config.launch_args_override is None + and self.config.runtime_bin is None + and self.config.bridge_bin is None + ) + if not uses_bundled_runtime or env.get("DSH_CORDIS_CONFIG"): + return + # Cannot fail: _default_launch_args() already imported the runtime + # package on this (bundled) path, raising the actionable install + # error when it is absent. + from deepseek_harness_runtime import bundled_default_config_path + + env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) + def _unsubscribe_notifications(self, subscription_id: str) -> None: with self._lock: self._notification_subscribers.pop(subscription_id, None) diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 51dfb668d8..a55c6c2c6a 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -17,7 +17,7 @@ import pytest from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig from deepseek_harness.errors import TransportClosedError -from deepseek_harness_runtime import bundled_default_config_path, resolve_bundled_launch_args +from deepseek_harness_runtime import resolve_bundled_launch_args _MODES = ("exe", "node") @@ -99,12 +99,16 @@ def test_bundled_runtime_surfaces_unbundled_plugin_failure(tmp_path: Path, mode: @pytest.mark.parametrize("mode", _MODES) +@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) def test_zero_config_run_injects_bundled_default_cordis_config( - tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, mode: str, ambient_config: str | None, monkeypatch: pytest.MonkeyPatch ) -> None: _launch_args(mode) # skip early when this carrier is unavailable monkeypatch.setenv("DSH_RUNTIME_MODE", mode) - monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + if ambient_config is None: + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + else: + monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) harness = DeepSeekHarness( model="deepseek-v4-pro", @@ -114,7 +118,8 @@ def test_zero_config_run_injects_bundled_default_cordis_config( base_url="http://127.0.0.1:9", request_timeout_seconds=120, ) - assert harness.client.config.env is not None - assert harness.client.config.env["DSH_CORDIS_CONFIG"] == str(bundled_default_config_path()) with harness: - pass # __enter__ boots the runtime on the injected default config; __exit__ shuts it down + # __enter__ boots the runtime, which exits with a usage error unless + # HarnessClient.start() injected the bundled default config over the + # unset/empty DSH_CORDIS_CONFIG; __exit__ shuts it down. + pass diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 3b12a28865..08fa13a331 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -548,13 +548,23 @@ with open(os.environ["SEEN"], "w") as seen: json.loads(line) -def test_client_uses_bundled_runtime_package_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def _install_fake_bundled_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Path: + """Fake the deepseek-harness-runtime-bin package on sys.path. + + A stub exe that dumps DSH_CORDIS_CONFIG to $ENV_DUMP before serving + initialize/shutdown, plus a module exposing the resolution surface the + client consumes. Returns the fake bundled default config path. + """ runtime = tmp_path / "dsh-jsonrpc-agent" runtime.write_text( """#!/usr/bin/env python3 import json +import os import sys +json.dump({"DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG")}, open(os.environ["ENV_DUMP"], "w")) for line in sys.stdin: msg = json.loads(line) if msg.get("method") == "initialize": @@ -566,22 +576,56 @@ for line in sys.stdin: ) runtime.chmod(0o755) + default_config = tmp_path / "default-cordis.yml" module_dir = tmp_path / "deepseek_harness_runtime" module_dir.mkdir() (module_dir / "__init__.py").write_text( f""" def resolve_bundled_launch_args(mode=None): return ({str(runtime)!r},) + + +def bundled_default_config_path(): + return {str(default_config)!r} """.strip() ) monkeypatch.syspath_prepend(str(tmp_path)) monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) + return default_config - with HarnessClient() as client: + +@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) +def test_client_default_launch_uses_bundled_runtime_and_injects_default_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ambient_config: str | None +) -> None: + env_dump = tmp_path / "env.json" + default_config = _install_fake_bundled_runtime(tmp_path, monkeypatch) + if ambient_config is None: + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + else: + monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) + + with HarnessClient(HarnessConfig(env={"ENV_DUMP": str(env_dump)})) as client: init = client.initialize(cwd="/workspace", model="deepseek-v4-pro") assert init.serverInfo.name == "bundled-runtime" + assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == str(default_config) + + +def test_client_respects_explicit_config_over_bundled_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + env_dump = tmp_path / "env.json" + _install_fake_bundled_runtime(tmp_path, monkeypatch) + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + + with HarnessClient( + HarnessConfig(env={"ENV_DUMP": str(env_dump), "DSH_CORDIS_CONFIG": "./explicit.yml"}) + ) as client: + client.initialize(cwd="/workspace", model="deepseek-v4-pro") + + assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == "./explicit.yml" def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.MonkeyPatch) -> None: From 7696f88d9f591fb234515ec7cd6b8674b0604331 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:57:12 +0800 Subject: [PATCH 08/18] jsonrpc: adapt to async agent creation and scoped subagent events --- packages/ui/jsonrpc/src/server.ts | 32 ++++++- packages/ui/jsonrpc/tests/server.spec.ts | 109 ++++++++++++++++++++--- 2 files changed, 126 insertions(+), 15 deletions(-) diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 1b403aac49..ac0f10f03c 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -84,8 +84,11 @@ export class HarnessSdkServer { private model = 'deepseek' private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() + private readonly sessionCreations = new Map>() private readonly subagentSessions = new Map() private readonly disposers: (() => void)[] = [] + private shutdownTask: Promise> | undefined + private shuttingDown = false constructor( private readonly ctx: Context, @@ -161,7 +164,7 @@ export class HarnessSdkServer { * @returns `{ accepted: true }` after the turn settled. */ async prompt(params: SessionPromptParams): Promise { - const rec = this.getOrCreateSession(params.sessionId) + const rec = await this.getOrCreateSession(params.sessionId) rec.lastTurnEnd = undefined rec.handle.agent.send(params.contentBlocks) await rec.handle.agent.whenIdle() @@ -181,7 +184,15 @@ export class HarnessSdkServer { * as part of process exit. * @returns an empty object (the JSON-RPC result). */ - async shutdown(): Promise> { + shutdown(): Promise> { + this.shutdownTask ??= this.performShutdown() + return this.shutdownTask + } + + private async performShutdown(): Promise> { + this.shuttingDown = true + const pendingCreations = [...this.sessionCreations.values()] + await Promise.allSettled(pendingCreations) const records = [...this.sessions.values()] this.sessions.clear() await Promise.all(records.map(rec => rec.handle.dispose())) @@ -211,10 +222,23 @@ export class HarnessSdkServer { } } - private getOrCreateSession(sessionId: string): SessionRecord { + private async getOrCreateSession(sessionId: string): Promise { + if (this.shuttingDown) throw new Error('SDK server is shutting down') const existing = this.sessions.get(sessionId) if (existing) return existing - const handle = this.ctx.agents.create({ + const pending = this.sessionCreations.get(sessionId) + if (pending) return pending + const creation = this.createSession(sessionId) + this.sessionCreations.set(sessionId, creation) + void creation.then( + () => { this.sessionCreations.delete(sessionId) }, + () => { this.sessionCreations.delete(sessionId) }, + ) + return creation + } + + private async createSession(sessionId: string): Promise { + const handle = await this.ctx.agents.create({ agentId: AgentId(sessionId), sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index a3ea6e3c1a..6b04f4b913 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -5,11 +5,12 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import SubagentService, { type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { @@ -58,11 +59,41 @@ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[ async function makeHarness(storageDir: string) { const ctx = new Context() await ctx.plugin(agentCore) + await ctx.plugin(SubagentService) await ctx.plugin(SessionPersistenceJsonl, { root: storageDir }) await new Promise(resolve => setTimeout(resolve, 50)) return ctx } +/** Drive the owning service so test lifecycle events carry the real parent scope. */ +async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndInfo): Promise { + const disposeProvider = ctx.subagents.registerProvider({ + name: info.provider, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + async start() { + return { + id: info.id, + result: info.lastAssistantMessage === undefined + ? Promise.reject(new Error('synthetic infrastructure failure')) + : Promise.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }), + dispose: () => Promise.resolve(), + } + }, + }) + try { + const run = await ctx.subagents.start(info.provider, { + parent, + prompt: [], + signal: new AbortController().signal, + }) + await run.result.then(() => undefined, () => undefined) + await run.dispose() + } finally { + disposeProvider() + } +} + describe('HarnessSdkServer', () => { it('creates a harness agent and calls the configured OpenAI-compatible endpoint', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-')) @@ -106,7 +137,7 @@ describe('HarnessSdkServer', () => { }) expect(llmServer.requests).toHaveLength(2) - const orphanHandle = ctx.agents.create({ + const orphanHandle = await ctx.agents.create({ agentId: AgentId('orphan-agent'), sessionId: SessionId('orphan-session'), meta: { cwd: storageDir }, @@ -183,12 +214,19 @@ describe('HarnessSdkServer', () => { const transport = new FakeTransport() const server = new HarnessSdkServer(ctx, transport) - const handle = ctx.agents.create({ + const parentHandle = await ctx.agents.create({ + agentId: AgentId('parent-agent'), + sessionId: SessionId('main'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const handle = await ctx.agents.create({ agentId: AgentId('child-agent'), sessionId: SessionId('child-session'), meta: { cwd: storageDir, parentSession: SessionId('main') }, + agentOptions: { model: 'deepseek' }, }) - ctx.emit('subagent/end', { + await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: AgentId('child-agent'), stopReason: 'completed', @@ -209,6 +247,7 @@ describe('HarnessSdkServer', () => { }) await handle.dispose() + await parentHandle.dispose() await server.shutdown() } finally { await ctx.fiber.dispose() @@ -219,33 +258,43 @@ describe('HarnessSdkServer', () => { it('falls back to live agent lineage for uncached subagent end events', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) - let handle: ReturnType | undefined - let failedHandle: ReturnType | undefined + let parentHandle: AgentHandle | undefined + let handle: AgentHandle | undefined + let failedHandle: AgentHandle | undefined try { - handle = ctx.agents.create({ + parentHandle = await ctx.agents.create({ + agentId: AgentId('fallback-parent-agent'), + sessionId: SessionId('fallback-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + handle = await ctx.agents.create({ agentId: AgentId('fallback-child-agent'), sessionId: SessionId('fallback-child-session'), meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, + agentOptions: { model: 'deepseek' }, }) - failedHandle = ctx.agents.create({ + failedHandle = await ctx.agents.create({ agentId: AgentId('failed-child-agent'), sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, }) const transport = new FakeTransport() const server = new HarnessSdkServer(ctx, transport) - ctx.emit('subagent/end', { + await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', id: AgentId('fallback-child-agent'), stopReason: 'max-tokens', + lastAssistantMessage: [], }) - ctx.emit('subagent/end', { + await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', id: AgentId('failed-child-agent'), stopReason: 'error', }) - ctx.emit('subagent/end', { + await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', id: AgentId('missing-child-agent'), stopReason: 'error', @@ -260,6 +309,7 @@ describe('HarnessSdkServer', () => { childSessionId: 'fallback-child-session', status: 'ok', stopReason: 'max-tokens', + lastAssistantMessage: [], }, }) expect(transport.notifications).toContainEqual({ @@ -281,6 +331,7 @@ describe('HarnessSdkServer', () => { } finally { await handle?.dispose() await failedHandle?.dispose() + await parentHandle?.dispose() await ctx.fiber.dispose() await rm(storageDir, { recursive: true, force: true }) } @@ -375,4 +426,40 @@ describe('HarnessSdkServer', () => { await rm(storageDir, { recursive: true, force: true }) } }) + + it('coalesces concurrent session creation and retries a failed creation', async () => { + let resolveShared: ((handle: AgentHandle) => void) | undefined + const sharedCreation = new Promise((resolve) => { resolveShared = resolve }) + const sharedHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) } + const retryHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) } + const create = vi.fn<(options: unknown) => Promise>() + .mockReturnValueOnce(sharedCreation) + .mockRejectedValueOnce(new Error('creation failed')) + .mockResolvedValueOnce(retryHandle) + const ctx = { + on: vi.fn(() => () => undefined), + agents: { create, get: () => undefined }, + get: () => undefined, + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + getOrCreateSession(sessionId: string): Promise<{ handle: AgentHandle }> + shutdown(): Promise> + } + + const first = server.getOrCreateSession('shared') + const second = server.getOrCreateSession('shared') + expect(create).toHaveBeenCalledTimes(1) + resolveShared?.(sharedHandle) + const [firstRecord, secondRecord] = await Promise.all([first, second]) + expect(firstRecord).toBe(secondRecord) + + await expect(server.getOrCreateSession('retry')).rejects.toThrow('creation failed') + await expect(server.getOrCreateSession('retry')).resolves.toMatchObject({ handle: retryHandle }) + expect(create).toHaveBeenCalledTimes(3) + + await server.shutdown() + expect(sharedHandle.dispose).toHaveBeenCalledOnce() + expect(retryHandle.dispose).toHaveBeenCalledOnce() + await expect(server.getOrCreateSession('after-shutdown')).rejects.toThrow('SDK server is shutting down') + }) }) From 5d913a796a21b808e086847f205aeed4e88e6468 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 16:34:03 +0800 Subject: [PATCH 09/18] jsonrpc: harden Python SDK lifecycle and protocol --- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 42 +++++----- packages/ui/jsonrpc/tests/server.spec.ts | 66 +++++++++++++-- python/sdk/src/deepseek_harness/api.py | 19 ++--- python/sdk/src/deepseek_harness/client.py | 40 ++++------ python/sdk/tests/manual_sdk_agent_smoke.py | 2 - python/sdk/tests/test_client.py | 93 +++++++++++++++++++++- 7 files changed, 198 insertions(+), 66 deletions(-) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index dbc3fddcd0..95ebdb0b00 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -20,4 +20,4 @@ The plugin owns the PROTOCOL-level exit: a `shutdown` request is answered first ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). The `initialize` params `sessionRoot`, `systemPrompt`, and `clientInfo`, and the `session/prompt` param `profile`, are accepted for wire compatibility but currently unused — persistence roots and the deployment persona come from the `cordis.yml` (see the TODO in [`src/server.ts`](src/server.ts)). +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). Persistence roots and the deployment persona come from `cordis.yml`; the wire exposes only parameters the server applies. diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index ac0f10f03c..1b193aaf1e 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -13,6 +13,7 @@ */ import type { Context } from 'cordis' +import { resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { AgentId } from '@deepseek-ai/dsh-agent' @@ -27,17 +28,6 @@ export interface InitializeParams { cwd: string /** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */ model: string - /** Accepted for SDK wire compatibility; unused — persistence roots come from the `cordis.yml`. */ - sessionRoot?: string - /** - * Accepted for SDK wire compatibility; currently NOT applied — the deployment - * persona comes from the `cordis.yml` system-prompt config. TODO(jsonrpc): - * map this onto a per-runtime system-prompt section once a per-agent override - * seam exists. - */ - systemPrompt?: string - /** Accepted for SDK wire compatibility; unused diagnostic client identity. */ - clientInfo?: { name?: string; version?: string } } /** Result of the `initialize` request: the server's identity for the SDK handshake. */ @@ -52,8 +42,6 @@ export interface SessionPromptParams { sessionId: string /** The prompt content blocks, sent verbatim as the user message. */ contentBlocks: ContentBlock[] - /** Accepted for SDK wire compatibility; unused — profiles are not a harness concept. */ - profile?: string } /** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */ @@ -132,7 +120,7 @@ export class HarnessSdkServer { agentId: String(info.id), ...(parentSessionId === undefined ? {} : { parentSessionId }), childSessionId, - status: info.stopReason === 'completed' || info.stopReason === 'max-tokens' ? 'ok' : 'error', + status: info.stopReason === 'completed' ? 'ok' : 'error', stopReason: info.stopReason, ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), }) @@ -148,7 +136,7 @@ export class HarnessSdkServer { * @returns the server identity for the handshake. */ async initialize(params: InitializeParams): Promise { - this.cwd = params.cwd + this.cwd = resolve(params.cwd) this.model = params.model if (!this.llmFiber && !this.hasAdapterFor(this.model)) { this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { models: [this.model] }) @@ -193,12 +181,28 @@ export class HarnessSdkServer { this.shuttingDown = true const pendingCreations = [...this.sessionCreations.values()] await Promise.allSettled(pendingCreations) + this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() - await Promise.all(records.map(rec => rec.handle.dispose())) - await this.llmFiber?.dispose() + this.subagentSessions.clear() + const failures: unknown[] = [] + while (this.disposers.length > 0) { + try { + this.disposers.pop()?.() + } catch (error) { + failures.push(error) + } + } + const teardownResults = await Promise.allSettled([ + ...records.map(rec => Promise.resolve().then(() => rec.handle.dispose())), + ...(this.llmFiber === undefined ? [] : [Promise.resolve().then(() => this.llmFiber?.dispose())]), + ]) this.llmFiber = undefined - while (this.disposers.length > 0) this.disposers.pop()?.() + failures.push(...teardownResults + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'SDK server teardown failed') return {} } @@ -251,7 +255,7 @@ export class HarnessSdkServer { private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' { if (!reason) return 'error' - return reason.kind === 'completed' || reason.kind === 'max-tokens' ? 'ok' : 'error' + return reason.kind === 'completed' ? 'ok' : 'error' } private hasAdapterFor(model: string): boolean { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 6b04f4b913..8d4d791841 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -108,15 +108,12 @@ describe('HarnessSdkServer', () => { const init = await server.handleRequest('initialize', { cwd: storageDir, model: 'dsagent-model', - sessionRoot: storageDir, - systemPrompt: 'Custom SDK instructions.', }) as { serverInfo: { name: string } } expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime') await server.handleRequest('session/prompt', { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }], - profile: 'build', }) expect(llmServer.requests).toHaveLength(1) @@ -307,7 +304,7 @@ describe('HarnessSdkServer', () => { agentId: 'fallback-child-agent', parentSessionId: 'fallback-parent', childSessionId: 'fallback-child-session', - status: 'ok', + status: 'error', stopReason: 'max-tokens', lastAssistantMessage: [], }, @@ -386,7 +383,7 @@ describe('HarnessSdkServer', () => { } expect(server.finishedStatus(undefined)).toBe('error') - expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok') + expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('error') expect(server.finishedStatus({ kind: 'error' })).toBe('error') await server.shutdown() } finally { @@ -462,4 +459,63 @@ describe('HarnessSdkServer', () => { expect(retryHandle.dispose).toHaveBeenCalledOnce() await expect(server.getOrCreateSession('after-shutdown')).rejects.toThrow('SDK server is shutting down') }) + + it('resolves a relative cwd before creating the session', async () => { + const create = vi.fn<(options: unknown) => Promise>() + .mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() }) + const ctx = { + on: vi.fn(() => () => undefined), + agents: { create, get: () => undefined }, + get: () => ({ models: () => ['model'] }), + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + initialize(params: { cwd: string; model: string }): Promise + getOrCreateSession(sessionId: string): Promise + shutdown(): Promise> + } + + await server.initialize({ cwd: '.', model: 'model' }) + await server.getOrCreateSession('relative') + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } })) + await server.shutdown() + }) + + it('settles every teardown and aggregates multiple failures', async () => { + const firstDispose = vi.fn(() => { throw new Error('first teardown failed') }) + const secondDispose = vi.fn(() => Promise.reject(new Error('second teardown failed'))) + const ctx = { + on: vi.fn(() => () => undefined), + agents: { create: vi.fn(), get: () => undefined }, + get: () => undefined, + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + sessions: Map + shutdown(): Promise> + } + server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined }) + server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined }) + + await expect(server.shutdown()).rejects.toThrow('SDK server teardown failed') + expect(firstDispose).toHaveBeenCalledOnce() + expect(secondDispose).toHaveBeenCalledOnce() + }) + + it('continues teardown after a subscription disposer fails', async () => { + let subscription = 0 + const listenerFailure = new Error('listener teardown failed') + const on = vi.fn(() => { + subscription += 1 + return subscription === 1 ? () => { throw listenerFailure } : () => undefined + }) + const ctx = { + on, + agents: { create: vi.fn(), get: () => undefined }, + get: () => undefined, + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) + + await expect(server.shutdown()).rejects.toBe(listenerFailure) + expect(on).toHaveBeenCalledTimes(4) + }) }) diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index dca522bf5d..c45a70ee33 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -23,14 +23,11 @@ class DeepSeekHarnessConfig: runtime_cwd: str | None = None session_root: str | None = None cordis: str | None = None - system_prompt: str | None = None env: dict[str, str] = field(default_factory=dict) runtime_bin: str | None = None launch_args_override: tuple[str, ...] | None = None request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 - client_name: str = "deepseek_harness_python_sdk" - client_version: str = "0.0.0-dev" base_url: str | None = None api_key: str | None = None @@ -52,8 +49,9 @@ class DeepSeekHarness: if config is not None and kwargs: raise TypeError("pass either DeepSeekHarnessConfig or keyword options, not both") self.config = config or DeepSeekHarnessConfig(**kwargs) - cwd = self.config.cwd or str(Path.cwd()) - runtime_cwd = self.config.runtime_cwd or cwd + cwd = str(Path(self.config.cwd or Path.cwd()).resolve()) + runtime_cwd = str(Path(self.config.runtime_cwd).resolve()) if self.config.runtime_cwd is not None else cwd + self._cwd = cwd env = dict(self.config.env) if self.config.session_root is not None: env["DSH_SESSION_ROOT"] = self.config.session_root @@ -73,8 +71,6 @@ class DeepSeekHarness: env=env, request_timeout_seconds=self.config.request_timeout_seconds, shutdown_timeout_seconds=self.config.shutdown_timeout_seconds, - client_name=self.config.client_name, - client_version=self.config.client_version, ) ) self._initialized = False @@ -95,10 +91,8 @@ class DeepSeekHarness: return self._client.start() self._client.initialize( - cwd=self.config.cwd or str(Path.cwd()), + cwd=self._cwd, model=self.config.model, - session_root=self.config.session_root, - system_prompt=self.config.system_prompt, ) self._initialized = True @@ -115,10 +109,9 @@ class DeepSeekHarness: input: str | list[JsonObject], *, session_id: str | None = None, - profile: str | None = None, on_notification: Callable[[Notification], None] | None = None, ) -> TurnResult: - return self.start_session(session_id).run(input, profile=profile, on_notification=on_notification) + return self.start_session(session_id).run(input, on_notification=on_notification) class Session: @@ -130,7 +123,6 @@ class Session: self, input: str | list[JsonObject], *, - profile: str | None = None, on_notification: Callable[[Notification], None] | None = None, ) -> TurnResult: content_blocks = normalize_input(input) @@ -156,7 +148,6 @@ class Session: self.harness.client.session_prompt( self.id, content_blocks, - profile=profile, on_notification=collect, notification_subscription=subscription, ) diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 5a6c28d3a2..e81dd6f30a 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -9,6 +9,7 @@ import time import uuid from collections import deque from dataclasses import dataclass +from pathlib import Path from typing import Callable, Literal, TypeAlias, TypeVar from pydantic import BaseModel @@ -31,8 +32,6 @@ class HarnessConfig: env: dict[str, str] | None = None request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 - client_name: str = "deepseek_harness_python_sdk" - client_version: str = "0.0.0-dev" class HarnessClient: @@ -75,7 +74,7 @@ class HarnessClient: stderr=subprocess.PIPE, text=True, encoding="utf-8", - cwd=self.config.cwd, + cwd=None if self.config.cwd is None else str(Path(self.config.cwd).resolve()), env=env, bufsize=1, ) @@ -90,18 +89,22 @@ class HarnessClient: self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds) except Exception as exc: self._stderr_lines.append(f"shutdown request failed: {exc}") - self._proc = None if proc.stdin: try: proc.stdin.close() except Exception as exc: self._stderr_lines.append(f"stdin close failed: {exc}") - try: - if proc.poll() is None: + if proc.poll() is None: + try: proc.terminate() - proc.wait(timeout=2) - except Exception: + except ProcessLookupError: + pass + try: + proc.wait(timeout=self.config.shutdown_timeout_seconds) + except subprocess.TimeoutExpired: proc.kill() + proc.wait() + self._proc = None self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed")) if self._reader_thread and self._reader_thread.is_alive(): self._reader_thread.join(timeout=0.5) @@ -113,35 +116,26 @@ class HarnessClient: *, cwd: str, model: str, - session_root: str | None = None, - system_prompt: str | None = None, ) -> InitializeResponse: payload: JsonObject = { - "clientInfo": { - "name": self.config.client_name, - "version": self.config.client_version, - }, - "cwd": cwd, + "cwd": str(Path(cwd).resolve()), "model": model, } - if session_root is not None: - payload["sessionRoot"] = session_root - if system_prompt is not None: - payload["systemPrompt"] = system_prompt - return self.request("initialize", payload, response_model=InitializeResponse) + try: + return self.request("initialize", payload, response_model=InitializeResponse) + except BaseException: + self.close() + raise def session_prompt( self, session_id: str, content_blocks: list[JsonObject], *, - profile: str | None = None, on_notification: Callable[[Notification], None] | None = None, notification_subscription: "NotificationSubscription | None" = None, ) -> None: payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks} - if profile is not None: - payload["profile"] = profile self.request( "session/prompt", payload, diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index 81ee36cfc9..d259d7dab7 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -66,7 +66,6 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: runtime_cwd=str(repo_root), session_root=str(session_root), cordis=str(bundled_default_config_path()), - system_prompt="You are running a Python SDK smoke test.", launch_args_override=("node", "--import", "tsx", str(runtime_entry)), env={ "DEEPSEEK_BASE_URL": base_url, @@ -78,7 +77,6 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: result = harness.run( "Please reply with a short confirmation and do not call tools.", session_id="sdk-smoke-main", - profile="build", ) print(f"turn_status={result.status}") print(f"final_response={result.final_response}") diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 08fa13a331..0f66ee92e8 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import inspect import sys import threading import time @@ -121,6 +122,45 @@ for line in sys.stdin: assert seen == ["subagent.started", "session.finished"] +def test_relative_cwd_is_absolute_in_process_environment_and_wire( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + script = tmp_path / "capture_cwd.py" + capture = tmp_path / "cwd.json" + script.write_text( + """ +import json +import os +import sys + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + json.dump({"process": os.getcwd(), "environment": os.environ.get("DSH_CWD"), "wire": msg["params"]["cwd"]}, open(os.environ["CAPTURE"], "w")) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + monkeypatch.chdir(tmp_path) + + with DeepSeekHarness( + cwd=".", + runtime_cwd=".", + launch_args_override=(sys.executable, str(script)), + env={"CAPTURE": str(capture)}, + ): + pass + + expected = str(tmp_path.resolve()) + assert json.loads(capture.read_text()) == { + "process": expected, + "environment": expected, + "wire": expected, + } + + def test_session_run_includes_subagent_finished_for_parent_session(tmp_path: Path) -> None: script = tmp_path / "fake_runtime.py" script.write_text( @@ -293,7 +333,7 @@ for line in sys.stdin: init = client.initialize(cwd="/workspace", model="dsagent") assert init.serverInfo.name == "fake-dsh" - client.session_prompt("main", [{"type": "text", "text": "fix it"}], profile="build") + client.session_prompt("main", [{"type": "text", "text": "fix it"}]) notification = client.next_notification() assert notification.method == "llm/request" assert notification.payload["requestId"] == "req-1" @@ -339,7 +379,7 @@ for line in sys.stdin: with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: client.initialize(cwd="/workspace", model="dsagent") with pytest.raises(ValueError): - client.session_prompt("main", [{"type": "text", "text": "fix it"}], profile="build") + client.session_prompt("main", [{"type": "text", "text": "fix it"}]) def test_client_routes_bridge_requests_and_sends_responses(tmp_path: Path) -> None: @@ -434,9 +474,12 @@ def test_client_close_times_out_when_shutdown_does_not_respond(tmp_path: Path) - script.write_text( """ import json +import signal import sys import time +signal.signal(signal.SIGTERM, signal.SIG_IGN) + for line in sys.stdin: msg = json.loads(line) if msg.get("method") == "initialize": @@ -453,10 +496,56 @@ for line in sys.stdin: ) ) client.start() + proc = client._proc + assert proc is not None client.initialize(cwd="/workspace", model="dsagent") start = time.monotonic() client.close() assert time.monotonic() - start < 2 + assert proc.poll() is not None + assert client._proc is None + + +def test_initialize_failure_reaps_started_runtime(tmp_path: Path) -> None: + script = tmp_path / "rejecting_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32000, "message": "bad initialize"}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) + client.start() + proc = client._proc + assert proc is not None + + with pytest.raises(Exception, match="bad initialize"): + client.initialize(cwd=".", model="dsagent") + + assert proc.wait(timeout=1) is not None + assert client._proc is None + + +def test_public_signatures_omit_unsupported_wire_parameters() -> None: + from deepseek_harness import DeepSeekHarnessConfig, Session + + assert "session_root" not in inspect.signature(HarnessClient.initialize).parameters + assert "system_prompt" not in inspect.signature(HarnessClient.initialize).parameters + assert "profile" not in inspect.signature(HarnessClient.session_prompt).parameters + assert "profile" not in inspect.signature(DeepSeekHarness.run).parameters + assert "profile" not in inspect.signature(Session.run).parameters + assert "system_prompt" not in DeepSeekHarnessConfig.__dataclass_fields__ + assert "client_name" not in HarnessConfig.__dataclass_fields__ + assert "client_version" not in HarnessConfig.__dataclass_fields__ def test_client_close_is_idempotent_before_and_after_start(tmp_path: Path) -> None: From 283ac7f09723f25496df90ecedf8b0ac7d7e71e4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 16:34:09 +0800 Subject: [PATCH 10/18] python: close runtime packaging and platform wheels --- package.json | 3 +- pnpm-lock.yaml | 18 ++++ python/sdk-runtime/hatch_build.py | 60 +++++++++++ python/sdk-runtime/package.json | 6 ++ python/sdk-runtime/pyproject.toml | 6 +- python/sdk/pyproject.toml | 4 +- scripts/build-exe-for-python-sdk.ts | 13 +++ scripts/build-python-release.py | 160 ++++++++++++++++++++++++++++ scripts/run-gates.ts | 3 + scripts/verify-runtime-closure.ts | 116 ++++++++++++++++++++ 10 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 python/sdk-runtime/hatch_build.py create mode 100644 scripts/build-python-release.py create mode 100644 scripts/verify-runtime-closure.ts diff --git a/package.json b/package.json index ed0b603feb..cdc78f1828 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", + "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "gen-rfc-index": "tsx scripts/gen-rfc-index.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", @@ -64,7 +65,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 164bcbb17f..c2e74d552b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1706,6 +1706,12 @@ importers: '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../packages/core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -1718,6 +1724,12 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../packages/skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../packages/skill/skill-local '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent @@ -1757,6 +1769,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../packages/skill/tool-skill '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent @@ -1772,6 +1787,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../packages/core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../packages/ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py new file mode 100644 index 0000000000..1c5b22e11a --- /dev/null +++ b/python/sdk-runtime/hatch_build.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import os +import platform +import stat +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +_PLATFORMS = { + "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), + "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), + "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), +} + + +def _host_platform_tag() -> str: + machine = platform.machine().lower() + arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine + system = platform.system().lower() + key = f"macos-{arch}" if system == "darwin" else f"linux-{arch}" if system == "linux" else system + try: + return _PLATFORMS[key][0] + except KeyError as exc: + raise RuntimeError(f"unsupported deepseek-harness-runtime-bin build platform: {key}") from exc + + +class RuntimeBuildHook(BuildHookInterface): + """Assign the native wheel tag and reject incomplete or mixed-platform payloads.""" + + def initialize(self, version: str, build_data: dict[str, object]) -> None: + if version == "editable": + return + if self.target_name == "sdist": + raise RuntimeError( + "deepseek-harness-runtime-bin is wheel-only; build and publish platform wheels only." + ) + + platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag() + matches = [value for value in _PLATFORMS.values() if value[0] == platform_tag] + if len(matches) != 1: + supported = ", ".join(value[0] for value in _PLATFORMS.values()) + raise RuntimeError( + f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" + ) + expected_executable = matches[0][1] + runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" + executables = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + if [path.name for path in executables] != [expected_executable]: + found = ", ".join(path.name for path in executables) or "none" + raise RuntimeError( + f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" + ) + if executables[0].stat().st_mode & stat.S_IXUSR == 0: + raise RuntimeError(f"runtime executable is not executable: {executables[0]}") + + build_data["pure_python"] = False + build_data["infer_tag"] = False + build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 7f179f3941..76e6bf157f 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -33,10 +33,14 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", @@ -50,11 +54,13 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-local": "workspace:^", diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index ed1e147494..d0f1d97bf5 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "deepseek-harness-runtime-bin" -version = "0.0.0-dev" +version = "0.0.0.dev0" description = "Pinned DeepSeek Harness runtime for the Python SDK" readme = "README.md" requires-python = ">=3.10" @@ -19,3 +19,7 @@ exclude = ["src/deepseek_harness_runtime/runtime/node"] [tool.hatch.build.targets.wheel] packages = ["src/deepseek_harness_runtime"] + +[tool.hatch.build.targets.wheel.hooks.custom] + +[tool.hatch.build.targets.sdist.hooks.custom] diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index 68760f7c61..859a2d6282 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "hatchling.build" [project] name = "deepseek-harness" -version = "0.0.0-dev" +version = "0.0.0.dev0" description = "Python SDK for DeepSeek Harness" readme = "README.md" requires-python = ">=3.10" license = { text = "BSD-3-Clause" } dependencies = [ "pydantic>=2.12", - "deepseek-harness-runtime-bin==0.0.0-dev", + "deepseek-harness-runtime-bin==0.0.0.dev0", ] [dependency-groups] diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 0cc7b0c731..6f29438d42 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -81,6 +81,8 @@ const OUT_DIR = 'dist-exe' const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' /** Subdir of {@link PYTHON_RUNTIME_DIR} carrying the staged closure for node-mode execution. */ const PYTHON_NODE_SUBDIR = 'node' +/** Deploy-root documentation is not runtime input and violates the generated-directory i18n exclusion if retained. */ +const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] /** * Whole-tree asset globs. The cordis Loader dynamic-imports bare package names @@ -295,6 +297,11 @@ class SingleExeBuild { constructor(private readonly cli: BuildCli) {} + /** Gate the manifest before spending time compiling or packaging it. */ + async verifyClosure(): Promise { + await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure']) + } + /** Step 1: `pnpm run build` — all packages emit `lib/` (skipped via --skip-build). */ async build(): Promise { if (this.cli.skipBuild) { @@ -322,6 +329,11 @@ class SingleExeBuild { '--config.link-workspace-packages=true', this.staging, ]) + if (this.cli.dryRun) { + for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) + } else { + await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true }))) + } } /** Step 3: patch the staged package.json with the bin entry + pkg asset globs. */ @@ -445,6 +457,7 @@ async function main(): Promise { const pipeline = new SingleExeBuild(cli) console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`) console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`) + await pipeline.verifyClosure() await pipeline.build() await pipeline.deployStaging() await pipeline.injectPkgConfig() diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py new file mode 100644 index 0000000000..a196973a53 --- /dev/null +++ b/scripts/build-python-release.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Stage and build one Python release wheel from a stable ``python-vX.Y.Z`` tag.""" + +from __future__ import annotations + +import argparse +import email +import os +import re +import shutil +import stat +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PLATFORMS = { + "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), + "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), + "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), +} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", choices=("sdk", "runtime"), required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--platform", choices=tuple(PLATFORMS)) + parser.add_argument("--runtime-exe", type=Path) + args = parser.parse_args() + version = version_from_tag(args.tag) + if args.package == "runtime" and (args.platform is None or args.runtime_exe is None): + parser.error("runtime builds require --platform and --runtime-exe") + if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None): + parser.error("SDK builds do not accept --platform or --runtime-exe") + + output_dir = args.output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary: + staging = Path(temporary) / args.package + if args.package == "sdk": + stage_sdk(staging, version) + environment = None + expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl" + else: + platform_tag, executable_name = PLATFORMS[args.platform] + stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name) + environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag} + expected = output_dir / f"deepseek_harness_runtime_bin-{version}-py3-none-{platform_tag}.whl" + command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)] + subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True) + if not expected.is_file(): + raise RuntimeError(f"build did not produce expected wheel: {expected}") + verify_wheel(expected, args.package, version, None if args.platform is None else PLATFORMS[args.platform]) + print(expected) + + +def version_from_tag(tag: str) -> str: + match = re.fullmatch(r"python-v(\d+\.\d+\.\d+)", tag) + if match is None: + raise ValueError(f"release tag must match python-vX.Y.Z, got {tag!r}") + return match.group(1) + + +def copy_package(source: Path, destination: Path) -> None: + shutil.copytree( + source, + destination, + ignore=shutil.ignore_patterns( + ".venv", + ".pytest_cache", + "__pycache__", + "*.pyc", + "dist", + "node_modules", + "dsh-jsonrpc-agent-pkg-*", + ), + ) + + +def rewrite_version(pyproject: Path, version: str) -> None: + text, count = re.subn( + r'^version = "[^"]+"$', + f'version = "{version}"', + pyproject.read_text(), + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError(f"could not rewrite version in {pyproject}") + pyproject.write_text(text) + + +def stage_sdk(destination: Path, version: str) -> None: + copy_package(ROOT / "python" / "sdk", destination) + pyproject = destination / "pyproject.toml" + rewrite_version(pyproject, version) + text, count = re.subn( + r'"deepseek-harness-runtime-bin==[^"]+"', + f'"deepseek-harness-runtime-bin=={version}"', + pyproject.read_text(), + count=1, + ) + if count != 1: + raise RuntimeError("SDK must contain exactly one runtime dependency pin") + pyproject.write_text(text) + + +def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: + if not executable.is_file(): + raise FileNotFoundError(f"runtime executable does not exist: {executable}") + if executable.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime executable is not executable: {executable}") + copy_package(ROOT / "python" / "sdk-runtime", destination) + rewrite_version(destination / "pyproject.toml", version) + runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + destination_executable = runtime_dir / executable_name + shutil.copyfile(executable, destination_executable) + destination_executable.chmod(executable.stat().st_mode & 0o777) + + +def verify_wheel( + wheel: Path, + package: str, + version: str, + platform: tuple[str, str] | None, +) -> None: + expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}" + with zipfile.ZipFile(wheel) as archive: + wheel_metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/WHEEL")) + metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) + wheel_metadata = email.message_from_bytes(archive.read(wheel_metadata_path)) + metadata = email.message_from_bytes(archive.read(metadata_path)) + if wheel_metadata.get_all("Tag") != [expected_tag]: + raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}") + if metadata.get("Version") != version: + raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}") + executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name] + if package == "runtime": + assert platform is not None + if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): + raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") + mode = archive.getinfo(executables[0]).external_attr >> 16 + if mode & stat.S_IXUSR == 0: + raise RuntimeError(f"{wheel} runtime executable lost its executable bit") + elif executables: + raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}") + if package == "sdk": + requirements = metadata.get_all("Requires-Dist") or [] + expected_requirement = f"deepseek-harness-runtime-bin=={version}" + if expected_requirement not in requirements: + raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b26e13a56c..94cf174468 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -151,6 +151,7 @@ function gatesForMode(selected: Mode): Gate[] { ] case 'pre-push': return [ + pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('test', 'test'), pnpmScript('snapshot', 'test:snapshot'), pnpmScript('build', 'build'), @@ -163,6 +164,7 @@ function gatesForMode(selected: Mode): Gate[] { function ciPrimaryGates(): Gate[] { return [ + pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('typecheck', 'typecheck'), lintGate(), @@ -184,6 +186,7 @@ function ciPrimaryGates(): Gate[] { function ciStaticGates(): Gate[] { return [ + pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), demoSmokeGate(), ...docSyncLeafGates(), diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts new file mode 100644 index 0000000000..31d2b9e3bf --- /dev/null +++ b/scripts/verify-runtime-closure.ts @@ -0,0 +1,116 @@ +/** + * Verify that the Python single-exe deploy manifest explicitly supplies every + * required workspace peer of every workspace package in its dependency graph. + * + * `pnpm deploy --config.auto-install-peers=false` cannot repair an incomplete + * runtime root. Keeping the peer at the root also prevents a successful build + * from producing an executable that fails only when Cordis loads the plugin. + */ +import { readFile, readdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' + +interface PackageManifest { + name?: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record +} + +interface WorkspacePackage { + path: string + manifest: PackageManifest +} + +const root = resolve(import.meta.dirname, '..') +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { manifest: { type: 'string' } }, +}) +const runtimeManifestPath = resolve(root, values.manifest ?? 'python/sdk-runtime/package.json') +const runtimeManifest = await loadManifest(runtimeManifestPath) +const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime' +const workspace = await loadWorkspacePackages() +const runtimeDependencies = runtimeManifest.dependencies ?? {} +const parents = new Map() +const queue: string[] = [] + +for (const dependency of Object.keys(runtimeDependencies).sort()) { + if (!workspace.has(dependency)) continue + parents.set(dependency, undefined) + queue.push(dependency) +} + +const failures: string[] = [] +for (let index = 0; index < queue.length; index += 1) { + const packageName = queue[index] + if (packageName === undefined) continue + const current = workspace.get(packageName) + if (current === undefined) continue + const peers = current.manifest.peerDependencies ?? {} + const peerMeta = current.manifest.peerDependenciesMeta ?? {} + for (const peer of Object.keys(peers).sort()) { + if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue + if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue + failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`) + } + const dependencies = { + ...current.manifest.dependencies, + ...current.manifest.optionalDependencies, + } + for (const dependency of Object.keys(dependencies).sort()) { + if (!workspace.has(dependency) || parents.has(dependency)) continue + parents.set(dependency, packageName) + queue.push(dependency) + } +} + +if (failures.length > 0) { + console.error('verify-runtime-closure: required workspace peers are missing from python/sdk-runtime dependencies:') + for (const failure of failures) console.error(` ${failure}`) + process.exit(1) +} + +console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`) + +async function loadWorkspacePackages(): Promise> { + const paths: string[] = [] + for (const group of await childDirectories(join(root, 'packages'))) { + for (const packageDir of await childDirectories(join(root, 'packages', group))) { + paths.push(join(root, 'packages', group, packageDir, 'package.json')) + } + } + for (const packageDir of await childDirectories(join(root, 'vendor'))) { + paths.push(join(root, 'vendor', packageDir, 'package.json')) + } + const result = new Map() + for (const path of paths) { + const manifest = await loadManifest(path) + if (manifest.name !== undefined) result.set(manifest.name, { path, manifest }) + } + return result +} + +async function childDirectories(path: string): Promise { + const entries = await readdir(path, { withFileTypes: true }) + return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort() +} + +async function loadManifest(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as PackageManifest +} + +function formatChain( + runtimeName: string, + packageName: string, + parents: ReadonlyMap, +): string { + const chain = [packageName] + let parent = parents.get(packageName) + while (parent !== undefined) { + chain.unshift(parent) + parent = parents.get(parent) + } + return [runtimeName, ...chain].join(' -> ') +} From cdd11ac5879da2728ca6dbeca9412240dad1989a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 16:34:16 +0800 Subject: [PATCH 11/18] ci: validate and publish Python runtime wheels --- .../workflows/build-exe-for-python-sdk.yml | 85 ++++++ .github/workflows/ci.yml | 19 +- .gitlab-ci.yml | 120 ++++++++ ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 14 +- ...-executable-sdk-runtime-distribution.zh.md | 14 +- python/README.i18n.yaml | 4 +- python/README.md | 16 +- python/README.zh.md | 16 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 4 +- python/sdk-runtime/README.zh.md | 4 +- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 10 + python/sdk/README.zh.md | 10 + scripts/smoke-python-runtime.py | 261 ++++++++++++++++++ 16 files changed, 556 insertions(+), 33 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 scripts/smoke-python-runtime.py diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 5e055a1ee5..e212c78d86 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -119,6 +119,13 @@ jobs: with: node-version: 24 + - uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install Python build tooling + run: python -m pip install uv==0.11.23 + - name: Enable corepack (pnpm) run: corepack enable @@ -156,6 +163,78 @@ jobs: - name: Build single-exe run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} + - name: Resolve platform artifact names + id: runtime + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + platform="${TARGET#node24-}" + exe="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$platform" + [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } + echo "platform=$platform" >> "$GITHUB_OUTPUT" + echo "exe=$exe" >> "$GITHUB_OUTPUT" + + - name: Full-turn SDK, custom cordis, and direct-binary smoke + run: >- + uv run --python 3.10 --group test --project python/sdk + python scripts/smoke-python-runtime.py + --scenario all + --exe "${{ steps.runtime.outputs.exe }}" + + - name: Build release-shaped SDK and runtime wheels + run: | + set -euo pipefail + python scripts/build-python-release.py \ + --package sdk \ + --tag python-v0.0.0 \ + --output-dir dist-python + python scripts/build-python-release.py \ + --package runtime \ + --tag python-v0.0.0 \ + --platform "${{ steps.runtime.outputs.platform }}" \ + --runtime-exe "${{ steps.runtime.outputs.exe }}" \ + --output-dir dist-python + + - name: Install only the SDK into a clean venv and run zero-config + run: | + set -euo pipefail + python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ + --find-links dist-python \ + deepseek-harness==0.0.0 + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ + --scenario sdk-default + + - name: Check Linux GLIBC requirements + if: runner.os == 'Linux' + run: | + set -euo pipefail + readelf --version-info "${{ steps.runtime.outputs.exe }}" | tee glibc-versions.txt + maximum="$(sed -n 's/.*Name: GLIBC_\([0-9.]*\).*/\1/p' glibc-versions.txt | sort -V | tail -1)" + [ -n "$maximum" ] || { echo "::error::No GLIBC requirements found"; exit 1; } + dpkg --compare-versions "$maximum" le 2.28 || { + echo "::error::Executable requires GLIBC_$maximum but wheel claims manylinux_2_28" + exit 1 + } + + - name: Run wheel in a manylinux 2.28 container + if: runner.os == 'Linux' + env: + RUNNER_ARCH: ${{ runner.arch }} + run: | + set -euo pipefail + case "$RUNNER_ARCH" in + X64) image=quay.io/pypa/manylinux_2_28_x86_64 ;; + ARM64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; + *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; + esac + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' + /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk + /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness==0.0.0 + /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default + ' + # The bare exe ships inside a tar.gz (the mode-preservation note in # the header): uploading dist-exe/ bare would hand consumers a 0644 # file that subprocess.Popen refuses to run. @@ -223,3 +302,9 @@ jobs: name: deepseek-harness-python-${{ matrix.target }} path: ${{ steps.pack.outputs.bundle }} if-no-files-found: error + + - uses: actions/upload-artifact@v6 + with: + name: deepseek-harness-wheels-${{ matrix.target }} + path: dist-python/*.whl + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4840b88192..e45d6698e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,23 @@ jobs: - name: Run compatibility gates run: pnpm run check:node-compat + python-sdk: + runs-on: ubuntu-latest + name: python 3.10 / keyless SDK + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: '3.10' + cache: pip + + - name: Install uv + run: python -m pip install uv==0.11.23 + + - name: Run complete keyless Python suite + run: uv run --python 3.10 --group test --project python/sdk pytest + # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every other job in THIS workflow must be listed in @@ -154,7 +171,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-compat] + needs: [node-24, node-compat, python-sdk] if: always() steps: - name: Fail if any needed job did not succeed diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000..784de70e9c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,120 @@ +workflow: + rules: + - if: '$CI_COMMIT_TAG =~ /^python-v\d+\.\d+\.\d+$/' + - when: never + +stages: + - build + - publish + +variables: + GIT_DEPTH: "0" + PIP_DISABLE_PIP_VERSION_CHECK: "1" + +.python-tools: + before_script: + - python3 -m venv .ci-python + - . .ci-python/bin/activate + - python -m pip install uv==0.11.23 + +sdk-wheel: + extends: .python-tools + stage: build + tags: [linux-x64] + script: + - python scripts/build-python-release.py --package sdk --tag "$CI_COMMIT_TAG" --output-dir release/sdk + artifacts: + paths: [release/sdk/*.whl] + expire_in: 1 week + +.runtime-wheel: + extends: .python-tools + stage: build + script: + - corepack enable + - pnpm install --frozen-lockfile + - pnpm run verify-runtime-closure + - pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="$PKG_TARGET" + - EXE="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$PLATFORM" + - test -x "$EXE" + - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" + - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" + - python -m venv .wheel-smoke + - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness=="${CI_COMMIT_TAG#python-v}" + - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default + - | + if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then + readelf --version-info "$EXE" > glibc-versions.txt + maximum="$(sed -n 's/.*Name: GLIBC_\([0-9.]*\).*/\1/p' glibc-versions.txt | sort -V | tail -1)" + test -n "$maximum" + dpkg --compare-versions "$maximum" le 2.28 + case "$PLATFORM" in + linux-x64) image=quay.io/pypa/manylinux_2_28_x86_64 ;; + linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; + *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; + esac + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness==${CI_COMMIT_TAG#python-v} && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + fi + artifacts: + paths: [release/$PLATFORM/*.whl] + expire_in: 1 week + +runtime-linux-x64: + extends: .runtime-wheel + tags: [linux-x64] + variables: + PKG_TARGET: node24-linux-x64 + PLATFORM: linux-x64 + needs: + - job: sdk-wheel + artifacts: true + +runtime-linux-arm64: + extends: .runtime-wheel + tags: [linux-arm64] + variables: + PKG_TARGET: node24-linux-arm64 + PLATFORM: linux-arm64 + needs: + - job: sdk-wheel + artifacts: true + +runtime-macos-arm64: + extends: .runtime-wheel + tags: [macos-arm64] + variables: + PKG_TARGET: node24-macos-arm64 + PLATFORM: macos-arm64 + needs: + - job: sdk-wheel + artifacts: true + +publish-python: + stage: publish + tags: [linux-x64] + resource_group: python-release + needs: + - job: sdk-wheel + artifacts: true + - job: runtime-linux-x64 + artifacts: true + - job: runtime-linux-arm64 + artifacts: true + - job: runtime-macos-arm64 + artifacts: true + before_script: + - python3 -m venv .ci-python + - . .ci-python/bin/activate + - python -m pip install twine==6.2.0 + script: + - version="${CI_COMMIT_TAG#python-v}" + - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 + - test -f "release/sdk/deepseek_harness-${version}-py3-none-any.whl" + - test -f "release/linux-x64/deepseek_harness_runtime_bin-${version}-py3-none-manylinux_2_28_x86_64.whl" + - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${version}-py3-none-manylinux_2_28_aarch64.whl" + - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${version}-py3-none-macosx_11_0_arm64.whl" + - python -m twine check release/*/*.whl + - export TWINE_USERNAME=gitlab-ci-token + - export TWINE_PASSWORD="$CI_JOB_TOKEN" + - export TWINE_REPOSITORY_URL="$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages/pypi" + - python -m twine upload --non-interactive release/*/*.whl || { echo 'Publish failed. GitLab does not overwrite an existing version; create a new python-vX.Y.Z tag.'; exit 1; } diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 2d879af81f..307928d126 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 4fab5f89542753f2dd5977d4262e1310097f4545 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 56a93f2392bff655d32ec4947c357b33d9e6da5e +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 0e463a23227bfb8f8eef5ee1a21bcddd9151c8e4 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2ce32f9bba20e85fa67559389be9d2d5c7e60cee diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 4fab5f8954..0e463a2322 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,17 +36,19 @@ Config discovery has two channels and fails loudly when both are missing: the `D Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. Two closure-completeness rules learned from measurement: in-repo pure libraries that closure members reference as peerDependency (`dsh-brand`, `dsh-timeout`, `dsh-invariants`, `@cordisjs/plugin-timer`) must be listed explicitly in the manifest's `dependencies`; deploy packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; CI static, pre-push, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the artifacts `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` (the CI artifact) and are copied back into the runtime directory. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the artifacts `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` (the CI artifact) and are copied back into the runtime directory. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached and artifacts uploaded per platform; macOS ad-hoc signing is handled by pkg. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached and artifacts uploaded per platform; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel/sdist distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. + +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) accepts only stable `python-vX.Y.Z` tags and stages both packages at `X.Y.Z`, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. The SDK is a `py3-none-any` wheel; the wheel-only runtime package contains exactly one exe and uses one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`. Its Hatch hook rejects sdists, universal tags, mixed executable payloads, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. @@ -60,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the 28 pytest cases in `python/sdk/tests` cover the client protocol (18 cases, keyless, against a fake runtime peer), dual-carrier launch (6 cases, each skipping independently when the artifact is missing), and the carrier resolution rules (4 cases). End-to-end tier: the acceptance material (five keyless items: env-channel launch, fail-loud on missing config, fail-loud on a bad plugin name, config channel, full turn against a mock endpoint + JSONL on disk; two with-key items: a real turn, and a world check where the bash tool writes a marker file + clean exit) drives the exe from a real Python SDK client. Current debts: running the above suites against the current artifacts, and a built-exe e2e. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform artifact completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked; its platform wheel is then installed in a clean venv and run without `runtime_bin`. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. @@ -80,4 +82,4 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp **Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving surface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. -**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial); worker-style plugins have undefined behavior inside the exe; acceptance and coverage carry catch-up debts (see Testing). +**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial); worker-style plugins have undefined behavior inside the exe. diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 56a93f2392..2ce32f9bba 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,17 +36,19 @@ exe 用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后的 exe 的 VFS 内是**构建产物形态的真实包树**(各包 `lib/` + 真实 `node_modules`),Loader 解析插件名走标准动态 `import()`:裸包名从 VFS 内 Loader 位置沿 `node_modules` 向上解析,天然落在 VFS 内。封闭集不需要白名单代码——集合就是 VFS 里装了什么,引用集合外的名字 import 失败。 -deploy root 是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm workspace 成员、零代码纯依赖清单)——「exe 装什么插件」与「Python runtime 分发什么」的合一事实源。往 exe 加插件 = 清单加一行依赖再重打包。两条实测得来的闭包完整性规则:闭包成员以 peerDependency 引用的仓库内纯库(`dsh-brand`、`dsh-timeout`、`dsh-invariants`、`@cordisjs/plugin-timer`)必须显式列进清单 `dependencies`;deploy 按各包 `files` 打包,tsdown 拆出的共享 chunk 必须被 `files` 覆盖。 +deploy root 是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm workspace 成员、零代码纯依赖清单)——「exe 装什么插件」与「Python runtime 分发什么」的合一事实源。往 exe 加插件 = 清单加一行依赖再重打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部 workspace 包,要求每个非 optional workspace peer 显式列在 runtime root,并报告「引用包 → 缺失 peer」的完整链路;CI static、pre-push 与 single-exe 构建都会在打包前运行该门禁。deploy 还会按各包 `files` 打包,因此 tsdown 拆出的共享 chunk 必须被 `files` 覆盖。 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):`pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 产物 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/`(CI artifact)并拷回 runtime 目录。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):runtime 闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 产物 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/`(CI artifact)并拷回 runtime 目录。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 -CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,`~/.pkg-cache` 缓存,artifact 按平台上传;macOS ad-hoc 签名由 pkg 处理。Windows 是非目标。 +CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,`~/.pkg-cache` 缓存,artifact 按平台上传;macOS ad-hoc 签名由 pkg 处理。每个平台都以 mock SSE 模型分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再以 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应,最后把 release 形态的 wheel 安装到干净 venv 中并在不传 `runtime_bin` 的情况下运行;Linux 还检查 GLIBC 依赖并在 manylinux 2.28 容器中运行。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受 `python-vX.Y.Z` tag 流水线,构建一个 SDK wheel 与 3 个原生 runtime wheel,再由单个串行 job 校验并发布这 4 个文件到项目 PyPI 注册表。Windows 是非目标。 ### Python SDK 分发:双载体,exe 为生产、node 为开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk`(客户端)+ `python/sdk-runtime`(运行时载体包)。runtime 包数据目录三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe、构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 自动解析**只找 exe**;node 载体仅显式 `DSH_RUNTIME_MODE=node` 启用(跑 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需系统 node ≥22.19),定位是本仓库成员的开发验证通道,不进 wheel/sdist 分发物。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk`(客户端)+ `python/sdk-runtime`(运行时载体包)。runtime 包数据目录三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe、构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 自动解析**只找 exe**;node 载体仅显式 `DSH_RUNTIME_MODE=node` 启用(跑 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需系统 node ≥22.19),定位是本仓库成员的开发验证通道,不进 wheel 分发物。 + +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 只接受稳定的 `python-vX.Y.Z` tag,并以 `X.Y.Z` 暂存两个包,同时让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。SDK 是 `py3-none-any` wheel;只提供 wheel 的 runtime 包恰好包含一个 exe,tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用 tag、混合可执行载荷以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢复:调用方没给 `cordis`、没显式指定 runtime、环境无 `DSH_CORDIS_CONFIG` 时,客户端把检入的默认 `cordis.yml`(agent-core + 预载 llm-deepseek + JSONL 持久化 + bash-local + `dsh-jsonrpc` serving 条目,`!!js` 环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 @@ -60,7 +62,7 @@ exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:`python/sdk/tests` 的 28 例 pytest 覆盖客户端协议(18 例,keyless、假运行时对端)、双载体启动(6 例,产物缺失时独立 skip)与载体解析规则(4 例)。端到端层:验收物料(keyless 五项:env 通道启动、缺配置 fail-loud、坏插件名 fail-loud、配置通道、mock 端点完整回合 + JSONL 落盘;带 key 两项:真实回合、bash 工具写 marker 文件的世界校验 + 干净退出)由真实 Python SDK 客户端驱动 exe。当前欠账:上述套件对当前产物的运行与 built-exe e2e。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的 keyless pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 cwd 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台产物都通过默认 SDK 路径、自定义配置和直接二进制协议对着 mock 端点完成一个轮次,并校验最终文本与 JSONL;随后把平台 wheel 安装进干净 venv,在不传 `runtime_bin` 的情况下运行。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 手工驱动注意:bin 视 stdin EOF 为「客户端已走」并立即 dispose,短命管道会中止在飞回合——管道驱动必须保持 stdin 打开到回合结束。 @@ -80,4 +82,4 @@ exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢 **买到的**:目标平台零依赖单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译无注册表);serving 面、插件集、配置三者全部收敛到 `cordis.yml` + 一份依赖清单两个事实源;exe 与 node 双载体同树同语义,开发验证不必等打包;官方 Node 二进制消除了补丁二进制供应链顾虑。 -**付出的**:产物 174MB 级且源码原样进 blob(无字节码混淆,闭源分发诉求需另行评估);pkg 的 VFS/模块钩子层仍是社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级走显式改动);`--sea` 单 target 单次调用(与 CI 每平台一腿匹配,本地多平台构建串行);worker 类插件在 exe 内行为未定义;验收与覆盖率存在补跑欠账(见「测试」)。 +**付出的**:产物 174MB 级且源码原样进 blob(无字节码混淆,闭源分发诉求需另行评估);pkg 的 VFS/模块钩子层仍是社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级走显式改动);`--sea` 单 target 单次调用(与 CI 每平台一腿匹配,本地多平台构建串行);worker 类插件在 exe 内行为未定义。 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index b337d2c288..d898ac5b36 100644 --- a/python/README.i18n.yaml +++ b/python/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 -README.md: 294cbd1eb0690de17fa70f3049bb4677e1fd135a -README.zh.md: f89d1612f6ac454b3a57515b5a1a0f3c59c579d1 +README.md: a55b3b90bcb2329f58f063edc7edc733bad030d1 +README.zh.md: f99cf2302d9dbad36fff1616ae33b40b8bb02f4e diff --git a/python/README.md b/python/README.md index 294cbd1eb0..a55b3b90bc 100644 --- a/python/README.md +++ b/python/README.md @@ -49,20 +49,26 @@ Two flavors, both for repo members: ## Distributing the Python packages -Build one wheel per package; the runtime wheel embeds whatever executables are present under `runtime/` at build time (and always the default `cordis.yml`), and excludes `runtime/node/`: +Python releases use stable tags of the form `python-vX.Y.Z`. The common staging script derives both distribution versions from the tag, pins `deepseek-harness-runtime-bin==X.Y.Z` in the SDK metadata, and rejects any other tag form. Build the pure SDK wheel once and one runtime wheel on each native platform: ```sh -(cd python/sdk-runtime && uv build) # or: hatch build -(cd python/sdk && uv build) -pip install dist/deepseek_harness_runtime_bin-*.whl dist/deepseek_harness-*.whl +python scripts/build-python-release.py --package sdk --tag python-v0.1.0 --output-dir dist-python +python scripts/build-python-release.py --package runtime --tag python-v0.1.0 --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +pip install --find-links dist-python deepseek-harness==0.1.0 ``` -Once the binaries are in place (built or downloaded), installing the two wheels (or `pip install ./python/sdk-runtime ./python/sdk` straight from the directories) gives a working zero-config `DeepSeekHarness()`. Two pre-release caveats: the runtime wheel is currently tagged `py3-none-any` while embedding platform-specific binaries — build it with only the matching platform's exe in place (one wheel per platform), or drop all three exes in for a fat universal wheel (~500 MB); and versioning/publishing policy (`0.0.0-dev`, no registry) is deliberately unset until the first tagged release. +The runtime distribution is wheel-only and rejects sdist builds, missing executables, and mixed-platform payloads. Its three wheel tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the SDK remains `py3-none-any`. A tag pipeline builds these four non-conflicting files and publishes them together, so a normal `pip install deepseek-harness==X.Y.Z` selects the matching runtime wheel and `import deepseek_harness` needs no `runtime_bin`. ## Zero-config semantics The runtime binary itself always requires an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as the first argv argument), has no built-in fallback, and boots only what the config lists. Zero-config is SDK wrapper behavior: when the caller uses no explicit channel, the client injects the runtime package's checked-in default configuration ([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml)) via `DSH_CORDIS_CONFIG`; any explicit channel wins and disables the injection. The full injection conditions live in the [sdk README](sdk/README.md); the default config's contents and the hard semantic in the [sdk-runtime README](sdk-runtime/README.md). +The executable is also a supported direct interface; keep stdin open for the NDJSON JSON-RPC exchange and supply a config explicitly: + +```sh +DSH_CORDIS_CONFIG=/absolute/path/cordis.yml ./dsh-jsonrpc-agent-pkg-macos-arm64 +``` + ## Test layout `test_client.py` is fully keyless (a Python fake runtime is the peer). `test_bundled_runtime.py` boots each bundled carrier and skips per carrier when its artifact is missing. `test_runtime_resolution.py` covers the carrier-resolution rules without spawning anything. diff --git a/python/README.zh.md b/python/README.zh.md index f89d1612f6..f99cf2302d 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -49,20 +49,26 @@ print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks ## 分发 Python 包 -每个包各构建一个 wheel;runtime wheel 会内嵌构建时 `runtime/` 下存在的全部可执行文件(以及始终包含的默认 `cordis.yml`),并排除 `runtime/node/`: +Python 发布只使用 `python-vX.Y.Z` 形式的稳定 tag。统一暂存脚本从 tag 推导两个分发物的版本,在 SDK 元数据中钉死 `deepseek-harness-runtime-bin==X.Y.Z`,并拒绝其他 tag 形式。纯 SDK wheel 只构建一次,runtime wheel 则在每个原生平台各构建一个: ```sh -(cd python/sdk-runtime && uv build) # or: hatch build -(cd python/sdk && uv build) -pip install dist/deepseek_harness_runtime_bin-*.whl dist/deepseek_harness-*.whl +python scripts/build-python-release.py --package sdk --tag python-v0.1.0 --output-dir dist-python +python scripts/build-python-release.py --package runtime --tag python-v0.1.0 --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +pip install --find-links dist-python deepseek-harness==0.1.0 ``` -二进制就位(构建或下载)后,安装这两个 wheel(或直接 `pip install ./python/sdk-runtime ./python/sdk` 从目录安装),就得到零配置可用的 `DeepSeekHarness()`。两个 pre-release 注意事项:runtime wheel 目前的标签是 `py3-none-any`,内容却是平台相关的二进制——构建时只放匹配平台的 exe(每平台一个 wheel),或把三个 exe 全部放入做成通用胖 wheel(约 500 MB);版本与发布策略(`0.0.0-dev`、无 registry)刻意留待首个 tagged release 再定。 +runtime 分发物只提供 wheel,并拒绝 sdist 构建、缺失可执行文件以及混合平台载荷。三个 wheel tag 分别是 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;SDK 保持 `py3-none-any`。tag 流水线统一构建并发布这 4 个互不冲突的文件,因此常规的 `pip install deepseek-harness==X.Y.Z` 会选中匹配平台的 runtime wheel,`import deepseek_harness` 不需要 `runtime_bin`。 ## 零配置语义 运行时二进制本身始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为首个 argv 参数的配置路径),没有内置兜底,也只启动配置里列出的东西。零配置是 SDK 包装层的行为:调用方没有走任何显式通道时,客户端把 runtime 包检入的默认配置([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml))注入 `DSH_CORDIS_CONFIG`;任一显式通道存在即胜出并禁用注入。注入条件的完整定义见 [sdk README](sdk/README.md),默认配置的内容与硬语义见 [sdk-runtime README](sdk-runtime/README.md)。 +可执行文件也支持直接调用;在 NDJSON JSON-RPC 交互期间保持 stdin 打开,并显式提供配置: + +```sh +DSH_CORDIS_CONFIG=/absolute/path/cordis.yml ./dsh-jsonrpc-agent-pkg-macos-arm64 +``` + ## 测试布局 `test_client.py` 完全无需 key(对端是 Python 假运行时)。`test_bundled_runtime.py` 逐个启动内置载体,某载体产物缺失时对应用例跳过。`test_runtime_resolution.py` 覆盖载体解析规则,不 spawn 任何进程。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 6ab0e66ea5..e6492375f6 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-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 -README.md: 4cf48c55f40ec1c8632582546dcbfb990bbaa660 -README.zh.md: 0c82569be45b5cefa2ca025728945be1bdefc541 +README.md: ee7c8d5254bd1b1c1e9ba06c8c9ba0c6e8dfcacc +README.zh.md: d3c44bf34c686e1aee7157b8b5d4d5b4d14bb053 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 4cf48c55f4..ee7c8d5254 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,13 +8,15 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel/sdist distributions. +- **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow (a tar.gz — tar preserves the executable bit) and unpack it into the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. +Each wheel contains exactly one executable. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple executables, and unsupported platform tags. Stable `python-vX.Y.Z` releases use the same `X.Y.Z` for this package and the SDK. + ## Resolution API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0c82569be4..d3c44bf34c 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,13 +8,15 @@ Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 gitignore: -- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一进入 wheel/sdist 分发物的载体。 +- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一进入 wheel 分发物的载体;本包不发布 sdist。 - **node(仅限开发)**——`runtime/node/` 下的完整 deploy 闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的 deploy root——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物(tar.gz——tar 保留可执行位)并解包到 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 +每个 wheel 只包含一个可执行文件。固定 tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台 tag。稳定的 `python-vX.Y.Z` 发布为本包和 SDK 使用同一个 `X.Y.Z`。 + ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 3ee5e33a32..b312edc1b0 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 -README.md: ef9f8703f3bdb7b6d2dca28639f3b794083f7905 -README.zh.md: 31b6e87d7b75cc10cb799862fe6eb0f1f26dccd6 +README.md: 1916086324e18ce79ea572f3c85115fda3b4ab91 +README.zh.md: 380ed1726f2b541cd66403edb68a2629071e18f0 diff --git a/python/sdk/README.md b/python/sdk/README.md index ef9f8703f3..1916086324 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -8,6 +8,14 @@ runtime inherits normal DeepSeek Harness environment variables such as endpoints directly or point those variables at a local proxy during benchmark runs. +Installing `deepseek-harness` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: + +```py +from deepseek_harness import DeepSeekHarness + +result = DeepSeekHarness().run("Say hi.") +``` + By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path. ```py @@ -25,3 +33,5 @@ with DeepSeekHarness( event stream, including intermediate assistant messages and tool activity. The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. + +`cwd` and `runtime_cwd` are resolved to absolute paths before subprocess launch, environment injection, and the wire handshake. The public API exposes only applied options: deployment persona and persistence belong in `cordis.yml`, while `session_root` remains the high-level convenience that sets `DSH_SESSION_ROOT`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 31b6e87d7b..380ed1726f 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -4,6 +4,14 @@ 通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接用真实模型端点,也可以在跑基准测试时把它们指向本地代理。 +安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel。因此常规入口不需要传可执行文件参数: + +```py +from deepseek_harness import DeepSeekHarness + +result = DeepSeekHarness().run("Say hi.") +``` + 默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己用插件组合需要在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 ```py @@ -19,3 +27,5 @@ with DeepSeekHarness( `TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此低层客户端的默认启动同样享有它:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值当作缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 + +`cwd` 与 `runtime_cwd` 会在启动子进程、注入环境变量和协议握手前解析为绝对路径。公开 API 只暴露真正生效的选项:部署 persona 与持久化配置归 `cordis.yml` 管理,而 `session_root` 继续作为设置 `DSH_SESSION_ROOT` 的高层便捷选项。 diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py new file mode 100644 index 0000000000..c0d95b40d5 --- /dev/null +++ b/scripts/smoke-python-runtime.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Keyless full-turn smoke for the SDK wrapper and direct NDJSON runtime use.""" + +from __future__ import annotations + +import argparse +import json +import os +import queue +import subprocess +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Callable + + +EXPECTED_TEXT = "runtime smoke ok" +CUSTOM_CORDIS = """\ +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' +- id: agent-core + name: '@deepseek-ai/dsh-agent-core' +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD +""" + + +class MockModelHandler(BaseHTTPRequestHandler): + """Return one deterministic OpenAI-compatible streaming completion.""" + + requests: list[dict[str, object]] = [] + + def do_POST(self) -> None: + content_length = int(self.headers.get("content-length", "0")) + body = json.loads(self.rfile.read(content_length)) + self.requests.append(body) + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.end_headers() + chunks = [ + {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]}, + {"choices": [{"delta": {"content": EXPECTED_TEXT}}]}, + {"choices": [{"delta": {"content": ""}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 3, "completion_tokens": 3}}, + ] + for chunk in chunks: + self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + def log_message(self, _format: str, *_args: object) -> None: + return + + +class MockModel: + def __enter__(self) -> "MockModel": + MockModelHandler.requests.clear() + self.server = ThreadingHTTPServer(("127.0.0.1", 0), MockModelHandler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + host, port = self.server.server_address + self.url = f"http://{host}:{port}" + return self + + def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scenario", choices=("all", "sdk-default", "sdk-custom", "direct"), default="all") + parser.add_argument("--exe", type=Path) + args = parser.parse_args() + if args.scenario in {"all", "sdk-custom", "direct"} and args.exe is None: + parser.error("--exe is required for custom and direct scenarios") + if args.exe is not None and not args.exe.is_file(): + parser.error(f"runtime executable does not exist: {args.exe}") + + with MockModel() as model: + if args.scenario in {"all", "sdk-default"}: + smoke_sdk_default(model.url) + if args.scenario in {"all", "sdk-custom"}: + assert args.exe is not None + smoke_sdk_custom(model.url, args.exe.resolve()) + if args.scenario in {"all", "direct"}: + assert args.exe is not None + smoke_direct(model.url, args.exe.resolve()) + if not MockModelHandler.requests: + raise AssertionError("mock model endpoint received no requests") + print(f"smoke-python-runtime: {args.scenario} passed") + + +def smoke_sdk_default(base_url: str) -> None: + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + with DeepSeekHarness( + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + result = harness.run("reply with the smoke text", session_id="default-smoke") + assert result.status == "ok", result + assert result.final_response == EXPECTED_TEXT, result.final_response + assert_session_log(sessions, root) + + +def smoke_sdk_custom(base_url: str, executable: Path) -> None: + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(CUSTOM_CORDIS) + with DeepSeekHarness( + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + result = harness.run("reply with the smoke text", session_id="custom-smoke") + assert result.status == "ok", result + assert result.final_response == EXPECTED_TEXT, result.final_response + assert_session_log(sessions, root) + + +def smoke_direct(base_url: str, executable: Path) -> None: + with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(CUSTOM_CORDIS) + environment = { + **os.environ, + "DSH_CORDIS_CONFIG": str(cordis), + "DSH_SESSION_ROOT": str(sessions), + "DSH_CWD": str(root), + "DEEPSEEK_API_KEY": "sk-keyless-smoke", + "DEEPSEEK_BASE_URL": base_url, + } + peer = RuntimePeer([str(executable)], root, environment) + try: + peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}}) + peer.read_until(lambda message: message.get("id") == "initialize") + peer.send({ + "jsonrpc": "2.0", + "id": "prompt", + "method": "session/prompt", + "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]}, + }) + messages = peer.read_until(lambda message: message.get("id") == "prompt") + if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages): + messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished")) + event_text = json.dumps(messages) + if EXPECTED_TEXT not in event_text: + raise AssertionError(f"direct runtime emitted no final response: {messages}") + peer.send({"jsonrpc": "2.0", "id": "shutdown", "method": "shutdown"}) + peer.read_until(lambda message: message.get("id") == "shutdown") + finally: + peer.close() + assert_session_log(sessions, root) + + +class RuntimePeer: + def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None: + self.process = subprocess.Popen( + argv, + cwd=cwd, + env=environment, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + ) + self.stdout: queue.Queue[str | None] = queue.Queue() + self.stderr: list[str] = [] + threading.Thread(target=self._read_stdout, daemon=True).start() + threading.Thread(target=self._read_stderr, daemon=True).start() + + def send(self, message: dict[str, object]) -> None: + if self.process.stdin is None: + raise RuntimeError("runtime stdin is unavailable") + self.process.stdin.write(json.dumps(message) + "\n") + self.process.stdin.flush() + + def read_until(self, predicate: Callable[[dict[str, object]], bool]) -> list[dict[str, object]]: + deadline = time.monotonic() + 60 + messages: list[dict[str, object]] = [] + while time.monotonic() < deadline: + try: + line = self.stdout.get(timeout=min(0.25, deadline - time.monotonic())) + except queue.Empty: + continue + if line is None: + raise RuntimeError(f"runtime exited before expected message; stderr: {''.join(self.stderr)}") + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + messages.append(message) + if predicate(message): + return messages + raise TimeoutError(f"runtime timed out; messages={messages}; stderr={''.join(self.stderr)}") + + def close(self) -> None: + if self.process.stdin is not None and not self.process.stdin.closed: + self.process.stdin.close() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + if self.process.returncode not in {0, -15}: + raise RuntimeError(f"runtime exited {self.process.returncode}; stderr: {''.join(self.stderr)}") + + def _read_stdout(self) -> None: + assert self.process.stdout is not None + for line in self.process.stdout: + self.stdout.put(line) + self.stdout.put(None) + + def _read_stderr(self) -> None: + assert self.process.stderr is not None + self.stderr.extend(self.process.stderr) + + +def assert_session_log(sessions: Path, cwd: Path) -> None: + logs = list(sessions.rglob("*.jsonl")) + if len(logs) != 1: + raise AssertionError(f"expected one JSONL session log under {sessions}, found {logs}") + lines = logs[0].read_text().splitlines() + header = json.loads(lines[0]) + if header.get("cwd") != str(cwd): + raise AssertionError(f"session header cwd is not absolute/canonical: {header}") + if EXPECTED_TEXT not in "\n".join(lines): + raise AssertionError(f"session log has no final response: {logs[0]}") + + +if __name__ == "__main__": + main() From ef2754110ad1340546b282b643e6cbcbd5479031 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 17:02:56 +0800 Subject: [PATCH 12/18] ci: retain only Python release wheels --- .../workflows/build-exe-for-python-sdk.yml | 151 +++++++----------- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 6 +- ...-executable-sdk-runtime-distribution.zh.md | 6 +- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- .../src/deepseek_harness_runtime/__init__.py | 5 +- 11 files changed, 74 insertions(+), 114 deletions(-) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index e212c78d86..957c11d963 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -8,16 +8,10 @@ name: Build single-exe # the dsh-jsonrpc-agent-pkg closure manifest with @yao-pkg/pkg into # dist-exe/. # -# Each build leg uploads two artifacts (both tar.gz — tar keeps file modes, -# so the exe's executable bit survives; upload-artifact's zip transport -# does not preserve them): -# - dsh-jsonrpc-agent-pkg- — the bare single-file exe, for -# consumers that want just the binary. -# - deepseek-harness-python- — the whole python/ directory with -# that exe already embedded (the build script syncs it into the Python -# runtime package): unpack and both packages pip install as-is, the -# checked-in default runtime/cordis.yml is editable in place, and the -# embedded exe also runs directly. +# The run retains exactly the four wheels that make up one Python release: +# one platform-independent SDK wheel plus one native runtime wheel for each +# supported platform. The matrix still exercises the bare executable and +# source tree, but they are intermediate test inputs rather than artifacts. # # Two explicit triggers, deliberately no per-commit CI: the exe is a # release-style deliverable, and the build (full pnpm build + pnpm deploy + @@ -103,8 +97,36 @@ jobs: echo "Matrix: $matrix" echo "matrix=$matrix" >> "$GITHUB_OUTPUT" - build: + sdk-wheel: needs: plan + name: deepseek_harness-0.0.0-py3-none-any.whl + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install Python build tooling + run: python -m pip install uv==0.11.23 + + - name: Build release-shaped SDK wheel + run: >- + python scripts/build-python-release.py + --package sdk + --tag python-v0.0.0 + --output-dir dist-python + + - uses: actions/upload-artifact@v6 + with: + name: deepseek_harness-0.0.0-py3-none-any.whl + path: dist-python/deepseek_harness-0.0.0-py3-none-any.whl + if-no-files-found: error + + build: + needs: [plan, sdk-wheel] name: ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 45 @@ -163,7 +185,7 @@ jobs: - name: Build single-exe run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} - - name: Resolve platform artifact names + - name: Resolve platform outputs id: runtime env: TARGET: ${{ matrix.target }} @@ -172,8 +194,15 @@ jobs: platform="${TARGET#node24-}" exe="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$platform" [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } + case "$platform" in + linux-x64) wheel=deepseek_harness_runtime_bin-0.0.0-py3-none-manylinux_2_28_x86_64.whl ;; + linux-arm64) wheel=deepseek_harness_runtime_bin-0.0.0-py3-none-manylinux_2_28_aarch64.whl ;; + macos-arm64) wheel=deepseek_harness_runtime_bin-0.0.0-py3-none-macosx_11_0_arm64.whl ;; + *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;; + esac echo "platform=$platform" >> "$GITHUB_OUTPUT" echo "exe=$exe" >> "$GITHUB_OUTPUT" + echo "wheel=$wheel" >> "$GITHUB_OUTPUT" - name: Full-turn SDK, custom cordis, and direct-binary smoke run: >- @@ -182,19 +211,19 @@ jobs: --scenario all --exe "${{ steps.runtime.outputs.exe }}" - - name: Build release-shaped SDK and runtime wheels - run: | - set -euo pipefail - python scripts/build-python-release.py \ - --package sdk \ - --tag python-v0.0.0 \ - --output-dir dist-python - python scripts/build-python-release.py \ - --package runtime \ - --tag python-v0.0.0 \ - --platform "${{ steps.runtime.outputs.platform }}" \ - --runtime-exe "${{ steps.runtime.outputs.exe }}" \ - --output-dir dist-python + - name: Build release-shaped runtime wheel + run: >- + python scripts/build-python-release.py + --package runtime + --tag python-v0.0.0 + --platform "${{ steps.runtime.outputs.platform }}" + --runtime-exe "${{ steps.runtime.outputs.exe }}" + --output-dir dist-python + + - uses: actions/download-artifact@v8 + with: + name: deepseek_harness-0.0.0-py3-none-any.whl + path: dist-python - name: Install only the SDK into a clean venv and run zero-config run: | @@ -235,76 +264,8 @@ jobs: /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default ' - # The bare exe ships inside a tar.gz (the mode-preservation note in - # the header): uploading dist-exe/ bare would hand consumers a 0644 - # file that subprocess.Popen refuses to run. - - name: Pack bare exe - id: pack-exe - env: - TARGET: ${{ matrix.target }} - run: | - set -euo pipefail - exe_tar="dsh-jsonrpc-agent-pkg-${TARGET}.tar.gz" - tar -czf "$exe_tar" -C dist-exe . - ls -lh "$exe_tar" - echo "tar=$exe_tar" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v6 with: - name: dsh-jsonrpc-agent-pkg-${{ matrix.target }} - path: ${{ steps.pack-exe.outputs.tar }} - if-no-files-found: error - - # After the build step, python/ is already in its complete - # distributable shape — the script synced this leg's exe into - # python/sdk-runtime/src/deepseek_harness_runtime/runtime/ next to the - # checked-in default cordis.yml — so packing is all that is left. - # Everything goes under one top-level deepseek-harness-python/ - # directory so unpacking never scatters files. - # - # Excluded: runtime/node/ (dev-only node-mode carrier, ~140MB) plus - # __pycache__ / .pytest_cache / .venv / node_modules anywhere (install - # or test leftovers); uv.lock stays in. - # - # Portability: GNU tar (ubuntu) and bsdtar (macos) both accept - # `tar -czf out.tar.gz --exclude=… -C ` and both treat - # an excluded directory as pruned (no descent). The top-level rename - # is done by copying into a temp dir first — GNU --transform / BSD -s - # are single-implementation flags. - - name: Pack Python SDK bundle - id: pack - env: - TARGET: ${{ matrix.target }} - run: | - set -euo pipefail - platform_arch="${TARGET#*-}" # node24-macos-arm64 -> macos-arm64 - exe="python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg-${platform_arch}" - if [ ! -x "$exe" ]; then - echo "::error::$exe missing or not executable — the build step did not sync this leg's exe into the Python runtime package; refusing to pack a half-empty bundle." - exit 1 - fi - staging="$(mktemp -d)" - cp -R python "$staging/deepseek-harness-python" - bundle="deepseek-harness-python-${platform_arch}.tar.gz" - tar -czf "$bundle" \ - --exclude='deepseek-harness-python/sdk-runtime/src/deepseek_harness_runtime/runtime/node' \ - --exclude='__pycache__' \ - --exclude='.pytest_cache' \ - --exclude='.venv' \ - --exclude='node_modules' \ - -C "$staging" deepseek-harness-python - rm -rf "$staging" - ls -lh "$bundle" - echo "bundle=$bundle" >> "$GITHUB_OUTPUT" - - - uses: actions/upload-artifact@v6 - with: - name: deepseek-harness-python-${{ matrix.target }} - path: ${{ steps.pack.outputs.bundle }} - if-no-files-found: error - - - uses: actions/upload-artifact@v6 - with: - name: deepseek-harness-wheels-${{ matrix.target }} - path: dist-python/*.whl + name: ${{ steps.runtime.outputs.wheel }} + path: dist-python/${{ steps.runtime.outputs.wheel }} if-no-files-found: error diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 307928d126..891792c7dc 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 0e463a23227bfb8f8eef5ee1a21bcddd9151c8e4 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2ce32f9bba20e85fa67559389be9d2d5c7e60cee +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 474a7af7f2cfe9177dec779e3eb50fd6034e806d +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 1f436d63fc91420c6da6d92e32bd16d5936ced5d diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 0e463a2322..474a7af7f2 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -40,9 +40,9 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the artifacts `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` (the CI artifact) and are copied back into the runtime directory. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached and artifacts uploaded per platform; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. The run retains only four artifacts, each containing one release file: the platform-independent SDK wheel and the three native runtime wheels; bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development @@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform artifact completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked; its platform wheel is then installed in a clean venv and run without `runtime_bin`. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked; its platform wheel is then installed in a clean venv and run without `runtime_bin`. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 2ce32f9bba..1f436d63fc 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -40,9 +40,9 @@ deploy root 是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runti ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):runtime 闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 产物 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/`(CI artifact)并拷回 runtime 目录。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):runtime 闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/` 并拷回 runtime 目录。CI 把它们作为测试中间输入,只保留对应的平台 wheel。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 -CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,`~/.pkg-cache` 缓存,artifact 按平台上传;macOS ad-hoc 签名由 pkg 处理。每个平台都以 mock SSE 模型分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再以 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应,最后把 release 形态的 wheel 安装到干净 venv 中并在不传 `runtime_bin` 的情况下运行;Linux 还检查 GLIBC 依赖并在 manylinux 2.28 容器中运行。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受 `python-vX.Y.Z` tag 流水线,构建一个 SDK wheel 与 3 个原生 runtime wheel,再由单个串行 job 校验并发布这 4 个文件到项目 PyPI 注册表。Windows 是非目标。 +CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,并缓存 `~/.pkg-cache`;macOS ad-hoc 签名由 pkg 处理。每个平台都以 mock SSE 模型分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再以 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应,最后把 release 形态的 wheel 安装到干净 venv 中并在不传 `runtime_bin` 的情况下运行;Linux 还检查 GLIBC 依赖并在 manylinux 2.28 容器中运行。整次运行只保留 4 个产物,每个只含一个发布文件:平台无关的 SDK wheel 与 3 个原生 runtime wheel;裸 exe 和源码 bundle 只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受 `python-vX.Y.Z` tag 流水线,构建一个 SDK wheel 与 3 个原生 runtime wheel,再由单个串行 job 校验并发布这 4 个文件到项目 PyPI 注册表。Windows 是非目标。 ### Python SDK 分发:双载体,exe 为生产、node 为开发 @@ -62,7 +62,7 @@ exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的 keyless pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 cwd 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台产物都通过默认 SDK 路径、自定义配置和直接二进制协议对着 mock 端点完成一个轮次,并校验最终文本与 JSONL;随后把平台 wheel 安装进干净 venv,在不传 `runtime_bin` 的情况下运行。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的 keyless pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 cwd 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议对着 mock 端点完成一个轮次,并校验最终文本与 JSONL;随后把平台 wheel 安装进干净 venv,在不传 `runtime_bin` 的情况下运行。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 手工驱动注意:bin 视 stdin EOF 为「客户端已走」并立即 dispose,短命管道会中止在飞回合——管道驱动必须保持 stdin 打开到回合结束。 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index d898ac5b36..ec8976bf07 100644 --- a/python/README.i18n.yaml +++ b/python/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 -README.md: a55b3b90bcb2329f58f063edc7edc733bad030d1 -README.zh.md: f99cf2302d9dbad36fff1616ae33b40b8bb02f4e +README.md: 2e2e3bff7e6903a1183cc445f590ecb327b92e65 +README.zh.md: f14dad4688ed3c54f46ae28475779b0a8d163ae0 diff --git a/python/README.md b/python/README.md index a55b3b90bc..2e2e3bff7e 100644 --- a/python/README.md +++ b/python/README.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -Products land in `dist-exe/` (CI artifact shape) and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a build the SDK finds the executable with no further setup. Alternatively, download the platform artifact from the `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) and place it at that same path. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). +Products land in `dist-exe/` and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the executable with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same binaries but retains only the four release wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). ## Validating the SDK against the executable diff --git a/python/README.zh.md b/python/README.zh.md index f99cf2302d..f14dad4688 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -产物落入 `dist-exe/`(CI 产物形态),并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),构建完成后 SDK 不需要额外设置就能找到可执行文件。也可以从 `build-exe-for-python-sdk` CI workflow(手动触发,或给 PR 打 `build-exe` 标签)下载对应平台的产物放到同一路径。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发用的 node 载体(见下文「对着 Node 源码运行」)。 +产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到可执行文件。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的二进制,但只保留 4 个发布 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发用的 node 载体(见下文「对着 Node 源码运行」)。 ## 用可执行文件验证 SDK diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index e6492375f6..dfe047f3b4 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-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 -README.md: ee7c8d5254bd1b1c1e9ba06c8c9ba0c6e8dfcacc -README.zh.md: d3c44bf34c686e1aee7157b8b5d4d5b4d14bb053 +README.md: 1a63f76f62b95cb36f53e3f8ff42898b26122d05 +README.zh.md: b214ed5e6d25a013def5429b92a14b7393f1e028 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index ee7c8d5254..1a63f76f62 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -13,7 +13,7 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. -Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow (a tar.gz — tar preserves the executable bit) and unpack it into the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. +Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. Each wheel contains exactly one executable. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple executables, and unsupported platform tags. Stable `python-vX.Y.Z` releases use the same `X.Y.Z` for this package and the SDK. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index d3c44bf34c..b214ed5e6d 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -13,7 +13,7 @@ Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的 deploy root——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 -载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物(tar.gz——tar 保留可执行位)并解包到 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 +载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台 runtime wheel。该工作流只保留 wheel,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 每个 wheel 只包含一个可执行文件。固定 tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台 tag。稳定的 `python-vX.Y.Z` 发布为本包和 SDK 使用同一个 `X.Y.Z`。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index fdaa08f4ef..9076861e88 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -35,9 +35,8 @@ _ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm _EXE_ACQUISITION_HINT = ( "Two ways to get the executable: run `scripts/build-exe-for-python-sdk.ts` (via tsx) in a " - "deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` " - "CI workflow (a tar.gz — tar preserves the executable bit) and unpack it into this package's " - "runtime/ directory. For local development " + "deepseek-harness checkout, or install the matching `deepseek-harness-runtime-bin` platform " + "wheel retained by the `build-exe-for-python-sdk` CI workflow. For local development " "against a repo source build, explicitly select the dev-only node carrier with " f"{RUNTIME_MODE_ENV_VAR}=node (or resolve_bundled_launch_args('node'))." ) From fe3777cf27ecc0bfe2ec7cad89e06d53bfaa2d5c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 17:49:01 +0800 Subject: [PATCH 13/18] python: derive release version from repository --- .../workflows/build-exe-for-python-sdk.yml | 40 +++++++++++++------ .gitlab-ci.yml | 17 ++++---- docs/cookbook/adding-a-package.md | 2 +- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 4 +- .../process/2026-06-16-pnpm-over-yarn.md | 2 +- python/README.i18n.yaml | 4 +- python/README.md | 11 ++--- python/README.zh.md | 11 ++--- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk/tests/test_release_version.py | 39 ++++++++++++++++++ scripts/build-python-release.py | 38 ++++++++++++++---- scripts/check-workspace-constraints.ts | 20 ++++++++-- 16 files changed, 149 insertions(+), 55 deletions(-) create mode 100644 python/sdk/tests/test_release_version.py diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 957c11d963..56099cb137 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -61,7 +61,21 @@ jobs: timeout-minutes: 5 outputs: matrix: ${{ steps.plan.outputs.matrix }} + version: ${{ steps.version.outputs.version }} steps: + - uses: actions/checkout@v6 + + - name: Resolve repository version + id: version + run: | + set -euo pipefail + version="$(jq -r '.version // empty' package.json)" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "::error::package.json version must be stable X.Y.Z, got '$version'" + exit 1 + } + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Compute matrix from targets input id: plan env: @@ -99,7 +113,7 @@ jobs: sdk-wheel: needs: plan - name: deepseek_harness-0.0.0-py3-none-any.whl + name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -116,13 +130,12 @@ jobs: run: >- python scripts/build-python-release.py --package sdk - --tag python-v0.0.0 --output-dir dist-python - uses: actions/upload-artifact@v6 with: - name: deepseek_harness-0.0.0-py3-none-any.whl - path: dist-python/deepseek_harness-0.0.0-py3-none-any.whl + name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl if-no-files-found: error build: @@ -189,15 +202,16 @@ jobs: id: runtime env: TARGET: ${{ matrix.target }} + VERSION: ${{ needs.plan.outputs.version }} run: | set -euo pipefail platform="${TARGET#node24-}" exe="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$platform" [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } case "$platform" in - linux-x64) wheel=deepseek_harness_runtime_bin-0.0.0-py3-none-manylinux_2_28_x86_64.whl ;; - linux-arm64) wheel=deepseek_harness_runtime_bin-0.0.0-py3-none-manylinux_2_28_aarch64.whl ;; - macos-arm64) wheel=deepseek_harness_runtime_bin-0.0.0-py3-none-macosx_11_0_arm64.whl ;; + linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; + linux-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl ;; + macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_11_0_arm64.whl ;; *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;; esac echo "platform=$platform" >> "$GITHUB_OUTPUT" @@ -215,23 +229,24 @@ jobs: run: >- python scripts/build-python-release.py --package runtime - --tag python-v0.0.0 --platform "${{ steps.runtime.outputs.platform }}" --runtime-exe "${{ steps.runtime.outputs.exe }}" --output-dir dist-python - uses: actions/download-artifact@v8 with: - name: deepseek_harness-0.0.0-py3-none-any.whl + name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python - name: Install only the SDK into a clean venv and run zero-config + env: + VERSION: ${{ needs.plan.outputs.version }} run: | set -euo pipefail python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ --find-links dist-python \ - deepseek-harness==0.0.0 + deepseek-harness=="$VERSION" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default @@ -251,6 +266,7 @@ jobs: if: runner.os == 'Linux' env: RUNNER_ARCH: ${{ runner.arch }} + VERSION: ${{ needs.plan.outputs.version }} run: | set -euo pipefail case "$RUNNER_ARCH" in @@ -258,9 +274,9 @@ jobs: ARM64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' + docker run --rm -e VERSION -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk - /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness==0.0.0 + /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness=="$VERSION" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default ' diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 784de70e9c..b4af9e3ecb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,8 @@ variables: before_script: - python3 -m venv .ci-python - . .ci-python/bin/activate + - export DSH_VERSION="$(python -c 'import json; print(json.load(open("package.json"))["version"])')" + - test "$CI_COMMIT_TAG" = "python-v$DSH_VERSION" || { echo "Tag $CI_COMMIT_TAG does not match package.json version $DSH_VERSION"; exit 1; } - python -m pip install uv==0.11.23 sdk-wheel: @@ -40,7 +42,7 @@ sdk-wheel: - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" - python -m venv .wheel-smoke - - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness=="${CI_COMMIT_TAG#python-v}" + - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness=="$DSH_VERSION" - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default - | if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then @@ -53,7 +55,7 @@ sdk-wheel: linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness==${CI_COMMIT_TAG#python-v} && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" fi artifacts: paths: [release/$PLATFORM/*.whl] @@ -105,14 +107,15 @@ publish-python: before_script: - python3 -m venv .ci-python - . .ci-python/bin/activate + - export DSH_VERSION="$(python -c 'import json; print(json.load(open("package.json"))["version"])')" + - test "$CI_COMMIT_TAG" = "python-v$DSH_VERSION" || { echo "Tag $CI_COMMIT_TAG does not match package.json version $DSH_VERSION"; exit 1; } - python -m pip install twine==6.2.0 script: - - version="${CI_COMMIT_TAG#python-v}" - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 - - test -f "release/sdk/deepseek_harness-${version}-py3-none-any.whl" - - test -f "release/linux-x64/deepseek_harness_runtime_bin-${version}-py3-none-manylinux_2_28_x86_64.whl" - - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${version}-py3-none-manylinux_2_28_aarch64.whl" - - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${version}-py3-none-macosx_11_0_arm64.whl" + - test -f "release/sdk/deepseek_harness-${DSH_VERSION}-py3-none-any.whl" + - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_x86_64.whl" + - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_aarch64.whl" + - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-macosx_11_0_arm64.whl" - python -m twine check release/*/*.whl - export TWINE_USERNAME=gitlab-ci-token - export TWINE_PASSWORD="$CI_JOB_TOKEN" diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 1ab6931696..852d403e2b 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -18,7 +18,7 @@ packages/// Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `compact`, `subagent`, `todo`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 891792c7dc..f53d8a5d3b 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 474a7af7f2cfe9177dec779e3eb50fd6034e806d -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 1f436d63fc91420c6da6d92e32bd16d5936ced5d +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 08fd6f530d1f564ca169a168c0f7d77bff6bf29f +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 050e4f9a6e8bb03e59835f2c06dd3c701aa34eed diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 474a7af7f2..08fd6f530d 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -42,13 +42,13 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. The run retains only four artifacts, each containing one release file: the platform-independent SDK wheel and the three native runtime wheels; bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. The run retains only four artifacts, each containing one release file: the platform-independent SDK wheel and the three native runtime wheels; bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) accepts only stable `python-vX.Y.Z` tags and stages both packages at `X.Y.Z`, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. The SDK is a `py3-none-any` wheel; the wheel-only runtime package contains exactly one exe and uses one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`. Its Hatch hook rejects sdists, universal tags, mixed executable payloads, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; the wheel-only runtime package contains exactly one exe and uses one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`. Its Hatch hook rejects sdists, universal tags, mixed executable payloads, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 1f436d63fc..050e4f9a6e 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -42,13 +42,13 @@ deploy root 是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runti [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):runtime 闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/` 并拷回 runtime 目录。CI 把它们作为测试中间输入,只保留对应的平台 wheel。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 -CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,并缓存 `~/.pkg-cache`;macOS ad-hoc 签名由 pkg 处理。每个平台都以 mock SSE 模型分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再以 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应,最后把 release 形态的 wheel 安装到干净 venv 中并在不传 `runtime_bin` 的情况下运行;Linux 还检查 GLIBC 依赖并在 manylinux 2.28 容器中运行。整次运行只保留 4 个产物,每个只含一个发布文件:平台无关的 SDK wheel 与 3 个原生 runtime wheel;裸 exe 和源码 bundle 只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受 `python-vX.Y.Z` tag 流水线,构建一个 SDK wheel 与 3 个原生 runtime wheel,再由单个串行 job 校验并发布这 4 个文件到项目 PyPI 注册表。Windows 是非目标。 +CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,并缓存 `~/.pkg-cache`;macOS ad-hoc 签名由 pkg 处理。每个平台都以 mock SSE 模型分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再以 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应,最后把 release 形态的 wheel 安装到干净 venv 中并在不传 `runtime_bin` 的情况下运行;Linux 还检查 GLIBC 依赖并在 manylinux 2.28 容器中运行。整次运行只保留 4 个产物,每个只含一个发布文件:平台无关的 SDK wheel 与 3 个原生 runtime wheel;裸 exe 和源码 bundle 只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` tag 流水线,构建一个 SDK wheel 与 3 个原生 runtime wheel,再由单个串行 job 校验并发布这 4 个文件到项目 PyPI 注册表。Windows 是非目标。 ### Python SDK 分发:双载体,exe 为生产、node 为开发 Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk`(客户端)+ `python/sdk-runtime`(运行时载体包)。runtime 包数据目录三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe、构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 自动解析**只找 exe**;node 载体仅显式 `DSH_RUNTIME_MODE=node` 启用(跑 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需系统 node ≥22.19),定位是本仓库成员的开发验证通道,不进 wheel 分发物。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 只接受稳定的 `python-vX.Y.Z` tag,并以 `X.Y.Z` 暂存两个包,同时让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。SDK 是 `py3-none-any` wheel;只提供 wheel 的 runtime 包恰好包含一个 exe,tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用 tag、混合可执行载荷以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录 `package.json` 读取权威的稳定 `X.Y.Z`,并以该版本暂存两个包,同时让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布 tag 只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel;只提供 wheel 的 runtime 包恰好包含一个 exe,tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用 tag、混合可执行载荷以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢复:调用方没给 `cordis`、没显式指定 runtime、环境无 `DSH_CORDIS_CONFIG` 时,客户端把检入的默认 `cordis.yml`(agent-core + 预载 llm-deepseek + JSONL 持久化 + bash-local + `dsh-jsonrpc` serving 条目,`!!js` 环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md index 6ea62b2c4e..7a574a3388 100644 --- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -15,7 +15,7 @@ Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed throu - **Workspaces** move from the `package.json` `workspaces` array + `.yarnrc.yml` to `pnpm-workspace.yaml` (`vendor/*`, `packages/*` — the same globs; `examples/*` stay non-workspace, matching the prior setup and tsdown's explicit globs). - **Strict symlinked linker** (pnpm's default) replaces Yarn's hoisted `node-modules` linker. We deliberately add **no** `node-linker=hoisted` / `shamefully-hoist` escape hatch: pnpm's non-flat `node_modules` makes phantom dependencies (importing an undeclared transitive dep) fail loudly, which is a *feature* for a repo whose whole quality story is mechanical gates ([mechanical quality gates](2026-06-11-quality-gates.md)). The gate suite — typecheck, lint, test, build, knip — is the safety net that proves no such phantom imports exist. - **Build-script allowlist.** pnpm 10+ does not run dependency lifecycle scripts unless allowlisted. `pnpm-workspace.yaml` carries an explicit `allowBuilds` map (`esbuild`, `lefthook`, `@google/genai`, `protobufjs`) — the same supply-chain-hardening posture the repo already takes toward model/tool output, now applied to install-time code execution. `peerDependencyRules.allowedVersions.typescript: '>=5 <7'` silences benign peer-range warnings for the in-repo TypeScript. -- **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, `version: 0.0.1`, `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope. +- **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, use the root `package.json` version, and set `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope. - All `yarn …` verbs across CI, lefthook hooks, `package.json` scripts, and docs become `pnpm …` / `pnpm run …`. `yarn.lock` → `pnpm-lock.yaml` (lockfile v9). `.gitignore` swaps `.yarn/` for `.pnpm-store/`. Vendored READMEs (e.g. `vendor/cordis/README.md`) keep their upstream `yarn` examples untouched per the Vendoring Policy. ## Alternatives considered diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index ec8976bf07..1ea214996e 100644 --- a/python/README.i18n.yaml +++ b/python/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 -README.md: 2e2e3bff7e6903a1183cc445f590ecb327b92e65 -README.zh.md: f14dad4688ed3c54f46ae28475779b0a8d163ae0 +README.md: d9403733b7daff9b3e8dbf21e1bc0c68d61b88a7 +README.zh.md: 28859e43b7332a8ef4c3cf199b97860447a011a8 diff --git a/python/README.md b/python/README.md index 2e2e3bff7e..d9403733b7 100644 --- a/python/README.md +++ b/python/README.md @@ -49,15 +49,16 @@ Two flavors, both for repo members: ## Distributing the Python packages -Python releases use stable tags of the form `python-vX.Y.Z`. The common staging script derives both distribution versions from the tag, pins `deepseek-harness-runtime-bin==X.Y.Z` in the SDK metadata, and rejects any other tag form. Build the pure SDK wheel once and one runtime wheel on each native platform: +The root [`package.json`](../package.json) version is authoritative for both Python distributions. The common staging script reads that version, injects it into both wheels, and pins the SDK metadata to the same `deepseek-harness-runtime-bin==X.Y.Z`; an optional `python-vX.Y.Z` release tag is accepted only when it matches the repository version. Build the pure SDK wheel once and one runtime wheel on each native platform: ```sh -python scripts/build-python-release.py --package sdk --tag python-v0.1.0 --output-dir dist-python -python scripts/build-python-release.py --package runtime --tag python-v0.1.0 --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness==0.1.0 +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +pip install --find-links dist-python deepseek-harness=="$version" ``` -The runtime distribution is wheel-only and rejects sdist builds, missing executables, and mixed-platform payloads. Its three wheel tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the SDK remains `py3-none-any`. A tag pipeline builds these four non-conflicting files and publishes them together, so a normal `pip install deepseek-harness==X.Y.Z` selects the matching runtime wheel and `import deepseek_harness` needs no `runtime_bin`. +The runtime distribution is wheel-only and rejects sdist builds, missing executables, and mixed-platform payloads. Its three wheel tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the SDK remains `py3-none-any`. A matching `python-vX.Y.Z` tag pipeline builds these four non-conflicting files and publishes them together, so a normal `pip install deepseek-harness==X.Y.Z` selects the matching runtime wheel and `import deepseek_harness` needs no `runtime_bin`. ## Zero-config semantics diff --git a/python/README.zh.md b/python/README.zh.md index f14dad4688..28859e43b7 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -49,15 +49,16 @@ print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks ## 分发 Python 包 -Python 发布只使用 `python-vX.Y.Z` 形式的稳定 tag。统一暂存脚本从 tag 推导两个分发物的版本,在 SDK 元数据中钉死 `deepseek-harness-runtime-bin==X.Y.Z`,并拒绝其他 tag 形式。纯 SDK wheel 只构建一次,runtime wheel 则在每个原生平台各构建一个: +根目录 [`package.json`](../package.json) 的版本是两个 Python 分发物的权威版本。统一暂存脚本读取这个版本并注入两个 wheel,同时在 SDK 元数据中钉死相同版本的 `deepseek-harness-runtime-bin==X.Y.Z`;可选的 `python-vX.Y.Z` 发布 tag 只有与仓库版本匹配时才会被接受。纯 SDK wheel 只构建一次,runtime wheel 则在每个原生平台各构建一个: ```sh -python scripts/build-python-release.py --package sdk --tag python-v0.1.0 --output-dir dist-python -python scripts/build-python-release.py --package runtime --tag python-v0.1.0 --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness==0.1.0 +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +pip install --find-links dist-python deepseek-harness=="$version" ``` -runtime 分发物只提供 wheel,并拒绝 sdist 构建、缺失可执行文件以及混合平台载荷。三个 wheel tag 分别是 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;SDK 保持 `py3-none-any`。tag 流水线统一构建并发布这 4 个互不冲突的文件,因此常规的 `pip install deepseek-harness==X.Y.Z` 会选中匹配平台的 runtime wheel,`import deepseek_harness` 不需要 `runtime_bin`。 +runtime 分发物只提供 wheel,并拒绝 sdist 构建、缺失可执行文件以及混合平台载荷。三个 wheel tag 分别是 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;SDK 保持 `py3-none-any`。匹配的 `python-vX.Y.Z` tag 流水线统一构建并发布这 4 个互不冲突的文件,因此常规的 `pip install deepseek-harness==X.Y.Z` 会选中匹配平台的 runtime wheel,`import deepseek_harness` 不需要 `runtime_bin`。 ## 零配置语义 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index dfe047f3b4..542b26c719 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-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 -README.md: 1a63f76f62b95cb36f53e3f8ff42898b26122d05 -README.zh.md: b214ed5e6d25a013def5429b92a14b7393f1e028 +README.md: 4beac57526761bb150e90b60f0030ed311c4034d +README.zh.md: 3c715bce17ed1630ef0439b042d2b67047968c55 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 1a63f76f62..4beac57526 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -15,7 +15,7 @@ Both carriers hold the same content, defined once: the [package.json](package.js Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one executable. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple executables, and unsupported platform tags. Stable `python-vX.Y.Z` releases use the same `X.Y.Z` for this package and the SDK. +Each wheel contains exactly one executable. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple executables, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. ## Resolution API diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index b214ed5e6d..3c715bce17 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -15,7 +15,7 @@ Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模 载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台 runtime wheel。该工作流只保留 wheel,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 -每个 wheel 只包含一个可执行文件。固定 tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台 tag。稳定的 `python-vX.Y.Z` 发布为本包和 SDK 使用同一个 `X.Y.Z`。 +每个 wheel 只包含一个可执行文件。固定 tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台 tag。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布 tag 必须与其匹配。 ## 解析 API diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py new file mode 100644 index 0000000000..38cb40862d --- /dev/null +++ b/python/sdk/tests/test_release_version.py @@ -0,0 +1,39 @@ +"""Tests for repository-owned Python release versions.""" + +from __future__ import annotations + +import json +import runpy +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "build-python-release.py" +build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) + + +def test_repository_version_matches_root_package_json() -> None: + expected = json.loads((ROOT / "package.json").read_text())["version"] + + assert build_python_release.repository_version() == expected + + +def test_release_tag_is_optional_for_non_release_builds() -> None: + build_python_release.validate_release_tag(None, "1.2.3") + + +def test_release_tag_must_match_repository_version() -> None: + build_python_release.validate_release_tag("python-v1.2.3", "1.2.3") + + with pytest.raises(ValueError, match="expected 'python-v1.2.3'"): + build_python_release.validate_release_tag("python-v1.2.4", "1.2.3") + + +def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text('{"version":"1.2.3-dev"}\n') + + with pytest.raises(ValueError, match="must be stable X.Y.Z"): + build_python_release.repository_version(tmp_path) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index a196973a53..e0e90aa818 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Stage and build one Python release wheel from a stable ``python-vX.Y.Z`` tag.""" +"""Stage and build one Python wheel at the repository version.""" from __future__ import annotations import argparse import email +import json import os import re import shutil @@ -26,12 +27,16 @@ PLATFORMS = { def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--package", choices=("sdk", "runtime"), required=True) - parser.add_argument("--tag", required=True) + parser.add_argument( + "--tag", + help="optional python-vX.Y.Z release tag; it must match package.json", + ) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--platform", choices=tuple(PLATFORMS)) parser.add_argument("--runtime-exe", type=Path) args = parser.parse_args() - version = version_from_tag(args.tag) + version = repository_version() + validate_release_tag(args.tag, version) if args.package == "runtime" and (args.platform is None or args.runtime_exe is None): parser.error("runtime builds require --platform and --runtime-exe") if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None): @@ -58,11 +63,28 @@ def main() -> None: print(expected) -def version_from_tag(tag: str) -> str: - match = re.fullmatch(r"python-v(\d+\.\d+\.\d+)", tag) - if match is None: - raise ValueError(f"release tag must match python-vX.Y.Z, got {tag!r}") - return match.group(1) +def repository_version(root: Path = ROOT) -> str: + package_json = root / "package.json" + try: + payload = json.loads(package_json.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"could not read repository version from {package_json}") from error + version = payload.get("version") if isinstance(payload, dict) else None + if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+", version) is None: + raise ValueError( + f"{package_json} version must be stable X.Y.Z, got {version!r}" + ) + return version + + +def validate_release_tag(tag: str | None, version: str) -> None: + if tag is None: + return + expected = f"python-v{version}" + if tag != expected: + raise ValueError( + f"release tag must match repository version: expected {expected!r}, got {tag!r}" + ) def copy_package(source: Path, destination: Path) -> None: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 2d12a66d8f..d7d52aa421 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -61,6 +61,9 @@ function readJson(path: string): PackageManifest { return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest } +const rootManifest = readJson(join(root, 'package.json')) +const repositoryVersion = rootManifest.version + /** Repo-relative dirs holding a package.json, walked to the configured depth. */ function packageDirs(base: string, depth: number): string[] { if (depth === 1) { @@ -78,7 +81,7 @@ function packageDirs(base: string, depth: number): string[] { function workspaceManifests(): WorkspaceManifest[] { const manifests: WorkspaceManifest[] = [ - { dir: '.', manifest: readJson(join(root, 'package.json')) }, + { dir: '.', manifest: rootManifest }, ] for (const { dir: base, depth } of workspaceGlobs) { @@ -147,8 +150,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (peer && dev && peer !== dev) { errors.push(`${label}: cordis peer (${peer}) and dev (${dev}) ranges must match`) } - if (manifest.version !== '0.0.1') { - errors.push(`${label}: package.json must set "version": "0.0.1"`) + if (manifest.version !== repositoryVersion) { + errors.push(`${label}: package.json version must match root version ${repositoryVersion ?? '(missing)'}`) } if (manifest.type !== 'module') { errors.push(`${label}: package.json must set "type": "module"`) @@ -205,7 +208,16 @@ function checkHierarchyShape(): string[] { return errors } -const errors = [...workspaceManifests().flatMap(checkWorkspace), ...checkHierarchyShape()] +function checkRepositoryVersion(): string[] { + if (repositoryVersion && /^\d+\.\d+\.\d+$/.test(repositoryVersion)) return [] + return ['package.json: version must be stable X.Y.Z'] +} + +const errors = [ + ...checkRepositoryVersion(), + ...workspaceManifests().flatMap(checkWorkspace), + ...checkHierarchyShape(), +] if (errors.length > 0) { console.error(errors.join('\n')) process.exitCode = 1 From d5e894edf498a92e9b1e8a4ce8094edfa4360f0c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:53:20 +0800 Subject: [PATCH 14/18] fix(sdk): harden runtime lifecycle and JSON-RPC Keep DeepSeekHarness.run() reusable, but make ownership of its lazy runtime process explicit. Document the context-manager/close contract and update every construction example to use a context manager so repeated runs remain valid without encouraging leaked subprocesses. Contain notification predicate failures at the subscription boundary. Remove only the subscriber whose callback raised, deliver that exception through its queue, and continue dispatching to healthy subscribers so arbitrary callback code cannot terminate the shared reader thread or strand later requests. Enforce one in-flight prompt per server session with an atomic activePrompt guard. Route overlap through the existing -32603 handler-error response and clear the guard in finally, preserving parallel prompts across sessions and sequential reuse without changing JSON-RPC request or notification shapes. Use StringDecoder for line framing so a UTF-8 code point split across Buffer chunks is not corrupted. Add a queued-write flush barrier, and make memoized shutdown await it before disposal and exit while retaining exactly-once cleanup when shutdown calls race or flushing fails. Cover callback isolation, same-session exclusion, cross-session concurrency, split multibyte input, delayed writes, racing shutdown, and flush failure with deterministic tests. --- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/index.ts | 30 ++++---- packages/ui/jsonrpc/src/server.ts | 38 ++++++---- packages/ui/jsonrpc/src/transport.ts | 25 ++++++- .../ui/jsonrpc/tests/plugin-apply.spec.ts | 70 ++++++++++++++++--- packages/ui/jsonrpc/tests/server.spec.ts | 57 ++++++++++++++- packages/ui/jsonrpc/tests/transport.spec.ts | 60 +++++++++++++++- python/README.i18n.yaml | 4 +- python/README.md | 3 +- python/README.zh.md | 3 +- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 5 +- python/sdk/README.zh.md | 5 +- python/sdk/src/deepseek_harness/api.py | 7 +- python/sdk/src/deepseek_harness/client.py | 15 +++- python/sdk/tests/test_client.py | 42 +++++++++++ 16 files changed, 315 insertions(+), 55 deletions(-) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 95ebdb0b00..007bd1a5f1 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -20,4 +20,4 @@ The plugin owns the PROTOCOL-level exit: a `shutdown` request is answered first ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). Persistence roots and the deployment persona come from `cordis.yml`; the wire exposes only parameters the server applies. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). A session accepts at most one in-flight `session/prompt`; an overlapping prompt for the same `sessionId` fails immediately through the standard handler-error response, while other sessions remain independent and the same session can be reused after the active prompt settles. Persistence roots and the deployment persona come from `cordis.yml`; the wire exposes only parameters the server applies. diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts index 9147f63e34..624c8db824 100644 --- a/packages/ui/jsonrpc/src/index.ts +++ b/packages/ui/jsonrpc/src/index.ts @@ -83,8 +83,9 @@ export const Config: Schema = Schema.object({}) * detaches the event subscriptions) and `transport.close()`. * * The `shutdown` request's process-exit semantics live HERE, because the - * plugin owns the server and transport: the request is answered first - * (`setImmediate` lets the response frame flush), then the plugin disposes its + * plugin owns the server and transport: the request is answered first, an + * explicit output-write barrier confirms the response frame flushed, then the + * plugin disposes its * OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the * request's `server.shutdown()` already brought every SDK-created agent to * quiescence (their session logs are flushed by the awaited agent-handle @@ -109,24 +110,25 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const server = new HarnessSdkServer(ctx, transport) // The shutdown-request exit path, exactly once (a second `shutdown` frame - // racing the dispose must not re-enter). `exit(0)` runs even if the dispose - // throws — the client was already answered, so exiting is the honest outcome. - let exiting = false - const disposeAndExit = async (): Promise => { - if (exiting) return - exiting = true - try { - await fiber.dispose() - } finally { + // racing the dispose shares the same task). Flush and disposal failures are + // settled independently: once shutdown was answered, process exit is still + // the honest outcome and neither failure may prevent the next teardown step. + let exitTask: Promise | undefined + const disposeAndExit = (): Promise => { + exitTask ??= (async () => { + await Promise.allSettled([Promise.resolve().then(() => transport.flush())]) + await Promise.allSettled([Promise.resolve().then(() => fiber.dispose())]) exit(0) - } + })() + return exitTask } transport.onRequest(async (method, params) => { const result = await server.handleRequest(method, params) if (method === 'shutdown') { - // Answer the request first (setImmediate lets the response frame - // flush), then dispose this plugin's fiber and exit 0 (see apply's doc). + // The transport writes the returned result after this handler resolves. + // Schedule the explicit flush barrier after that write, then dispose this + // plugin's fiber and exit 0 (see apply's doc). setImmediate(() => { void disposeAndExit() }) } return result diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 1b193aaf1e..86af6645f3 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -36,7 +36,10 @@ export interface InitializeResult { serverInfo: { name: string; version: string } } -/** Parameters of a `session/prompt` request (one user turn on one SDK session). */ +/** + * Parameters of a `session/prompt` request: one user turn on one SDK session, + * with at most one in flight per session. + */ export interface SessionPromptParams { /** The SDK-side session id; an unknown id lazily creates the agent+session pair. */ sessionId: string @@ -53,6 +56,7 @@ export interface SessionPromptResult { interface SessionRecord { handle: AgentHandle lastTurnEnd: TurnEndReason | undefined + activePrompt: boolean } interface SubagentRecord { @@ -147,22 +151,30 @@ export class HarnessSdkServer { /** * Handle `session/prompt`: get-or-create the session's agent, send the * content as the user message, await turn settle (quiescence), then notify - * `session.finished` with the settled turn's outcome. + * `session.finished` with the settled turn's outcome. A session accepts at + * most one prompt at a time; an overlapping request fails immediately while + * other sessions remain independent. * @param params - the target session id and prompt content. * @returns `{ accepted: true }` after the turn settled. */ async prompt(params: SessionPromptParams): Promise { const rec = await this.getOrCreateSession(params.sessionId) - rec.lastTurnEnd = undefined - rec.handle.agent.send(params.contentBlocks) - await rec.handle.agent.whenIdle() - const status = this.finishedStatus(rec.lastTurnEnd) - this.transport.notify('session.finished', { - sessionId: params.sessionId, - status, - reason: rec.lastTurnEnd, - }) - return { accepted: true } + if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`) + rec.activePrompt = true + try { + rec.lastTurnEnd = undefined + rec.handle.agent.send(params.contentBlocks) + await rec.handle.agent.whenIdle() + const status = this.finishedStatus(rec.lastTurnEnd) + this.transport.notify('session.finished', { + sessionId: params.sessionId, + status, + reason: rec.lastTurnEnd, + }) + return { accepted: true } + } finally { + rec.activePrompt = false + } } /** @@ -248,7 +260,7 @@ export class HarnessSdkServer { meta: { cwd: this.cwd }, agentOptions: { model: this.model }, }) - const rec: SessionRecord = { handle, lastTurnEnd: undefined } + const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false } this.sessions.set(sessionId, rec) return rec } diff --git a/packages/ui/jsonrpc/src/transport.ts b/packages/ui/jsonrpc/src/transport.ts index 003c78f4e5..1f3011f2eb 100644 --- a/packages/ui/jsonrpc/src/transport.ts +++ b/packages/ui/jsonrpc/src/transport.ts @@ -11,6 +11,7 @@ import { randomUUID } from 'node:crypto' import type { Readable, Writable } from 'node:stream' +import { StringDecoder } from 'node:string_decoder' type JsonRpcId = string | number type RequestHandler = (method: string, params: Record) => Promise @@ -56,6 +57,7 @@ interface PendingRequest { */ export class JsonRpcLineTransport implements JsonRpcTransportPeer { private buffer = '' + private readonly decoder = new StringDecoder('utf8') private started = false private requestHandler: RequestHandler | undefined private notificationHandler: NotificationHandler | undefined @@ -122,8 +124,27 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params }) } + /** + * Wait until every frame written before this call has reached the output's + * write callback. The empty queued write is a barrier and emits no protocol + * bytes. + * @returns a promise that settles with the output write callback. + */ + flush(): Promise { + return new Promise((resolve, reject) => { + this.output.write('', (error) => { + if (error) reject(error) + else resolve() + }) + }) + } + private readonly onData = (chunk: Buffer | string): void => { - this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8') + this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk) + this.drainLines() + } + + private drainLines(): void { for (;;) { const newline = this.buffer.indexOf('\n') if (newline < 0) break @@ -139,6 +160,8 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer { } private readonly onInputEnd = (): void => { + this.buffer += this.decoder.end() + this.drainLines() this.failPending(new Error('JSON-RPC input closed')) } diff --git a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts index 50821767f9..8f4a504264 100644 --- a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts +++ b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts @@ -28,6 +28,7 @@ import * as jsonrpc from '../src/index.ts' /** One ordered observation on the plugin's outward-facing seams: a JSON-RPC frame written to `output`, or an `exit(code)` call. */ type WireEvent = | { kind: 'frame'; frame: Record } + | { kind: 'write-complete'; ids: (string | number)[] } | { kind: 'exit'; code: number } interface ApplyHarness { @@ -36,6 +37,7 @@ interface ApplyHarness { fiber: Awaited> /** Every output frame and exit call, in observation order — ordering assertions read this. */ events: WireEvent[] + outputErrors: Error[] send(frame: Record): void sendRaw(text: string): void frames(): Record[] @@ -65,7 +67,10 @@ async function settle(): Promise { * server.spec recipe) and mount the jsonrpc plugin on it through the real * namespace mount path, with in-memory seams standing in for stdio/exit. */ -async function mountPlugin(storageDir: string): Promise { +async function mountPlugin( + storageDir: string, + options: { writeDelayMs?: number; failFlush?: boolean } = {}, +): Promise { const ctx = new Context() await ctx.plugin(agentCore) await ctx.plugin(SessionPersistenceJsonl, { root: storageDir }) @@ -73,22 +78,39 @@ async function mountPlugin(storageDir: string): Promise { const input = new PassThrough() const events: WireEvent[] = [] + const outputErrors: Error[] = [] let pendingOutput = '' - // A hand-rolled Writable (not a PassThrough): _write records each decoded - // frame synchronously, so `events` preserves the true frame-vs-exit order. + // A hand-rolled Writable (not a PassThrough): _write records frames on + // admission and write-complete only when its callback fires, so a delayed + // output proves exit waits for the transport's flush barrier. const output = new Writable({ write(chunk: Buffer, _encoding, callback) { + const ids: (string | number)[] = [] pendingOutput += chunk.toString('utf8') for (;;) { const newline = pendingOutput.indexOf('\n') if (newline < 0) break const line = pendingOutput.slice(0, newline).trim() pendingOutput = pendingOutput.slice(newline + 1) - if (line) events.push({ kind: 'frame', frame: JSON.parse(line) as Record }) + if (line) { + const frame = JSON.parse(line) as Record + events.push({ kind: 'frame', frame }) + if (typeof frame.id === 'string' || typeof frame.id === 'number') ids.push(frame.id) + } } - callback() + const complete = (): void => { + if (options.failFlush === true && chunk.length === 0) { + callback(new Error('flush callback failed')) + return + } + events.push({ kind: 'write-complete', ids }) + callback() + } + if ((options.writeDelayMs ?? 0) > 0) setTimeout(complete, options.writeDelayMs) + else complete() }, }) + output.on('error', (error: Error) => { outputErrors.push(error) }) const exit = (code: number): void => { events.push({ kind: 'exit', code }) } const fiber = await ctx.plugin(jsonrpc, { input, output, exit }) @@ -99,6 +121,7 @@ async function mountPlugin(storageDir: string): Promise { ctx, fiber, events, + outputErrors, send: (frame) => { input.write(`${JSON.stringify(frame)}\n`) }, sendRaw: (text) => { input.write(text) }, frames, @@ -199,7 +222,7 @@ describe('dsh-jsonrpc plugin apply', () => { it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-')) - const harness = await mountPlugin(storageDir) + const harness = await mountPlugin(storageDir, { writeDelayMs: 10 }) try { // Two shutdown frames in ONE chunk: both are dispatched from the same // read-loop pass, so both setImmediate exit callbacks get scheduled and @@ -211,15 +234,21 @@ describe('dsh-jsonrpc plugin apply', () => { await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit recorder call') expect(harness.exits()).toEqual([0]) - // Response-then-exit ordering: both shutdown responses were flushed to - // output BEFORE exit(0) ran (the setImmediate in the request handler). + // Response-then-exit ordering: both response write callbacks and the + // empty flush barrier complete before exit(0), even on delayed output. const exitIndex = harness.events.findIndex(event => event.kind === 'exit') const firstResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-1') const secondResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-2') + const firstComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-1')) + const secondComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-2')) + const flushComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.length === 0) expect(firstResponse).toBeGreaterThanOrEqual(0) expect(secondResponse).toBeGreaterThanOrEqual(0) - expect(exitIndex).toBeGreaterThan(firstResponse) - expect(exitIndex).toBeGreaterThan(secondResponse) + expect(firstComplete).toBeGreaterThan(firstResponse) + expect(secondComplete).toBeGreaterThan(secondResponse) + expect(flushComplete).toBeGreaterThan(firstComplete) + expect(flushComplete).toBeGreaterThan(secondComplete) + expect(exitIndex).toBeGreaterThan(flushComplete) // Idempotent: the racing second shutdown never produces a second exit. await settle() @@ -236,6 +265,27 @@ describe('dsh-jsonrpc plugin apply', () => { } }) + it('still disposes and exits once when the flush callback fails', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-flush-failure-')) + const harness = await mountPlugin(storageDir, { failFlush: true }) + try { + harness.send({ jsonrpc: '2.0', id: 'sd-fail', method: 'shutdown' }) + + await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure') + await settle() + expect(harness.exits()).toEqual([0]) + expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed']) + + const before = harness.frames().length + harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, model: 'x' } }) + await settle() + expect(harness.frames().length).toBe(before) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('stops serving on a bare fiber dispose (HMR-style unload) without calling exit', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-dispose-')) const harness = await mountPlugin(storageDir) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 8d4d791841..1cde550f2e 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -152,6 +152,57 @@ describe('HarnessSdkServer', () => { } }) + it('rejects overlapping prompts for one session without serializing other sessions', async () => { + let releaseMain: (() => void) | undefined + const firstMainIdle = new Promise((resolve) => { releaseMain = resolve }) + const mainWhenIdle = vi.fn<() => Promise>() + .mockReturnValueOnce(firstMainIdle) + .mockResolvedValue(undefined) + const mainSend = vi.fn() + const mainAgent = { + send: mainSend, + whenIdle: mainWhenIdle, + } as unknown as Agent + const otherSend = vi.fn() + const otherAgent = { + send: otherSend, + whenIdle: vi.fn(() => Promise.resolve()), + } as unknown as Agent + const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } + const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } + const create = vi.fn(async (options: { agentId: AgentId }) => + String(options.agentId) === 'main' ? mainHandle : otherHandle) + const ctx = { + on: vi.fn(() => () => undefined), + agents: { create, get: () => undefined }, + get: () => undefined, + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) + const prompt = (sessionId: string, text: string) => server.prompt({ + sessionId, + contentBlocks: [{ type: 'text', text }], + }) + + const first = prompt('main', 'first') + await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() }) + + await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main') + await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true }) + releaseMain?.() + await expect(first).resolves.toEqual({ accepted: true }) + await expect(prompt('main', 'sequential')).resolves.toEqual({ accepted: true }) + + mainWhenIdle.mockRejectedValueOnce(new Error('turn wait failed')) + await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed') + await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true }) + + expect(mainSend).toHaveBeenCalledTimes(4) + expect(otherSend).toHaveBeenCalledOnce() + await server.shutdown() + expect(mainHandle.dispose).toHaveBeenCalledOnce() + expect(otherHandle.dispose).toHaveBeenCalledOnce() + }) + it('notifies the host when a child session is created with parent lineage', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-')) const ctx = await makeHarness(storageDir) @@ -490,11 +541,11 @@ describe('HarnessSdkServer', () => { get: () => undefined, } as unknown as Context const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { - sessions: Map + sessions: Map shutdown(): Promise> } - server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined }) - server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined }) + server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined, activePrompt: false }) + server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined, activePrompt: false }) await expect(server.shutdown()).rejects.toThrow('SDK server teardown failed') expect(firstDispose).toHaveBeenCalledOnce() diff --git a/packages/ui/jsonrpc/tests/transport.spec.ts b/packages/ui/jsonrpc/tests/transport.spec.ts index 0e98a4af10..2f3173c7c9 100644 --- a/packages/ui/jsonrpc/tests/transport.spec.ts +++ b/packages/ui/jsonrpc/tests/transport.spec.ts @@ -1,5 +1,5 @@ import { once } from 'node:events' -import { PassThrough } from 'node:stream' +import { PassThrough, Writable } from 'node:stream' import { describe, expect, it } from 'vitest' import { JsonRpcLineTransport } from '../src/index.ts' @@ -122,6 +122,64 @@ describe('JsonRpcLineTransport', () => { b.close() }) + it('preserves multibyte UTF-8 characters split across Buffer chunks', async () => { + const input = new PassThrough() + const output = new PassThrough() + const transport = new JsonRpcLineTransport(input, output) + const notifications: Record[] = [] + transport.onNotification((method, params) => { notifications.push({ method, params }) }) + transport.start() + + const frame = Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', method: 'message', params: { text: '你好' } })}\n`) + const character = Buffer.from('你') + const characterStart = frame.indexOf(character) + expect(characterStart).toBeGreaterThanOrEqual(0) + input.write(frame.subarray(0, characterStart + 1)) + input.write(frame.subarray(characterStart + 1)) + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(notifications).toEqual([{ method: 'message', params: { text: '你好' } }]) + transport.close() + }) + + it('flush waits for all earlier output writes', async () => { + const events: string[] = [] + const output = new Writable({ + write(chunk: Buffer, _encoding, callback) { + const label = chunk.length === 0 ? 'barrier' : 'frame' + events.push(`start:${label}`) + setTimeout(() => { + events.push(`finish:${label}`) + callback() + }, 5) + }, + }) + const transport = new JsonRpcLineTransport(new PassThrough(), output) + + transport.notify('tick') + await transport.flush() + + expect(events).toEqual([ + 'start:frame', + 'finish:frame', + 'start:barrier', + 'finish:barrier', + ]) + transport.close() + }) + + it('reports an output callback failure from flush', async () => { + const output = { + write(_chunk: string, callback?: (error?: Error) => void) { + callback?.(new Error('flush failed')) + return true + }, + } + const transport = new JsonRpcLineTransport(new PassThrough(), output as never) + + await expect(transport.flush()).rejects.toThrow('flush failed') + }) + it('rejects pending requests when the input closes', async () => { const { aToB, b } = transportPair() b.start() diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 1ea214996e..061c15aa92 100644 --- a/python/README.i18n.yaml +++ b/python/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 -README.md: d9403733b7daff9b3e8dbf21e1bc0c68d61b88a7 -README.zh.md: 28859e43b7332a8ef4c3cf199b97860447a011a8 +README.md: 35ed645ce0e4d5e4c88c8aae2fe33d94aea79b3e +README.zh.md: bddb709114df2e46bbea6e6faeb660bffa30df2b diff --git a/python/README.md b/python/README.md index d9403733b7..35ed645ce0 100644 --- a/python/README.md +++ b/python/README.md @@ -37,7 +37,8 @@ For an interactive check (needs `DEEPSEEK_API_KEY` in the environment or the rep ```python from deepseek_harness import DeepSeekHarness -print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks the bundled exe +with DeepSeekHarness() as harness: + print(harness.run("say hi").final_response) # auto-resolution picks the bundled exe ``` ## Running the SDK against the Node source (no executable) diff --git a/python/README.zh.md b/python/README.zh.md index 28859e43b7..bddb709114 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -37,7 +37,8 @@ uv run --project python/sdk pytest # ```python from deepseek_harness import DeepSeekHarness -print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks the bundled exe +with DeepSeekHarness() as harness: + print(harness.run("say hi").final_response) # auto-resolution picks the bundled exe ``` ## 对着 Node 源码运行 SDK(不用可执行文件) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index b312edc1b0..99aa49e0cc 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 -README.md: 1916086324e18ce79ea572f3c85115fda3b4ab91 -README.zh.md: 380ed1726f2b541cd66403edb68a2629071e18f0 +README.md: 60540376c5fd85b0852e204bc8bad3f01c849de5 +README.zh.md: 7148a45c8f4bd22aaa28fc15ac9cd589cba13dfb diff --git a/python/sdk/README.md b/python/sdk/README.md index 1916086324..60540376c5 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -13,9 +13,12 @@ Installing `deepseek-harness` installs the exact same-version `deepseek-harness- ```py from deepseek_harness import DeepSeekHarness -result = DeepSeekHarness().run("Say hi.") +with DeepSeekHarness() as harness: + result = harness.run("Say hi.") ``` +`DeepSeekHarness` keeps its lazily started runtime subprocess for reuse across calls. Use it as a context manager, as above, or call `close()` explicitly when finished. + By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path. ```py diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 380ed1726f..7148a45c8f 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -9,9 +9,12 @@ ```py from deepseek_harness import DeepSeekHarness -result = DeepSeekHarness().run("Say hi.") +with DeepSeekHarness() as harness: + result = harness.run("Say hi.") ``` +`DeepSeekHarness` 会保留延迟启动的运行时子进程,以供多次调用复用。请像上例一样将其用作上下文管理器,或在用完后显式调用 `close()`。 + 默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己用插件组合需要在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 ```py diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index c45a70ee33..3743371cea 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -43,7 +43,12 @@ class TurnResult: class DeepSeekHarness: - """Synchronous high-level SDK for running DeepSeek Harness agent turns.""" + """Reusable synchronous SDK for running DeepSeek Harness agent turns. + + The runtime subprocess starts lazily and remains owned by this instance + across calls to :meth:`run`. Use the instance as a context manager, or call + :meth:`close` explicitly when finished, so the subprocess is always reaped. + """ def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None: if config is not None and kwargs: diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index e81dd6f30a..be457c538a 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -350,10 +350,19 @@ class HarnessClient: params = message.get("params") notification = Notification(method=method, payload=params if isinstance(params, dict) else {}) with self._lock: - subscribers = list(self._notification_subscribers.values()) + subscribers = list(self._notification_subscribers.items()) delivered = False - for subscriber, predicate in subscribers: - if predicate is None or predicate(notification): + for subscription_id, (subscriber, predicate) in subscribers: + try: + matches = predicate is None or predicate(notification) + except BaseException as exc: + with self._lock: + current = self._notification_subscribers.get(subscription_id) + if current is not None and current[0] is subscriber: + self._notification_subscribers.pop(subscription_id, None) + subscriber.put(exc) + continue + if matches: subscriber.put(notification) delivered = True if not delivered: diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 0f66ee92e8..327e5f5c39 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -356,6 +356,48 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe assert notification.payload["sessionId"] == "other" +def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif method in {"emit-first", "emit-second"}: + print(json.dumps({"jsonrpc": "2.0", "method": "tick", "params": {"source": method}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + def broken_filter(_notification: object) -> bool: + raise RuntimeError("bad notification filter") + + with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: + client.initialize(cwd="/workspace", model="dsagent") + with ( + client.subscribe_notifications(broken_filter) as broken, + client.subscribe_notifications(lambda notification: notification.method == "tick") as healthy, + ): + client.notify("emit-first") + with pytest.raises(RuntimeError, match="bad notification filter"): + broken.next() + assert healthy.next().payload == {"source": "emit-first"} + assert client._notifications.qsize() == 0 + + client.session_prompt("main", [{"type": "text", "text": "reader still works"}]) + client.notify("emit-second") + assert healthy.next().payload == {"source": "emit-second"} + + def test_client_rejects_unaccepted_session_prompt_response(tmp_path: Path) -> None: script = tmp_path / "fake_bridge.py" script.write_text( From ebcfab16212f02269c9021de260db6c65ead2288 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:55:47 +0800 Subject: [PATCH 15/18] fix(pkg): support worker-backed tools in single exe pkg stores application files in a virtual filesystem, and its worker_threads hook discovers worker entry points only when they are passed as filesystem strings. Convert the code-runtime entry with fileURLToPath() and return the workflow built entry as a string while retaining its source-mode data URL bootstrap. Emit worker entry bundles as CommonJS .cjs files. pkg executes a VFS-backed string-path worker through Module._compile, so an ESM-only entry can be present in the executable yet still fail when launched. Keep the public hosts ESM, adapt worker startup accordingly, and align exports, package file lists, workspace constraints, documentation, and built-worker tests with the actual artifact format. Expand the custom-config executable smoke to load the Code Mode and workflow plugins and script real run_code and zero-agent workflow calls. Require both tools to return 42 from workers launched inside the pkg VFS, turning worker support from an asset-presence assumption into an end-to-end runtime contract. Update the implemented RFC and verification gates to describe and exercise the supported built-worker path. This adds no tool or JSON-RPC protocol shape; it fixes how existing worker-backed capabilities are located and executed in the single-file distribution. --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 6 +- ...-executable-sdk-runtime-distribution.zh.md | 8 +- .../feature/2026-07-05-dynamic-workflows.md | 2 +- docs/testing.md | 2 +- .../code-runtime-worker/README.md | 2 +- .../code-runtime-worker/package.json | 4 +- .../code-runtime-worker/src/index.ts | 16 +- .../code-runtime-worker/src/worker.ts | 2 +- .../tests/built-lib.e2e.ts | 6 +- .../code-runtime-worker/tsdown.config.ts | 8 +- .../workflow/workflow-workerthread/README.md | 2 +- .../workflow-workerthread/package.json | 4 +- .../workflow-workerthread/src/host.ts | 7 +- .../tests/built-worker.e2e.ts | 6 +- .../workflow-workerthread/tsdown.config.ts | 6 +- scripts/check-workspace-constraints.ts | 2 +- scripts/run-gates.ts | 2 +- scripts/smoke-python-runtime.py | 172 ++++++++++++++++-- 19 files changed, 206 insertions(+), 55 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index f53d8a5d3b..9440824276 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 08fd6f530d1f564ca169a168c0f7d77bff6bf29f -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 050e4f9a6e8bb03e59835f2c06dd3c701aa34eed +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 511f5f2cf8b43aac5960dfe4630f489ec3dc5086 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 6774fc7ec2d9439154305ba0ae35dceeff922a4a diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 08fd6f530d..511f5f2cf8 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -58,11 +58,11 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Disposition of worker-style plugins -`dsh-workflow-workerthread` and `dsh-code-runtime-worker` depend on on-disk sibling files via `new Worker(new URL('./worker.js', import.meta.url))`. PoC measurement: pkg's Worker patch intercepts **string paths only**; the URL-object form finds no file inside the VFS. The fix is already known (`fileURLToPath()` to a string), but this round's review decision is **do not verify, do not promise, do not handle**: they compile into the exe with the full set, and behavior when an external `cordis.yml` references them is undefined. +`dsh-workflow-workerthread` and `dsh-code-runtime-worker` are supported inside the exe. Their built entries convert the sibling worker URL with `fileURLToPath()` and pass the resulting filesystem string to `Worker`, which is the form pkg's Worker hook resolves inside the VFS. The workflow engine keeps its data-URL bootstrap for unbuilt source execution; only its built sibling entry uses the filesystem string. The custom-config executable smoke loads both backends, invokes a real `run_code` call and a zero-agent `workflow` call, and requires each worker to return `42` from inside pkg's VFS. ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked; its platform wheel is then installed in a clean venv and run without `runtime_bin`. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. Its platform wheel is then installed in a clean venv and run without `runtime_bin`. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. @@ -82,4 +82,4 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp **Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving surface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. -**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial); worker-style plugins have undefined behavior inside the exe. +**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial). diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 050e4f9a6e..6774fc7ec2 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -56,13 +56,13 @@ exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢 `@deepseek-ai/dsh-jsonrpc-agent`(包)→ `dsh-jsonrpc-agent`(bin)→ `dsh-jsonrpc-agent-pkg`(闭包清单;无 scope 前缀,刻意避开 constraints 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。wire `serverInfo.name` 保持 `deepseek-harness-sdk-runtime`(协议稳定值);Python dist 名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 -## worker 类插件的处置 +## 工作线程插件 -`dsh-workflow-workerthread` 与 `dsh-code-runtime-worker` 以 `new Worker(new URL('./worker.js', import.meta.url))` 依赖磁盘兄弟文件。PoC 实测:pkg 的 Worker 补丁只拦截**字符串路径**,URL 对象形态在 VFS 内找不到文件——修法已明确(`fileURLToPath()` 转字符串),但本期评审决策是**不验证、不承诺、不处理**:它们随全量集合编译进 exe,外部 `cordis.yml` 引用时行为未定义。 +exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个后端的构建入口都通过 `fileURLToPath()` 转换相邻 worker 的 URL,再将所得文件系统字符串传给 `Worker`;pkg 的 Worker 钩子可以用这种形式解析 VFS 内文件。工作流引擎在未构建的源码执行中仍保留 data URL 引导程序,只有构建后的相邻入口使用文件系统字符串。自定义配置的可执行文件冒烟测试会加载两个后端,实际调用 `run_code` 与不启动 agent 的 `workflow`,并要求两个 worker 都从 pkg 的 VFS 内返回 `42`。 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的 keyless pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 cwd 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议对着 mock 端点完成一个轮次,并校验最终文本与 JSONL;随后把平台 wheel 安装进干净 venv,在不传 `runtime_bin` 的情况下运行。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的 keyless pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 cwd 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议对着 mock 端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实 worker 文件执行 `run_code` 和不启动 agent 的 `workflow`。随后把平台 wheel 安装进干净 venv,在不传 `runtime_bin` 的情况下运行。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 手工驱动注意:bin 视 stdin EOF 为「客户端已走」并立即 dispose,短命管道会中止在飞回合——管道驱动必须保持 stdin 打开到回合结束。 @@ -82,4 +82,4 @@ exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢 **买到的**:目标平台零依赖单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译无注册表);serving 面、插件集、配置三者全部收敛到 `cordis.yml` + 一份依赖清单两个事实源;exe 与 node 双载体同树同语义,开发验证不必等打包;官方 Node 二进制消除了补丁二进制供应链顾虑。 -**付出的**:产物 174MB 级且源码原样进 blob(无字节码混淆,闭源分发诉求需另行评估);pkg 的 VFS/模块钩子层仍是社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级走显式改动);`--sea` 单 target 单次调用(与 CI 每平台一腿匹配,本地多平台构建串行);worker 类插件在 exe 内行为未定义。 +**付出的**:产物 174MB 级且源码原样进 blob(无字节码混淆,闭源分发诉求需另行评估);pkg 的 VFS/模块钩子层仍是社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级走显式改动);`--sea` 每个 target 调用一次(与 CI 每平台一腿匹配,本地多平台构建串行)。 diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 9fef336d9c..11e52eb8de 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -26,7 +26,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Why node:worker_threads**: one run uses one unpooled worker because a workflow run is already heavyweight relative to thread startup. The script runs in a vm context inside the worker, keeping the script-visible surface to the hook contract instead of exposing a bare worker realm, while `agent()` bridges by message-port RPC to I/O-bound child loops on the host. This keeps `start()` from blocking the host on the script's synchronous slice, makes the post-cancel deadline end in a real `worker.terminate()`, and gives cross-thread values a serialization boundary by construction. isolated-vm was rejected for its maintenance state, required `--no-node-snapshot` consumer flag on Node ≥ 20, and node-gyp fallback. -Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Pending async starts, published child records, one host cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.js`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate. +Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Pending async starts, published child records, one host cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.cjs`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate. **Meta as data, never evaluated**: the meta block reaches the seam as a plain JSON request field (the tool's schema-validated `meta` parameter) and the engine only shape-validates it, every violation named. This is a host-isolation invariant, not a convenience: evaluating a meta literal host-side — even one contractually "pure", in an empty timed vm context — hands script-controlled getters a host stack with no timeout the moment the result is READ, defeating the exact spin isolation the worker thread buys. diff --git a/docs/testing.md b/docs/testing.md index 62989a1096..23cca651fe 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.js`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. - An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). ## When a snapshot test is required diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index f691904e88..8138f01539 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -29,4 +29,4 @@ Every field is validated (positive numbers) and defaulted; there are no other tu ## The worker entry, unbuilt and built -`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling bundle `lib/worker.js` (its own tsdown entry). The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md). +`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling CommonJS bundle `lib/worker.cjs` (its own tsdown entry). The CommonJS format is required because pkg's VFS Worker hook compiles filesystem-string entries as CommonJS. The host converts either entry URL to a filesystem string before constructing `Worker`, which works through both ordinary Node resolution and that pkg hook. The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md). diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index 027089337b..91075243d2 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -13,14 +13,14 @@ }, "./worker": { "types": "./lib/types/worker.d.ts", - "default": "./lib/worker.js" + "default": "./lib/worker.cjs" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/worker.js", + "lib/worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index f78f06cb0b..7a8024cb3d 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -14,6 +14,7 @@ import { Worker } from 'node:worker_threads' import { stripTypeScriptTypes } from 'node:module' +import { fileURLToPath } from 'node:url' import { Context } from 'cordis' import z from 'schemastery' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' @@ -98,16 +99,19 @@ interface LiveRun { } /** - * The worker entry module. Source runs unbuilt (`src/worker.ts`, loadable + * The worker entry path. Source runs unbuilt (`src/worker.ts`, loadable * directly on this repo's Node range via native type stripping — the file * is erasable-only with type-only relative imports); the built package - * ships it as a sibling bundle (`lib/worker.js`, its own tsdown entry). + * ships it as a sibling CommonJS bundle (`lib/worker.cjs`, its own tsdown + * entry) because pkg's VFS Worker hook compiles string-path entries as + * CommonJS. * The URL *pathname*'s extension says which world this module is in — * pathname, because dev-time module runners (vitest) may suffix - * `import.meta.url` with a query string; relative resolution drops it. + * `import.meta.url` with a query string; relative resolution drops it. Worker + * receives a filesystem string so pkg's VFS Worker hook can resolve it. */ -/* v8 ignore next -- the './worker.js' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */ -const WORKER_URL = new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url) +/* v8 ignore next -- the './worker.cjs' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */ +const WORKER_PATH = fileURLToPath(new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.cjs', import.meta.url)) /** Render an unknown thrown value as a message, `Error` or not. */ function messageOf(error: unknown): string { @@ -273,7 +277,7 @@ export class WorkerCodeRuntime extends CodeRuntime { maxLogBytes: this.config.maxLogBytes, maxValueBytes: this.config.maxValueBytes, } - const worker = new Worker(WORKER_URL, { + const worker = new Worker(WORKER_PATH, { workerData: bootData, // Model code gets NO ambient environment — stronger than the scrubbed // env the defensive-patterns rule requires for spawned commands. diff --git a/packages/code-runtime/code-runtime-worker/src/worker.ts b/packages/code-runtime/code-runtime-worker/src/worker.ts index efaafdb038..9c9b009b4e 100644 --- a/packages/code-runtime/code-runtime-worker/src/worker.ts +++ b/packages/code-runtime/code-runtime-worker/src/worker.ts @@ -17,4 +17,4 @@ import type { WorkerBootData } from './protocol.ts' // A worker always has a parent port; guard loudly rather than run detached. if (!parentPort) throw new Error('dsh-code-runtime-worker: worker entry loaded outside a worker thread') -await runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr }) +void runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr }) diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 66ce1830b6..aac0ece8a1 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' * BUILT-ARTIFACT smoke for the published package (the real-load-path guard * from docs/testing.md): the unit suite runs `src/` under vitest, where the * worker entry resolves to `src/worker.ts` — a consumer runs `lib/index.js` - * under plain `node`, where it must resolve the sibling `lib/worker.js` + * under plain `node`, where it must resolve the sibling `lib/worker.cjs` * bundle instead. This spawns plain `node` (NOT tsx) from inside the package * directory and imports the package BY NAME, so resolution flows through the * real `exports` map exactly as it would from a downstream install; the @@ -21,11 +21,11 @@ import { describe, expect, it } from 'vitest' */ const pkgDir = fileURLToPath(new URL('..', import.meta.url)) -const built = ['lib/index.js', 'lib/worker.js'].every(file => existsSync(join(pkgDir, file))) +const built = ['lib/index.js', 'lib/worker.cjs'].every(file => existsSync(join(pkgDir, file))) && existsSync(join(pkgDir, '../code-runtime/lib/index.js')) describe.skipIf(!built)('built lib real load path (plain node)', () => { - it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.js entry', async () => { + it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.cjs entry', async () => { const script = ` const { Context } = await import('cordis') const { WorkerCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-worker') diff --git a/packages/code-runtime/code-runtime-worker/tsdown.config.ts b/packages/code-runtime/code-runtime-worker/tsdown.config.ts index 5af39d936a..3eec074119 100644 --- a/packages/code-runtime/code-runtime-worker/tsdown.config.ts +++ b/packages/code-runtime/code-runtime-worker/tsdown.config.ts @@ -3,8 +3,10 @@ import { defineConfig } from 'tsdown' /** * Package-shape override (see the root tsdown.config.ts): besides the * default lib/index.js bundle, the worker BOOTSTRAP ships as its own - * sibling entry — `new Worker(new URL('./worker.js', import.meta.url))` - * loads it as a file, so it cannot be part of the index bundle. TWO + * sibling CommonJS entry — `new Worker(fileURLToPath(new URL('./worker.cjs', import.meta.url)))` + * loads it as a file, so it cannot be part of the index bundle. pkg's VFS + * Worker hook compiles string-path entries as CommonJS, so an ESM worker is + * not viable inside the executable. TWO * single-entry builds, not one two-entry build: a multi-entry build emits * the shared bootstrap module as a `lib/bootstrap-*.js` chunk both bundles * import, which the package.json `files` whitelist (deliberately exact) @@ -25,7 +27,7 @@ export default defineConfig([ { entry: ['lib/types/worker.js'], outDir: 'lib', - format: ['esm'], + format: ['cjs'], platform: 'node', target: 'es2024', fixedExtension: false, diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index b9786ae1e2..023b562b42 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -32,7 +32,7 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide ## Run sequence -`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. +`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. Source mode uses a data-URL bootstrap that installs the TypeScript transforms inside the worker; built mode passes the sibling CommonJS bundle `lib/worker.cjs` as a filesystem string. CommonJS is required because pkg's VFS Worker hook compiles filesystem-string entries in that format; the same entry also works under ordinary Node resolution. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. For each `agent()` call: diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index ed934cd0cc..afaf840f78 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -13,14 +13,14 @@ }, "./worker": { "types": "./lib/types/worker.d.ts", - "default": "./lib/worker.js" + "default": "./lib/worker.cjs" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/worker.js", + "lib/worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 95c07f0d2d..9ce754c0c6 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -43,6 +43,7 @@ import { Worker } from 'node:worker_threads' import type { WorkerOptions } from 'node:worker_threads' +import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { assertNever } from '@deepseek-ai/dsh-llm' @@ -87,12 +88,12 @@ interface ChildRecord { * AMBIENT channel only — an escapee still holds process-wide privileges * like fs access (the README's trust premise stands). * @param init - the run payload, passed as `workerData`. - * @returns the entry URL and the Worker options to spawn it with. + * @returns the entry path or URL and the Worker options to spawn it with. */ -function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOptions } { +function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: WorkerOptions } { /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */ if (!import.meta.url.endsWith('.ts')) { - return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } } + return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } } } // Resolve tsx lazily: only the unbuilt shape executes this arm, so a built // consumer never needs the dev-only loader installed. A JavaScript entry is diff --git a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts index 545be831f6..779dd50600 100644 --- a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts @@ -8,17 +8,17 @@ import { describe, expect, it } from 'vitest' const packageRoot = fileURLToPath(new URL('..', import.meta.url)) const builtIndex = join(packageRoot, 'lib', 'index.js') -const builtWorker = join(packageRoot, 'lib', 'worker.js') +const builtWorker = join(packageRoot, 'lib', 'worker.cjs') const run = promisify(execFile) /** * The BUILT-output guard for the worker entry: every other suite runs * unbuilt (src/ + tsx), so nothing else proves that `lib/index.js` resolves - * its sibling `lib/worker.js` and that the bundle boots a worker under plain + * its sibling `lib/worker.cjs` and that the bundle boots a worker under plain * node (no tsx loader). Keyless — a zero-agent script needs no provider — * and self-skips until `pnpm run build` has produced the bundles. */ -describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.js)', () => { +describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.cjs)', () => { it('the built engine spawns its built worker under plain node and completes a run', async () => { // ESM resolves bare specifiers from the IMPORTING FILE's location, so the // driver must live inside the package for its node_modules to apply — a diff --git a/packages/workflow/workflow-workerthread/tsdown.config.ts b/packages/workflow/workflow-workerthread/tsdown.config.ts index 3102a36c1c..c163fe4369 100644 --- a/packages/workflow/workflow-workerthread/tsdown.config.ts +++ b/packages/workflow/workflow-workerthread/tsdown.config.ts @@ -6,7 +6,9 @@ import { defineConfig } from 'tsdown' * entries are JS emitted by tsc under lib/types and are bundled as two * single-entry passes so shared modules (realm, runtime, session) are inlined * into each instead of split into a hash-named chunk (the worker entry must - * be a self-contained file the Worker constructor can load by path). + * be a self-contained file the Worker constructor can load by path). The + * worker bundle is CommonJS because pkg's VFS Worker hook compiles + * filesystem-string entries as CommonJS. */ export default defineConfig([ { @@ -22,7 +24,7 @@ export default defineConfig([ { entry: ['lib/types/worker.js'], outDir: 'lib', - format: ['esm'], + format: ['cjs'], platform: 'node', target: 'es2024', fixedExtension: false, diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index d7d52aa421..a1cbfccd09 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -110,7 +110,7 @@ const dshBinPackageFiles = [ const dshWorkerPackageFiles = [ 'lib/index.js', - 'lib/worker.js', + 'lib/worker.cjs', 'lib/types/**/*.d.ts', 'lib/types/**/*.d.ts.map', 'src', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 94cf174468..c8a0e0d359 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -330,7 +330,7 @@ function builtBinSmokeGate(): Gate { 'packages/ui/stdio-agent/tests/built-bin.e2e.ts', 'packages/ui/acp-agent/tests/built-bin.e2e.ts', // The worker-entry packages' built bundles: the only automated proof - // that lib/index.js resolves its sibling lib/worker.js under plain node + // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). 'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts', 'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts', diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index c0d95b40d5..47de59dd2e 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -17,11 +17,18 @@ from typing import Callable EXPECTED_TEXT = "runtime smoke ok" +CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." +CODE_WORKER_TEXT = "code worker smoke ok" +WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." +WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" CUSTOM_CORDIS = """\ - id: jsonrpc name: '@deepseek-ai/dsh-jsonrpc' - id: agent-core name: '@deepseek-ai/dsh-agent-core' + config: + tools: + mode: both - id: sessions name: '@deepseek-ai/dsh-session-persistence-jsonl' config: @@ -30,11 +37,21 @@ CUSTOM_CORDIS = """\ name: '@deepseek-ai/dsh-bash-local' config: cwd: !!js process.env.DSH_CWD +- id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' +- id: subagents + name: '@deepseek-ai/dsh-subagent' +- id: workflow-engine + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn +- id: workflow-tool + name: '@deepseek-ai/dsh-tool-workflow' """ class MockModelHandler(BaseHTTPRequestHandler): - """Return one deterministic OpenAI-compatible streaming completion.""" + """Return deterministic text and worker-tool streaming completions.""" requests: list[dict[str, object]] = [] @@ -45,11 +62,7 @@ class MockModelHandler(BaseHTTPRequestHandler): self.send_response(200) self.send_header("content-type", "text/event-stream") self.end_headers() - chunks = [ - {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]}, - {"choices": [{"delta": {"content": EXPECTED_TEXT}}]}, - {"choices": [{"delta": {"content": ""}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 3, "completion_tokens": 3}}, - ] + chunks = completion_chunks(body) for chunk in chunks: self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode()) self.wfile.write(b"data: [DONE]\n\n") @@ -59,6 +72,127 @@ class MockModelHandler(BaseHTTPRequestHandler): return +def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: + """Choose the next deterministic model response from request history.""" + messages = body.get("messages") + if not isinstance(messages, list) or not messages: + raise AssertionError(f"model request has no messages: {body}") + latest = messages[-1] + if not isinstance(latest, dict): + raise AssertionError(f"model request has an invalid latest message: {body}") + + if latest.get("role") == "tool": + tool_name = latest_tool_name(messages) + tool_text = json.dumps(latest.get("content")) + if "42" not in tool_text: + raise AssertionError(f"{tool_name} worker returned no expected value: {latest}") + if tool_name == "run_code": + return text_chunks(CODE_WORKER_TEXT) + if tool_name == "workflow": + return text_chunks(WORKFLOW_WORKER_TEXT) + raise AssertionError(f"unexpected tool follow-up: {tool_name}") + + prompt = message_text(latest.get("content")) + if prompt == CODE_PROMPT: + assert_advertised_tool(body, "run_code") + return tool_call_chunks("call-code-worker", "run_code", {"code": "return 6 * 7"}) + if prompt == WORKFLOW_PROMPT: + assert_advertised_tool(body, "workflow") + return tool_call_chunks( + "call-workflow-worker", + "workflow", + { + "script": "return 6 * 7", + "meta": { + "name": "pkg-worker-smoke", + "description": "exercise the packaged workflow worker", + }, + }, + ) + return text_chunks(EXPECTED_TEXT) + + +def text_chunks(text: str) -> list[dict[str, object]]: + """Build a complete streaming text response.""" + return [ + {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]}, + {"choices": [{"delta": {"content": text}}]}, + { + "choices": [{"delta": {"content": ""}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 3}, + }, + ] + + +def tool_call_chunks(call_id: str, name: str, arguments: dict[str, object]) -> list[dict[str, object]]: + """Build a complete streaming function-call response.""" + return [ + {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]}, + { + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": json.dumps(arguments)}, + }], + }, + }], + }, + { + "choices": [{"delta": {"content": ""}, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 3}, + }, + ] + + +def latest_tool_name(messages: list[object]) -> str: + """Find the assistant tool call paired with the latest tool result.""" + for message in reversed(messages[:-1]): + if not isinstance(message, dict): + continue + calls = message.get("tool_calls") + if not isinstance(calls, list): + continue + for call in reversed(calls): + if not isinstance(call, dict): + continue + function = call.get("function") + if isinstance(function, dict) and isinstance(function.get("name"), str): + return function["name"] + raise AssertionError(f"tool result has no preceding assistant tool call: {messages}") + + +def message_text(content: object) -> str: + """Read OpenAI text content in either string or block-list form.""" + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + block.get("text", "") + for block in content + if isinstance(block, dict) and isinstance(block.get("text"), str) + ) + return "" + + +def assert_advertised_tool(body: dict[str, object], expected: str) -> None: + """Require the packaged deployment to expose the requested tool.""" + tools = body.get("tools") + if not isinstance(tools, list): + raise AssertionError(f"model request advertised no tools: {body}") + names: set[str] = set() + for tool in tools: + if not isinstance(tool, dict): + continue + function = tool.get("function") + if isinstance(function, dict) and isinstance(function.get("name"), str): + names.add(function["name"]) + if expected not in names: + raise AssertionError(f"model request did not advertise {expected}: {names}") + + class MockModel: def __enter__(self) -> "MockModel": MockModelHandler.requests.clear() @@ -116,7 +250,7 @@ def smoke_sdk_default(base_url: str) -> None: result = harness.run("reply with the smoke text", session_id="default-smoke") assert result.status == "ok", result assert result.final_response == EXPECTED_TEXT, result.final_response - assert_session_log(sessions, root) + assert_session_log(sessions, root, EXPECTED_TEXT) def smoke_sdk_custom(base_url: str, executable: Path) -> None: @@ -137,10 +271,16 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: base_url=base_url, request_timeout_seconds=60, ) as harness: - result = harness.run("reply with the smoke text", session_id="custom-smoke") - assert result.status == "ok", result - assert result.final_response == EXPECTED_TEXT, result.final_response - assert_session_log(sessions, root) + text_result = harness.run("reply with the smoke text", session_id="custom-smoke") + code_result = harness.run(CODE_PROMPT, session_id="custom-smoke") + workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke") + assert text_result.status == "ok", text_result + assert text_result.final_response == EXPECTED_TEXT, text_result.final_response + assert code_result.status == "ok", code_result + assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response + assert workflow_result.status == "ok", workflow_result + assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response + assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) def smoke_direct(base_url: str, executable: Path) -> None: @@ -177,7 +317,7 @@ def smoke_direct(base_url: str, executable: Path) -> None: peer.read_until(lambda message: message.get("id") == "shutdown") finally: peer.close() - assert_session_log(sessions, root) + assert_session_log(sessions, root, EXPECTED_TEXT) class RuntimePeer: @@ -245,7 +385,7 @@ class RuntimePeer: self.stderr.extend(self.process.stderr) -def assert_session_log(sessions: Path, cwd: Path) -> None: +def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None: logs = list(sessions.rglob("*.jsonl")) if len(logs) != 1: raise AssertionError(f"expected one JSONL session log under {sessions}, found {logs}") @@ -253,8 +393,10 @@ def assert_session_log(sessions: Path, cwd: Path) -> None: header = json.loads(lines[0]) if header.get("cwd") != str(cwd): raise AssertionError(f"session header cwd is not absolute/canonical: {header}") - if EXPECTED_TEXT not in "\n".join(lines): - raise AssertionError(f"session log has no final response: {logs[0]}") + rendered = "\n".join(lines) + for expected in expected_texts: + if expected not in rendered: + raise AssertionError(f"session log has no {expected!r} response: {logs[0]}") if __name__ == "__main__": From b49c37f83ae6c94aca30c29cc57765a882aa5c34 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:03:25 +0800 Subject: [PATCH 16/18] docs(i18n): align single-exe terminology Add canonical Chinese bindings for build target, deploy root, peer dependency, serving surface, wheel, wrapper, and VFS so future translations have one source of truth. Apply the existing runtime, plugin, artifact, and pipeline bindings throughout every Chinese counterpart touched by this PR. Require the first Chinese use of peer dependency to retain the English term so readers can map it back to the package-manager concept without reintroducing mixed prose throughout the document. Rewrite mixed-language prose where the English term is not an identifier, while retaining package names, paths, flags, pkg, single-exe, and other literal names exactly. Explain VFS on first use and keep technical constraints readable without inventing compatibility terminology. Describe single-executable worker support as implemented behavior, including the filesystem-string entry contract and CommonJS worker artifact required by the pkg loader, and remove the obsolete bundled-but-unsupported limitation. Regenerate pairing fingerprints and the generated catalog reference so the mechanical documentation gates validate the revised English and Chinese pairs. This keeps the terminology table authoritative and prevents the two languages from drifting as the runtime documentation evolves. --- docs/config-catalog.md | 2 +- docs/i18n/terminology.md | 7 +++ ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 62 +++++++++---------- python/README.i18n.yaml | 2 +- python/README.zh.md | 18 +++--- python/sdk-runtime/README.i18n.yaml | 2 +- python/sdk-runtime/README.zh.md | 22 +++---- python/sdk/README.i18n.yaml | 2 +- python/sdk/README.zh.md | 8 +-- 11 files changed, 69 insertions(+), 62 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0183b44d37..dce169bda8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -212,7 +212,7 @@ export interface Config { } ``` -Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:29`](../packages/code-runtime/code-runtime-worker/src/index.ts) +Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:30`](../packages/code-runtime/code-runtime-worker/src/index.ts) ## `@deepseek-ai/dsh-compact-basic` diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index f95a8e3a8f..1d52a8c221 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -22,6 +22,7 @@ | RAG | RAG | 首次出现可写:检索增强生成(RAG) | | SDK | SDK | | | SSE | SSE | 首次出现可写:SSE(Server-Sent Events) | +| VFS | VFS | 首次出现写:虚拟文件系统(VFS) | | agent | agent | 首次出现可写:agent(智能体) | | agent loop | agent loop | | | backlog | backlog | 双语翻译语境指待翻清单 | @@ -52,6 +53,7 @@ | block | 块 | | | background task | 后台任务 | | | backend | 后端 | | +| build target | 构建目标 | | | capability | 能力 | | | cancel | 取消 | | | checkpoint | 检查点 | | @@ -66,6 +68,7 @@ | coverage | 覆盖率 | | | crash recovery | 崩溃恢复 | | | dispose | dispose | 首次出现可写:dispose(释放资源);正文优先保留英文 | +| deploy root | 部署根目录 | | | durability | 持久性 | | | enforcement frontier | 强制边界 | i18n 机制词:manifest `required` 清单所划的门禁生效范围 | | event log | 事件日志 | | @@ -95,6 +98,7 @@ | module | 模块 | | | orphan | 孤立 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;不要译作:孤儿 | | pairing | 配对 | | +| peer dependency | 对等依赖 | 首次出现写:对等依赖(peer dependency) | | permission | 权限 | | | persistence | 持久化 | | | pipeline | 流水线 | | @@ -110,6 +114,7 @@ | runtime | 运行时 | | | sandbox | 沙箱 | | | service | 服务 | | +| serving surface | 对外服务接口 | | | session | 会话 | | | session event | 会话事件 | | | smoke test | 冒烟测试 | | @@ -133,4 +138,6 @@ | turn | 轮次 | | | typecheck | 类型检查 | | | vocabulary | 词汇 | | +| wheel | wheel 包 | | | workflow | 工作流 | | +| wrapper | 包装层 | | diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 9440824276..8c20314d97 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 511f5f2cf8b43aac5960dfe4630f489ec3dc5086 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 6774fc7ec2d9439154305ba0ae35dceeff922a4a +2026-07-10-single-file-executable-sdk-runtime-distribution.md: ac392a80310e70c846e80b6660bab22e01acfeb2 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 021ca6a1c5c4b4214bf9dc16931c5b32a7117c9b diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 511f5f2cf8..ac392a8031 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -58,7 +58,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Disposition of worker-style plugins -`dsh-workflow-workerthread` and `dsh-code-runtime-worker` are supported inside the exe. Their built entries convert the sibling worker URL with `fileURLToPath()` and pass the resulting filesystem string to `Worker`, which is the form pkg's Worker hook resolves inside the VFS. The workflow engine keeps its data-URL bootstrap for unbuilt source execution; only its built sibling entry uses the filesystem string. The custom-config executable smoke loads both backends, invokes a real `run_code` call and a zero-agent `workflow` call, and requires each worker to return `42` from inside pkg's VFS. +`dsh-workflow-workerthread` and `dsh-code-runtime-worker` are supported inside the exe. Their built hosts convert the sibling `lib/worker.cjs` URL with `fileURLToPath()` and pass the resulting filesystem string to `Worker`, which is the form pkg's Worker hook resolves inside the VFS. The worker entries are CommonJS because that hook compiles VFS worker files as CommonJS. The workflow engine keeps its data-URL bootstrap for unbuilt source execution; only its built sibling entry uses the filesystem string. The custom-config executable smoke loads both backends, invokes a real `run_code` call and a zero-agent `workflow` call, and requires each worker to return `42` from inside pkg's VFS. ## Testing diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 6774fc7ec2..021ca6a1c5 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -6,80 +6,80 @@ Status: implemented ## 问题 -DeepSeek Harness 需要为 Python 库专门提供一个免安装 Node、可直接在目标平台运行的 SDK 分发形态:一个单文件可执行程序(下称 exe),对外提供 stdio JSON-RPC 服务面(`HarnessSdkServer`,Python SDK 的对端),且实际启动的插件与配置完全由 exe 外部输入的 `cordis.yml` 决定。 +DeepSeek Harness 需要为 Python 库专门提供一种无需安装 Node、可直接在目标平台运行的 SDK 分发形态:一个单文件可执行程序(下称 exe),通过 stdio 提供 JSON-RPC 对外服务接口(`HarnessSdkServer`,Python SDK 的对端),且实际启动的插件与配置完全由 exe 外部输入的 `cordis.yml` 决定。 -- 与 Python SDK 通信的 JSONRPC 协议已经过验证 -- 需要提供标准化的 cordis.yml 加载所有插件(ESModule)的能力 +- 与 Python SDK 通信的 JSON-RPC 协议已经过验证 +- 需要提供通过标准化 `cordis.yml` 加载所有插件(ES 模块)的能力 - 分发物要自带 Node 运行时,并支持本地源码链接的调试模式 ## 决策 ### 打包路线:@yao-pkg/pkg 的 `--sea` 模式 -exe 用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后的活跃维护 fork)的 **`--sea`(enhanced SEA)模式**打包。相比 Node 原生 SEA,pkg 在其上加装 `/snapshot` VFS 与运行时模块钩子,ESM 入口原样交给 Node 默认 ESM loader,不依赖任何 ESM→CJS 转译。 -> 实测(macos-arm64、node24 target、pkg 6.21.0):VFS 内裸包名 ESM 动态 import(含顶层 await)、CJS 互操作、`node:sqlite`、集合外包名 fail loud、VFS 外磁盘 ESM import 全部通过,`import.meta.url` 原样为 `file:///snapshot/...`。 +exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后的活跃维护 fork)的 **`--sea`(enhanced SEA)模式**打包。相比 Node 原生 SEA,pkg 在其上增加 `/snapshot` 虚拟文件系统(VFS)与运行时模块钩子,将 ESM 入口原样交给 Node 默认的 ESM loader,不依赖任何 ESM→CJS 转译。 +> 实测(macos-arm64、node24 构建目标、pkg 6.21.0):VFS 内裸包名 ESM 动态 `import()`(含顶层 `await`)、CJS 互操作、`node:sqlite`、集合外包名明确报错、VFS 外磁盘 ESM `import()` 全部通过,`import.meta.url` 原样为 `file:///snapshot/...`。 -`--sea` 要求 target ≥ node22,exe 统一以 node24 为 target;单次 pkg 调用只打一个 target,多平台各调一次。 +`--sea` 要求构建目标 ≥ node22,exe 统一以 node24 为构建目标;每次 pkg 调用只打包一个构建目标,多平台各调用一次。 -术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「snapshot」(ACP replay goldens、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 +术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的“快照”(ACP 回放 golden、`$DSH_SNAPSHOT`)无关,本文用“VFS”指前者。 -### serving 面是插件:ui/jsonrpc + ui/jsonrpc-agent 两包 +### 对外服务接口也是插件:ui/jsonrpc + ui/jsonrpc-agent 两包 -确定性协议实现(`server.ts` / `transport.ts`)按 `ui/acp` + `ui/acp-agent` 的既有模式落为两包——serving 面本身也是插件: +确定性协议实现(`server.ts` / `transport.ts`)按 `ui/acp` + `ui/acp-agent` 的既有模式落为两包——对外服务接口本身也是插件: -- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件,apply 时在进程 stdio 上挂 `HarnessSdkServer` + 行式 JSON-RPC transport,disposal 走 `ctx.effect()`。是否服务由 `cordis.yml` 决定;一份 yml 没挂它就是一个不 serve 的合法进程。协议级退出归插件(`shutdown` 请求应答后 dispose 自身 fiber 再 `exit(0)`;HMR 式卸载只停服务不退进程)。 -- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md)(`@deepseek-ai/dsh-jsonrpc-agent`):薄 app bin——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`,boot 完即毕,server 由 yml 里的 `dsh-jsonrpc` 条目带起。依赖只有 app-boot。进程级退出归 bin(stdin EOF/SIGTERM → dispose 后 0,SIGINT → 130)。 +- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答 `shutdown` 请求后 dispose 自身 fiber,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。 +- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md)(`@deepseek-ai/dsh-jsonrpc-agent`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 -配置发现两通道,缺失即报错:`DSH_CORDIS_CONFIG` 环境变量优先(SDK 客户端约定),argv 位置参数次之;无任何默认路径或内置回退——「实际启动的插件由外部 cordis.yml 决定」是硬语义。 +配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——“实际启动的插件由外部 `cordis.yml` 决定”是硬语义。 -### 插件解析:VFS 装真实包树,闭包清单即 deploy root +### 插件解析:VFS 装载真实包树,闭包清单就是部署根目录 -exe 的 VFS 内是**构建产物形态的真实包树**(各包 `lib/` + 真实 `node_modules`),Loader 解析插件名走标准动态 `import()`:裸包名从 VFS 内 Loader 位置沿 `node_modules` 向上解析,天然落在 VFS 内。封闭集不需要白名单代码——集合就是 VFS 里装了什么,引用集合外的名字 import 失败。 +exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -deploy root 是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm workspace 成员、零代码纯依赖清单)——「exe 装什么插件」与「Python runtime 分发什么」的合一事实源。往 exe 加插件 = 清单加一行依赖再重打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部 workspace 包,要求每个非 optional workspace peer 显式列在 runtime root,并报告「引用包 → 缺失 peer」的完整链路;CI static、pre-push 与 single-exe 构建都会在打包前运行该门禁。deploy 还会按各包 `files` 打包,因此 tsdown 拆出的共享 chunk 必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖清单),也是“exe 安装哪些插件”与“Python 运行时分发什么”的统一事实源。向 exe 添加插件,就是在清单中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;CI 静态检查、pre-push 与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):runtime 闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/` 并拷回 runtime 目录。CI 把它们作为测试中间输入,只保留对应的平台 wheel。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。CI 将这些文件作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 -CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,并缓存 `~/.pkg-cache`;macOS ad-hoc 签名由 pkg 处理。每个平台都以 mock SSE 模型分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再以 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应,最后把 release 形态的 wheel 安装到干净 venv 中并在不传 `runtime_bin` 的情况下运行;Linux 还检查 GLIBC 依赖并在 manylinux 2.28 容器中运行。整次运行只保留 4 个产物,每个只含一个发布文件:平台无关的 SDK wheel 与 3 个原生 runtime wheel;裸 exe 和源码 bundle 只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` tag 流水线,构建一个 SDK wheel 与 3 个原生 runtime wheel,再由单个串行 job 校验并发布这 4 个文件到项目 PyPI 注册表。Windows 是非目标。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR 添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。整次运行只保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包和 3 个原生运行时 wheel 包;裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 -### Python SDK 分发:双载体,exe 为生产、node 为开发 +### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk`(客户端)+ `python/sdk-runtime`(运行时载体包)。runtime 包数据目录三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe、构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 自动解析**只找 exe**;node 载体仅显式 `DSH_RUNTIME_MODE=node` 启用(跑 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需系统 node ≥22.19),定位是本仓库成员的开发验证通道,不进 wheel 分发物。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录 `package.json` 读取权威的稳定 `X.Y.Z`,并以该版本暂存两个包,同时让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布 tag 只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel;只提供 wheel 的 runtime 包恰好包含一个 exe,tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用 tag、混合可执行载荷以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;只提供 wheel 包的运行时包恰好包含一个 exe,标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用标签、混合可执行载荷以及不支持的平台。 -exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢复:调用方没给 `cordis`、没显式指定 runtime、环境无 `DSH_CORDIS_CONFIG` 时,客户端把检入的默认 `cordis.yml`(agent-core + 预载 llm-deepseek + JSONL 持久化 + bash-local + `dsh-jsonrpc` serving 条目,`!!js` 环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 +exe“必须显式配置”的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 ### 命名血统 -`@deepseek-ai/dsh-jsonrpc-agent`(包)→ `dsh-jsonrpc-agent`(bin)→ `dsh-jsonrpc-agent-pkg`(闭包清单;无 scope 前缀,刻意避开 constraints 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。wire `serverInfo.name` 保持 `deepseek-harness-sdk-runtime`(协议稳定值);Python dist 名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 +`@deepseek-ai/dsh-jsonrpc-agent`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包清单;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 ## 工作线程插件 -exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个后端的构建入口都通过 `fileURLToPath()` 转换相邻 worker 的 URL,再将所得文件系统字符串传给 `Worker`;pkg 的 Worker 钩子可以用这种形式解析 VFS 内文件。工作流引擎在未构建的源码执行中仍保留 data URL 引导程序,只有构建后的相邻入口使用文件系统字符串。自定义配置的可执行文件冒烟测试会加载两个后端,实际调用 `run_code` 与不启动 agent 的 `workflow`,并要求两个 worker 都从 pkg 的 VFS 内返回 `42`。 +exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个后端构建后的宿主都通过 `fileURLToPath()` 转换相邻 `lib/worker.cjs` 的 URL,再将所得文件系统字符串传给 `Worker`;pkg 的 Worker 钩子可以用这种形式解析 VFS 内文件。该钩子会把 VFS 内的工作线程文件作为 CommonJS 编译,所以工作线程入口采用 CommonJS。工作流引擎在未构建的源码执行中仍保留 `data:` URL 引导程序,只有构建后的相邻入口使用文件系统字符串。自定义配置的可执行文件冒烟测试会加载两个后端,实际调用 `run_code` 与不启动 agent 的 `workflow`,并要求两个工作线程都从 pkg 的 VFS 内返回 `42`。 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的 keyless pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 cwd 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议对着 mock 端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实 worker 文件执行 `run_code` 和不启动 agent 的 `workflow`。随后把平台 wheel 安装进干净 venv,在不传 `runtime_bin` 的情况下运行。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在“决策”各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对模拟端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。JSON-RPC 协议不在 ACP 快照体系内,因此没有快照层(这是明确指出的空缺,并非遗漏)。 -手工驱动注意:bin 视 stdin EOF 为「客户端已走」并立即 dispose,短命管道会中止在飞回合——管道驱动必须保持 stdin 打开到回合结束。 +手工驱动注意:`bin` 将 stdin EOF 视为“客户端已离开”并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 ## 曾考虑的替代方案 -**Node 原生 SEA 裸用。** 注入主脚本必须是 CJS 单文件、blob 内无文件系统与模块解析,动态 import 裸包名无从解析,只能把插件静态编译进主脚本并手工注册——绕过标准模块解析、插件集合被硬编码,与「配置决定一切」相悖。最终路线实为「官方 SEA 地基 + pkg 的 VFS/模块钩子层」,否掉的是裸用而非 SEA 本身。 +**裸用 Node 原生 SEA。** 注入的主脚本必须是 CJS 单文件,blob 内没有文件系统与模块解析,因此动态 `import()` 无法解析裸包名;只能把插件静态编译进主脚本并手工注册。这会绕过标准模块解析并硬编码插件集合,与“配置决定一切”相悖。最终路线实际是“官方 SEA 基础 + pkg 的 VFS/模块钩子层”;否决的是裸用方式,而不是 SEA 本身。 -**pkg standard 模式。** PoC 判死,非取舍:它把 ESM 经 esbuild 转 CJS + V8 字节码,运行时 vm 编译未接动态 import 回调,任何 `import()` 一律抛 `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`,`--options experimental-require-module` 无效;且依赖社区补丁 Node 二进制(macos-arm64 无预编译,现场源码编译约 10 分钟)。对本仓库架构零可行性。 +**pkg 标准模式。** PoC 证明该模式不可行,而非权衡后放弃:它通过 esbuild 将 ESM 转为 CJS + V8 字节码,但运行时 VM 编译没有接入动态 `import()` 回调,任何 `import()` 都会抛出 `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`,`--options experimental-require-module` 也无效;此外,它依赖社区补丁版 Node 二进制(macos-arm64 没有预编译版本,现场从源码编译约需 10 分钟)。该模式不适用于本仓库架构。 **每包 ESM→CJS 预打包进 VFS。** 保持真实解析语义、只降级模块格式的折中;`--sea` 直接通过实测,这层构建复杂度无需引入。 -**jsonrpc-agent 背全量闭包依赖。** app bin 声明 53+ 个它不 import 的依赖,「打包清单」冒充真实依赖关系,且迫使 constraints 为其开 cordis-in-dependencies 与 files-通配两个例外。闭包清单落在 python 侧的清单包上,constraints 无需任何例外,bin 保持与 acp-agent 同构的正常包形状。 +**让 jsonrpc-agent 承担完整闭包依赖。** 应用入口将声明 53 个以上自身并不 `import()` 的依赖,使“打包清单”伪装成真实依赖关系,还会迫使 `constraints` 为其增加 `cordis-in-dependencies` 与 `files` 通配符两个例外。将闭包清单放在 Python 侧的清单包后,`constraints` 不需要任何例外,`bin` 也能保持与 acp-agent 同构的正常包形状。 -**开放插件集(磁盘加载用户插件)。** 本期封闭集;PoC 顺带证实 VFS 外磁盘 ESM import 可行(经 `ctx.baseUrl` 相对路径通道),列为后续演进,需另解外部插件与 exe 内 cordis 实例的共享问题。 +**开放插件集(从磁盘加载用户插件)。** 本期采用封闭集;PoC 同时证实,可以通过 `ctx.baseUrl` 相对路径通道从 VFS 外的磁盘 `import()` ESM。该能力列为后续演进,届时还需解决外部插件与 exe 内 Cordis 实例的共享问题。 ## 后果 -**买到的**:目标平台零依赖单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译无注册表);serving 面、插件集、配置三者全部收敛到 `cordis.yml` + 一份依赖清单两个事实源;exe 与 node 双载体同树同语义,开发验证不必等打包;官方 Node 二进制消除了补丁二进制供应链顾虑。 +**买到的**:目标平台零依赖的单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖清单这两个事实源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 -**付出的**:产物 174MB 级且源码原样进 blob(无字节码混淆,闭源分发诉求需另行评估);pkg 的 VFS/模块钩子层仍是社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级走显式改动);`--sea` 每个 target 调用一次(与 CI 每平台一腿匹配,本地多平台构建串行)。 +**付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级需要显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 061c15aa92..64e10faf58 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 35ed645ce0e4d5e4c88c8aae2fe33d94aea79b3e -README.zh.md: bddb709114df2e46bbea6e6faeb660bffa30df2b +README.zh.md: 214c5cd1900e52f9fe479247f9940a97bb22766b diff --git a/python/README.zh.md b/python/README.zh.md index bddb709114..214c5cd190 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -以子进程方式驱动 DeepSeek Harness 的 Python 包:客户端 SDK spawn `dsh-jsonrpc-agent` 二进制,在 stdio 上以换行分隔的 JSON-RPC 与之通信。运行时载体是本仓库产出的单文件可执行文件;设计、构建与验收细节见 [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)。 +以子进程方式驱动 DeepSeek Harness 的 Python 包:客户端 SDK spawn `dsh-jsonrpc-agent` 二进制,并通过 stdio 上按行分隔的 JSON-RPC 与之通信。运行时载体是本仓库产出的单文件可执行文件;设计、构建与验收细节见 [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)。 ## 包 -| 目录 | Dist / 模块 | 职责 | +| 目录 | 分发名 / 模块 | 职责 | |---|---|---| | [sdk](sdk/) | `deepseek-harness` / `deepseek_harness` | 客户端 SDK:高层回合 API `DeepSeekHarness` 与低层 JSON-RPC 客户端 `HarnessClient` | | [sdk-runtime](sdk-runtime/) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 运行时载体:定位内置的运行时二进制,并携带默认的 agent(智能体)配置 | @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到可执行文件。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的二进制,但只保留 4 个发布 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发用的 node 载体(见下文「对着 Node 源码运行」)。 +产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到可执行文件。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的二进制,但只保留 4 个发布用 wheel 包。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 ## 用可执行文件验证 SDK @@ -45,12 +45,12 @@ with DeepSeekHarness() as harness: 两种方式,均面向仓库成员: -- **已构建的 node 载体**——设置 `DSH_RUNTIME_MODE=node`,SDK 会用系统 Node(>= 22.19)运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`。这棵树每次跑构建脚本都会刷新,与 exe 打进 VFS 的是同一份依赖闭包,插件语义一致。不会被自动选中,也不进入分发物。 -- **未构建的源码(tsx)**——把客户端直接指向 bin 的 TypeScript 源码,用于编辑-运行循环与调试:`launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")`,`cwd` 设为仓库根,另经 `cordis=...` 传入配置(或依赖默认配置注入)。[sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) 是现成范例。 +- **已构建的 `node` 载体**——设置 `DSH_RUNTIME_MODE=node`,SDK 会用系统 Node(>= 22.19)运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`。这棵树每次运行构建脚本都会刷新,与 exe 打入 pkg 虚拟文件系统(VFS)的是同一份依赖闭包,因此插件语义一致。它不会被自动选中,也不进入分发物。 +- **未构建的源码(tsx)**——把客户端直接指向 `bin` 的 TypeScript 源码,用于编辑、运行和调试:`launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")`,`cwd` 设为仓库根,再通过 `cordis=...` 传入配置(或使用默认配置注入)。[sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) 是现成范例。 ## 分发 Python 包 -根目录 [`package.json`](../package.json) 的版本是两个 Python 分发物的权威版本。统一暂存脚本读取这个版本并注入两个 wheel,同时在 SDK 元数据中钉死相同版本的 `deepseek-harness-runtime-bin==X.Y.Z`;可选的 `python-vX.Y.Z` 发布 tag 只有与仓库版本匹配时才会被接受。纯 SDK wheel 只构建一次,runtime wheel 则在每个原生平台各构建一个: +根目录 [`package.json`](../package.json) 的版本是两个 Python 分发物的权威版本。统一暂存脚本读取这个版本并注入两个 wheel 包,同时在 SDK 元数据中钉死相同版本的 `deepseek-harness-runtime-bin==X.Y.Z`;可选的 `python-vX.Y.Z` 发布标签只有与仓库版本匹配时才会被接受。纯 SDK wheel 包只构建一次,运行时 wheel 包则在每个原生平台各构建一个: ```sh version="$(node -p "require('./package.json').version")" @@ -59,11 +59,11 @@ python scripts/build-python-release.py --package runtime --platform macos-arm64 pip install --find-links dist-python deepseek-harness=="$version" ``` -runtime 分发物只提供 wheel,并拒绝 sdist 构建、缺失可执行文件以及混合平台载荷。三个 wheel tag 分别是 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;SDK 保持 `py3-none-any`。匹配的 `python-vX.Y.Z` tag 流水线统一构建并发布这 4 个互不冲突的文件,因此常规的 `pip install deepseek-harness==X.Y.Z` 会选中匹配平台的 runtime wheel,`import deepseek_harness` 不需要 `runtime_bin`。 +运行时分发物只提供 wheel 包,并拒绝 sdist 构建、缺失可执行文件以及混合平台载荷。三个 wheel 包标签分别是 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;SDK 保持 `py3-none-any`。匹配的 `python-vX.Y.Z` 标签流水线统一构建并发布这 4 个互不冲突的文件,因此常规的 `pip install deepseek-harness==X.Y.Z` 会选中匹配平台的运行时 wheel 包,`import deepseek_harness` 不需要 `runtime_bin`。 ## 零配置语义 -运行时二进制本身始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为首个 argv 参数的配置路径),没有内置兜底,也只启动配置里列出的东西。零配置是 SDK 包装层的行为:调用方没有走任何显式通道时,客户端把 runtime 包检入的默认配置([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml))注入 `DSH_CORDIS_CONFIG`;任一显式通道存在即胜出并禁用注入。注入条件的完整定义见 [sdk README](sdk/README.md),默认配置的内容与硬语义见 [sdk-runtime README](sdk-runtime/README.md)。 +运行时二进制本身始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为首个 argv 参数的配置路径),没有内置兜底,也只启动配置里列出的内容。零配置是 SDK 包装层的行为:调用方没有使用任何显式通道时,客户端把运行时包检入的默认配置([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml))注入 `DSH_CORDIS_CONFIG`;任一显式通道存在即优先采用,并禁用注入。注入条件的完整定义见 [sdk README](sdk/README.md),默认配置的内容与硬语义见 [sdk-runtime README](sdk-runtime/README.md)。 可执行文件也支持直接调用;在 NDJSON JSON-RPC 交互期间保持 stdin 打开,并显式提供配置: @@ -73,4 +73,4 @@ DSH_CORDIS_CONFIG=/absolute/path/cordis.yml ./dsh-jsonrpc-agent-pkg-macos-arm64 ## 测试布局 -`test_client.py` 完全无需 key(对端是 Python 假运行时)。`test_bundled_runtime.py` 逐个启动内置载体,某载体产物缺失时对应用例跳过。`test_runtime_resolution.py` 覆盖载体解析规则,不 spawn 任何进程。 +`test_client.py` 完全无需密钥(对端是 Python 假运行时)。`test_bundled_runtime.py` 逐个启动内置载体,某个载体产物缺失时跳过对应用例。`test_runtime_resolution.py` 覆盖载体解析规则,不 spawn 任何进程。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 542b26c719..f369d13463 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 4beac57526761bb150e90b60f0030ed311c4034d -README.zh.md: 3c715bce17ed1630ef0439b042d2b67047968c55 +README.zh.md: c0cc0eef6a9b569105408d2f2e6321795493036a diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 3c715bce17..c0cc0eef6a 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -1,29 +1,29 @@ -# DeepSeek Harness 运行时 wheel +# DeepSeek Harness 运行时 wheel 包 [English](README.md) | 中文 -Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 +Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 ## 运行时载体 -两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 gitignore: +两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一进入 wheel 分发物的载体;本包不发布 sdist。 -- **node(仅限开发)**——`runtime/node/` 下的完整 deploy 闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 +- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 -两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的 deploy root——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 +两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 -载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台 runtime wheel。该工作流只保留 wheel,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 +载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 只包含一个可执行文件。固定 tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台 tag。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布 tag 必须与其匹配。 +每个 wheel 包只包含一个可执行文件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 ## 解析 API -- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体;node 载体没有单一路径的等价物,经由上面的 argv 元组启动)。 +- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,`node` 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 `node` 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体;`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动)。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 ## 零配置设计 -运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。bin(`dsh-jsonrpc-agent`)只启动配置里列出的插件;服务面(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它启动出的 agent 没有对外通道。本包检入 `runtime/cordis.yml`(JSON-RPC 服务条目、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash,各项由 SDK 设置的 `DSH_*` 环境变量参数化);调用方未走任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。零配置因此是 wrapper 里一次显式、可见的参数传递,不是运行时里的隐藏回退。 +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入 `runtime/cordis.yml`(JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash,各项由 SDK 设置的 `DSH_*` 环境变量参数化);调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 99aa49e0cc..a294dc1194 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 60540376c5fd85b0852e204bc8bad3f01c849de5 -README.zh.md: 7148a45c8f4bd22aaa28fc15ac9cd589cba13dfb +README.zh.md: 241c06057889f1aa4add6fc54024fba92bd19429 diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 7148a45c8f..241c060578 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -4,7 +4,7 @@ 通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接用真实模型端点,也可以在跑基准测试时把它们指向本地代理。 -安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel。因此常规入口不需要传可执行文件参数: +安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: ```py from deepseek_harness import DeepSeekHarness @@ -15,7 +15,7 @@ with DeepSeekHarness() as harness: `DeepSeekHarness` 会保留延迟启动的运行时子进程,以供多次调用复用。请像上例一样将其用作上下文管理器,或在用完后显式调用 `close()`。 -默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己用插件组合需要在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 +默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、`agent-core`、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己的插件组合,请在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 ```py from deepseek_harness import DeepSeekHarness @@ -29,6 +29,6 @@ with DeepSeekHarness( `TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 -同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此低层客户端的默认启动同样享有它:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值当作缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 +同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 -`cwd` 与 `runtime_cwd` 会在启动子进程、注入环境变量和协议握手前解析为绝对路径。公开 API 只暴露真正生效的选项:部署 persona 与持久化配置归 `cordis.yml` 管理,而 `session_root` 继续作为设置 `DSH_SESSION_ROOT` 的高层便捷选项。 +`cwd` 与 `runtime_cwd` 会在启动子进程、注入环境变量和协议握手前解析为绝对路径。公开 API 只暴露真正生效的选项:部署的角色设定与持久化配置归 `cordis.yml` 管理,而 `session_root` 继续作为设置 `DSH_SESSION_ROOT` 的高层便捷选项。 From cf892265102468b5e6c905f9772bf61af8caff6c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:31:26 +0800 Subject: [PATCH 17/18] test(pkg): snapshot advanced Python SDK executable flow The existing assertion-only executable smoke proved selected outputs but could not detect drift across the integrated Python SDK, JSON-RPC notification stream, and persisted session shape. Keep this separate from ACP snapshots because it must launch the actual platform-native packaged executable through the Python SDK. The deterministic model drives Cordis dynamic tool mounting, a Code Mode worker dispatch, direct spawn delegation, workflow-worker delegation, plugin disposal, and the parent/child persistence lineage. Commit four portable goldens for the SDK result and three JSONL logs. Normalize timestamps, temporary paths, opaque session and agent identifiers, and bulky request headers while retaining ordering, tool names and arguments, header deltas, results, lineage, and final responses so all native build legs compare the same behavior. Run the comparison in the label-gated executable build workflow and document the current coverage in the paired implemented RFC. --- .../workflows/build-exe-for-python-sdk.yml | 2 +- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- scripts/smoke-python-runtime.py | 419 ++- .../advanced/result.json | 2566 +++++++++++++++++ .../advanced/session.1.jsonl | 13 + .../advanced/session.2.jsonl | 13 + .../advanced/session.jsonl | 66 + 9 files changed, 3068 insertions(+), 19 deletions(-) create mode 100644 scripts/snapshots/python-sdk-single-exe/advanced/result.json create mode 100644 scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl create mode 100644 scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl create mode 100644 scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 56099cb137..5bf4f7f239 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -218,7 +218,7 @@ jobs: echo "exe=$exe" >> "$GITHUB_OUTPUT" echo "wheel=$wheel" >> "$GITHUB_OUTPUT" - - name: Full-turn SDK, custom cordis, and direct-binary smoke + - name: Full-turn SDK, executable snapshot, and direct-binary smoke run: >- uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 8c20314d97..db8c7eb8ee 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: ac392a80310e70c846e80b6660bab22e01acfeb2 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 021ca6a1c5c4b4214bf9dc16931c5b32a7117c9b +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 4170d9f63773ef998bc5393fabc98a2b9fcba2fa +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 7d80fb572ea36010df4829121fabe8cf66944f6b diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index ac392a8031..4170d9f637 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. Its platform wheel is then installed in a clean venv and run without `runtime_bin`. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The comparison normalizes the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 021ca6a1c5..7d80fb572e 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在“决策”各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对模拟端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。JSON-RPC 协议不在 ACP 快照体系内,因此没有快照层(这是明确指出的空缺,并非遗漏)。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在“决策”各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对模拟端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个由 spawn 提供方直接启动的 subagent(子 agent)和一个会通过 spawn 启动第二个子 agent 的工作流,随后卸载该插件。比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为“客户端已离开”并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 47de59dd2e..2c61d07c19 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -"""Keyless full-turn smoke for the SDK wrapper and direct NDJSON runtime use.""" +"""Keyless full-turn and snapshot smoke for the Python SDK runtime.""" from __future__ import annotations import argparse +import difflib import json import os import queue @@ -13,7 +14,10 @@ import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from typing import Callable +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from deepseek_harness import TurnResult EXPECTED_TEXT = "runtime smoke ok" @@ -21,6 +25,32 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" +SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario." +SNAPSHOT_SESSION_ID = "advanced-executable" +SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else." +SNAPSHOT_WORKFLOW_CHILD_PROMPT = "Reply with exactly WORKFLOW_CHILD_OK and nothing else." +SNAPSHOT_FINAL_TEXT = "ADVANCED_EXECUTABLE_OK" +SNAPSHOT_MOUNT_CODE = """\ +return (ctx) => { + harness.registerTool(ctx, harness.defineTool({ + name: 'snapshot_double', + description: 'Double a number for executable snapshot verification.', + parameters: { value: { type: 'number', required: true } }, + async execute(args) { + return [{ type: 'text', text: String(args.value * 2) }] + } + })) +} +""" +SNAPSHOT_WORKFLOW_SCRIPT = ( + "phase('Delegate')\n" + f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n" + "return { reply }" +) +SNAPSHOT_DIRECTORY = ( + Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced" +) +SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl") CUSTOM_CORDIS = """\ - id: jsonrpc name: '@deepseek-ai/dsh-jsonrpc' @@ -41,17 +71,27 @@ CUSTOM_CORDIS = """\ name: '@deepseek-ai/dsh-code-runtime-worker' - id: subagents name: '@deepseek-ai/dsh-subagent' +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn +- id: subagent-tool + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn - id: workflow-engine name: '@deepseek-ai/dsh-workflow-workerthread' config: provider: spawn - id: workflow-tool name: '@deepseek-ai/dsh-tool-workflow' +- id: cordis-tool + name: '@deepseek-ai/dsh-tool-cordis' """ class MockModelHandler(BaseHTTPRequestHandler): - """Return deterministic text and worker-tool streaming completions.""" + """Return deterministic text, worker, and orchestration completions.""" requests: list[dict[str, object]] = [] @@ -82,8 +122,11 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: raise AssertionError(f"model request has an invalid latest message: {body}") if latest.get("role") == "tool": - tool_name = latest_tool_name(messages) - tool_text = json.dumps(latest.get("content")) + call_id, tool_name = latest_tool_call(messages) + tool_text = message_text(latest.get("content")) + advanced = advanced_tool_followup(body, call_id, tool_name, tool_text) + if advanced is not None: + return advanced if "42" not in tool_text: raise AssertionError(f"{tool_name} worker returned no expected value: {latest}") if tool_name == "run_code": @@ -93,6 +136,17 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: raise AssertionError(f"unexpected tool follow-up: {tool_name}") prompt = message_text(latest.get("content")) + if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: + return text_chunks("DIRECT_CHILD_OK") + if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: + return text_chunks("WORKFLOW_CHILD_OK") + if prompt == SNAPSHOT_PROMPT: + assert_advertised_tool(body, "cordis_mount") + return tool_call_chunks( + "advanced-mount", + "cordis_mount", + {"code": SNAPSHOT_MOUNT_CODE}, + ) if prompt == CODE_PROMPT: assert_advertised_tool(body, "run_code") return tool_call_chunks("call-code-worker", "run_code", {"code": "return 6 * 7"}) @@ -112,6 +166,70 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(EXPECTED_TEXT) +def advanced_tool_followup( + body: dict[str, object], + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Advance the executable snapshot's deterministic parent tool chain.""" + if not call_id.startswith("advanced-"): + return None + if call_id == "advanced-mount" and tool_name == "cordis_mount": + if "mounted dyn-1" not in tool_text: + raise AssertionError(f"cordis_mount returned no mount id: {tool_text}") + assert_advertised_tool(body, "run_code") + assert_advertised_tool(body, "snapshot_double") + return tool_call_chunks( + "advanced-code", + "run_code", + {"code": "return await tools.snapshot_double({ value: 21 })"}, + ) + if call_id == "advanced-code" and tool_name == "run_code": + if "42" not in tool_text: + raise AssertionError(f"run_code returned no dynamic-tool value: {tool_text}") + assert_advertised_tool(body, "subagent") + return tool_call_chunks( + "advanced-direct-child", + "subagent", + { + "description": "Check direct child", + "prompt": SNAPSHOT_DIRECT_CHILD_PROMPT, + }, + ) + if call_id == "advanced-direct-child" and tool_name == "subagent": + if "DIRECT_CHILD_OK" not in tool_text: + raise AssertionError(f"subagent returned no expected child value: {tool_text}") + assert_advertised_tool(body, "workflow") + return tool_call_chunks( + "advanced-workflow", + "workflow", + { + "script": SNAPSHOT_WORKFLOW_SCRIPT, + "meta": { + "name": "advanced-exe-snapshot", + "description": "exercise one packaged workflow child", + }, + }, + ) + if call_id == "advanced-workflow" and tool_name == "workflow": + if "WORKFLOW_CHILD_OK" not in tool_text: + raise AssertionError(f"workflow returned no expected child value: {tool_text}") + assert_advertised_tool(body, "cordis_unmount") + return tool_call_chunks( + "advanced-unmount", + "cordis_unmount", + {"id": "dyn-1"}, + ) + if call_id == "advanced-unmount" and tool_name == "cordis_unmount": + if "unmounted dyn-1" not in tool_text: + raise AssertionError(f"cordis_unmount returned no disposal result: {tool_text}") + if "snapshot_double" in advertised_tool_names(body): + raise AssertionError("snapshot_double remained advertised after cordis_unmount") + return text_chunks(SNAPSHOT_FINAL_TEXT) + raise AssertionError(f"unexpected advanced tool follow-up: {call_id} {tool_name}: {tool_text}") + + def text_chunks(text: str) -> list[dict[str, object]]: """Build a complete streaming text response.""" return [ @@ -147,8 +265,8 @@ def tool_call_chunks(call_id: str, name: str, arguments: dict[str, object]) -> l ] -def latest_tool_name(messages: list[object]) -> str: - """Find the assistant tool call paired with the latest tool result.""" +def latest_tool_call(messages: list[object]) -> tuple[str, str]: + """Find the assistant call id and name paired with the latest tool result.""" for message in reversed(messages[:-1]): if not isinstance(message, dict): continue @@ -159,8 +277,13 @@ def latest_tool_name(messages: list[object]) -> str: if not isinstance(call, dict): continue function = call.get("function") - if isinstance(function, dict) and isinstance(function.get("name"), str): - return function["name"] + call_id = call.get("id") + if ( + isinstance(call_id, str) + and isinstance(function, dict) + and isinstance(function.get("name"), str) + ): + return call_id, function["name"] raise AssertionError(f"tool result has no preceding assistant tool call: {messages}") @@ -177,8 +300,8 @@ def message_text(content: object) -> str: return "" -def assert_advertised_tool(body: dict[str, object], expected: str) -> None: - """Require the packaged deployment to expose the requested tool.""" +def advertised_tool_names(body: dict[str, object]) -> set[str]: + """Return the model-facing tool names advertised on one request.""" tools = body.get("tools") if not isinstance(tools, list): raise AssertionError(f"model request advertised no tools: {body}") @@ -189,6 +312,12 @@ def assert_advertised_tool(body: dict[str, object], expected: str) -> None: function = tool.get("function") if isinstance(function, dict) and isinstance(function.get("name"), str): names.add(function["name"]) + return names + + +def assert_advertised_tool(body: dict[str, object], expected: str) -> None: + """Require the packaged deployment to expose the requested tool.""" + names = advertised_tool_names(body) if expected not in names: raise AssertionError(f"model request did not advertise {expected}: {names}") @@ -211,11 +340,18 @@ class MockModel: def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--scenario", choices=("all", "sdk-default", "sdk-custom", "direct"), default="all") + parser.add_argument( + "--scenario", + choices=("all", "sdk-default", "sdk-custom", "sdk-snapshot", "direct"), + default="all", + ) parser.add_argument("--exe", type=Path) + parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "direct"} and args.exe is None: - parser.error("--exe is required for custom and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-snapshot", "direct"} and args.exe is None: + parser.error("--exe is required for custom, snapshot, and direct scenarios") + if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: + parser.error("--update-snapshots requires --scenario sdk-snapshot or all") if args.exe is not None and not args.exe.is_file(): parser.error(f"runtime executable does not exist: {args.exe}") @@ -225,6 +361,9 @@ def main() -> None: if args.scenario in {"all", "sdk-custom"}: assert args.exe is not None smoke_sdk_custom(model.url, args.exe.resolve()) + if args.scenario in {"all", "sdk-snapshot"}: + assert args.exe is not None + smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) if args.scenario in {"all", "direct"}: assert args.exe is not None smoke_direct(model.url, args.exe.resolve()) @@ -283,6 +422,49 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) +def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: + """Drive and compare the advanced SDK/executable behavioral snapshot.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-snapshot-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(CUSTOM_CORDIS) + with DeepSeekHarness( + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID) + + assert result.status == "ok", result + assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response + methods = [notification.method for notification in result.notifications] + if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2: + raise AssertionError(f"advanced snapshot emitted unexpected subagent lifecycle: {methods}") + if not any(event.get("type") == "tool/code-dispatch" for event in result.events): + raise AssertionError("advanced snapshot emitted no tool/code-dispatch event") + + logs = read_session_logs(sessions) + child_ids = snapshot_child_ids(result) + expected_ids = {SNAPSHOT_SESSION_ID, *child_ids} + if set(logs) != expected_ids: + raise AssertionError(f"advanced snapshot expected parent plus two child logs: {sorted(logs)}") + if "DIRECT_CHILD_OK" not in render_jsonl(logs[child_ids[0]]): + raise AssertionError("first advanced child log has no direct-subagent result") + if "WORKFLOW_CHILD_OK" not in render_jsonl(logs[child_ids[1]]): + raise AssertionError("second advanced child log has no workflow-subagent result") + + files = build_snapshot_files(result, logs, child_ids, root) + compare_snapshot_files(files, update_snapshots) + + def smoke_direct(base_url: str, executable: Path) -> None: with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary: root = Path(temporary).resolve() @@ -399,5 +581,214 @@ def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None: raise AssertionError(f"session log has no {expected!r} response: {logs[0]}") +def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]: + """Parse every persisted JSONL session into a map keyed by header id.""" + logs: dict[str, list[dict[str, object]]] = {} + for path in sorted(sessions.rglob("*.jsonl")): + records = [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line + ] + if not records or records[0].get("type") != "session": + raise AssertionError(f"session log has no header: {path}") + session_id = records[0].get("id") + if not isinstance(session_id, str): + raise AssertionError(f"session log header has no string id: {path}") + if session_id in logs: + raise AssertionError(f"duplicate persisted session id: {session_id}") + logs[session_id] = records + return logs + + +def snapshot_child_ids(result: "TurnResult") -> list[str]: + """Return the two child session ids in their SDK notification order.""" + child_ids: list[str] = [] + for notification in result.notifications: + if notification.method != "subagent.started": + continue + payload = notification.payload + if payload.get("parentSessionId") != SNAPSHOT_SESSION_ID: + continue + child_id = payload.get("childSessionId") + if isinstance(child_id, str) and child_id not in child_ids: + child_ids.append(child_id) + if len(child_ids) != 2: + raise AssertionError(f"advanced snapshot expected two child session ids: {child_ids}") + return child_ids + + +def build_snapshot_files( + result: "TurnResult", + logs: dict[str, list[dict[str, object]]], + child_ids: list[str], + cwd: Path, +) -> dict[str, str]: + """Render the SDK result and three persisted logs into stable goldens.""" + replacements = [(str(cwd), "{{cwd}}"), (SNAPSHOT_SESSION_ID, "{{parent}}")] + for index, child_id in enumerate(child_ids, start=1): + replacements.append((child_id, f"{{{{child-{index}}}}}")) + agent_id = snapshot_agent_id(result, child_id) + replacements.append((agent_id, f"{{{{agent-{index}}}}}")) + replacements.sort(key=lambda pair: len(pair[0]), reverse=True) + + result_value = { + "session_id": result.session_id, + "status": result.status, + "final_response": result.final_response, + "events": result.events, + "notifications": [ + {"method": notification.method, "payload": notification.payload} + for notification in result.notifications + ], + "session_root": result.session_root, + } + normalized_result = normalize_snapshot_value(result_value, replacements) + files = { + "result.json": json.dumps(normalized_result, indent=2, ensure_ascii=False) + "\n", + "session.jsonl": render_jsonl( + [normalize_snapshot_value(record, replacements) for record in logs[SNAPSHOT_SESSION_ID]] + ), + } + for index, child_id in enumerate(child_ids, start=1): + files[f"session.{index}.jsonl"] = render_jsonl( + [normalize_snapshot_value(record, replacements) for record in logs[child_id]] + ) + if tuple(files) != SNAPSHOT_FILENAMES: + raise AssertionError(f"advanced snapshot file set drifted: {tuple(files)}") + return files + + +def snapshot_agent_id(result: "TurnResult", child_id: str) -> str: + """Find the successful subagent id paired with one child session.""" + for notification in result.notifications: + if notification.method != "subagent.finished": + continue + payload = notification.payload + if payload.get("childSessionId") != child_id: + continue + if payload.get("provider") != "spawn" or payload.get("status") != "ok": + raise AssertionError(f"advanced child did not finish successfully: {payload}") + agent_id = payload.get("agentId") + if isinstance(agent_id, str): + return agent_id + raise AssertionError(f"advanced snapshot has no finished agent for child {child_id}") + + +def normalize_snapshot_value( + value: object, + replacements: list[tuple[str, str]], +) -> object: + """Scrub volatile values and bulky request headers without losing behavior.""" + if isinstance(value, str): + normalized = value + for actual, token in replacements: + normalized = normalized.replace(actual, token) + return normalized + if isinstance(value, list): + return [normalize_snapshot_value(item, replacements) for item in value] + if not isinstance(value, dict): + return value + + normalized = { + key: normalize_snapshot_value(item, replacements) + for key, item in value.items() + } + if normalized.get("type") == "session" and "createdAt" in normalized: + normalized["createdAt"] = 0 + if "seq" in normalized and "time" in normalized: + normalized["time"] = 0 + scrub_snapshot_header(normalized) + return normalized + + +def scrub_snapshot_header(value: dict[object, object]) -> None: + """Tokenize request-header bulk while retaining delta tool names.""" + data = value.get("data") + if not isinstance(data, dict): + return + if value.get("type") == "request/header": + header = data.get("header") + if not isinstance(header, dict): + return + if "system" in header: + header["system"] = "{{system}}" + tools = header.get("tools") + if isinstance(tools, list): + header["tools"] = [ + tool.get("name") if isinstance(tool, dict) else "{{tools}}" + for tool in tools + ] + if isinstance(header.get("messagePrefix"), list): + header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]] + return + if value.get("type") != "request/header-delta": + return + system = data.get("system") + if isinstance(system, dict) and isinstance(system.get("insert"), list): + system["insert"] = ["{{system}}" for _ in system["insert"]] + tools = data.get("tools") + if isinstance(tools, dict): + for key in ("added", "changed"): + if isinstance(tools.get(key), list): + tools[key] = [scrub_snapshot_tool_schema(tool) for tool in tools[key]] + if isinstance(data.get("messagePrefix"), list): + data["messagePrefix"] = ["{{messagePrefix}}" for _ in data["messagePrefix"]] + + +def scrub_snapshot_tool_schema(value: object) -> object: + """Keep a changed tool's name while tokenizing its schema bulk.""" + if not isinstance(value, dict): + return value + return { + key: item if key == "name" else "{{tools}}" + for key, item in value.items() + } + + +def render_jsonl(records: list[object]) -> str: + """Render parsed JSON values as compact, newline-terminated JSONL.""" + return "".join( + json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" + for record in records + ) + + +def compare_snapshot_files(files: dict[str, str], update: bool) -> None: + """Write or exactly compare the advanced executable snapshot files.""" + if update: + SNAPSHOT_DIRECTORY.mkdir(parents=True, exist_ok=True) + for name, content in files.items(): + (SNAPSHOT_DIRECTORY / name).write_text(content, encoding="utf-8") + print(f"smoke-python-runtime: updated snapshots in {SNAPSHOT_DIRECTORY}") + + existing = { + path.name + for path in SNAPSHOT_DIRECTORY.iterdir() + if path.is_file() + } if SNAPSHOT_DIRECTORY.is_dir() else set() + expected = set(SNAPSHOT_FILENAMES) + if existing != expected: + raise AssertionError( + "advanced snapshot files differ: " + f"missing={sorted(expected - existing)}, unexpected={sorted(existing - expected)}" + ) + for name, actual in files.items(): + expected_text = (SNAPSHOT_DIRECTORY / name).read_text(encoding="utf-8") + if actual == expected_text: + continue + diff = "".join(difflib.unified_diff( + expected_text.splitlines(keepends=True), + actual.splitlines(keepends=True), + fromfile=f"expected/{name}", + tofile=f"actual/{name}", + )) + raise AssertionError( + f"advanced executable snapshot mismatch in {name}; " + "rerun with --update-snapshots after reviewing the behavior\n" + f"{diff}" + ) + + if __name__ == "__main__": main() diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json new file mode 100644 index 0000000000..2ecfc76a2e --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -0,0 +1,2566 @@ +{ + "session_id": "{{parent}}", + "status": "ok", + "final_response": "ADVANCED_EXECUTABLE_OK", + "events": [ + { + "type": "turn/start", + "seq": 0, + "time": 0, + "data": { + "turn": 1, + "trigger": { + "kind": "message", + "source": { + "kind": "user" + } + } + } + }, + { + "type": "user/message", + "seq": 1, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + } + }, + "surfaceOp": "append" + }, + { + "type": "step/start", + "seq": 2, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + }, + { + "type": "request/header", + "seq": 3, + "time": 0, + "data": { + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "subagent", + "workflow" + ] + }, + "reason": "initial" + } + }, + { + "type": "assistant/chunk", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 5, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-mount", + "name": "cordis_mount", + "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 6, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 7, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 8, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 9, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "content": [ + { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 4, + 5, + 6, + 7, + 8 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 10, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "callId": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + }, + { + "type": "tool/result", + "seq": 11, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "callId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "mounted dyn-1 (plugin \"\", state: active)" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 10 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 12, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + }, + { + "type": "step/start", + "seq": 13, + "time": 0, + "data": { + "turn": 1, + "step": 2 + } + }, + { + "type": "request/header", + "seq": 14, + "time": 0, + "data": { + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "snapshot_double", + "subagent", + "workflow" + ] + }, + "reason": "fallback" + } + }, + { + "type": "assistant/chunk", + "seq": 15, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 16, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-code", + "name": "run_code", + "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 17, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 18, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 19, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 20, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 15, + 16, + 17, + 18, + 19 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 21, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "callId": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + }, + { + "type": "tool/code-dispatch", + "seq": 22, + "time": 0, + "data": { + "parentCallId": "advanced-code", + "subCallId": "advanced-code:code:1", + "name": "snapshot_double", + "arguments": { + "value": 21 + }, + "isError": false, + "resultSummary": "42" + } + }, + { + "type": "tool/result", + "seq": 23, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "callId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false, + "meta": { + "logs": [], + "dispatches": 1 + } + }, + "sourceEventSeqs": [ + 21 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 24, + "time": 0, + "data": { + "turn": 1, + "step": 2 + } + }, + { + "type": "step/start", + "seq": 25, + "time": 0, + "data": { + "turn": 1, + "step": 3 + } + }, + { + "type": "assistant/chunk", + "seq": 26, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 27, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-direct-child", + "name": "subagent", + "argumentsDelta": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 28, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 29, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 30, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 31, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "content": [ + { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 26, + 27, + 28, + 29, + 30 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 32, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "callId": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + }, + { + "type": "tool/result", + "seq": 33, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "callId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 32 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 34, + "time": 0, + "data": { + "turn": 1, + "step": 3 + } + }, + { + "type": "step/start", + "seq": 35, + "time": 0, + "data": { + "turn": 1, + "step": 4 + } + }, + { + "type": "assistant/chunk", + "seq": 36, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 37, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-workflow", + "name": "workflow", + "argumentsDelta": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 38, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 39, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 40, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 41, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "content": [ + { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 36, + 37, + 38, + 39, + 40 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 42, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "callId": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + }, + { + "type": "tool/result", + "seq": 43, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "callId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 42 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 44, + "time": 0, + "data": { + "turn": 1, + "step": 4 + } + }, + { + "type": "step/start", + "seq": 45, + "time": 0, + "data": { + "turn": 1, + "step": 5 + } + }, + { + "type": "assistant/chunk", + "seq": 46, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 47, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-unmount", + "name": "cordis_unmount", + "argumentsDelta": "{\"id\": \"dyn-1\"}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 48, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 49, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 50, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 51, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 46, + 47, + 48, + 49, + 50 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 52, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + }, + { + "type": "tool/result", + "seq": 53, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "unmounted dyn-1 (plugin \"\")" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 52 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 54, + "time": 0, + "data": { + "turn": 1, + "step": 5 + } + }, + { + "type": "step/start", + "seq": 55, + "time": 0, + "data": { + "turn": 1, + "step": 6 + } + }, + { + "type": "request/header-delta", + "seq": 56, + "time": 0, + "data": { + "system": { + "keepStart": 62, + "keepEnd": 34, + "insert": [] + }, + "tools": { + "added": [], + "removed": [ + "snapshot_double" + ], + "changed": [] + } + } + }, + { + "type": "assistant/chunk", + "seq": 57, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + } + }, + { + "type": "assistant/chunk", + "seq": 58, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "text-delta", + "index": 0, + "text": "ADVANCED_EXECUTABLE_OK" + } + } + }, + { + "type": "assistant/chunk", + "seq": 59, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 60, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 61, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + }, + { + "type": "assistant/message", + "seq": 62, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 57, + 58, + 59, + 60, + 61 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 63, + "time": 0, + "data": { + "turn": 1, + "step": 6 + } + }, + { + "type": "turn/end", + "seq": 64, + "time": 0, + "data": { + "turn": 1, + "reason": { + "kind": "completed" + } + } + } + ], + "notifications": [ + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "turn/start", + "seq": 0, + "time": 0, + "data": { + "turn": 1, + "trigger": { + "kind": "message", + "source": { + "kind": "user" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "user/message", + "seq": 1, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + } + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 2, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 3, + "time": 0, + "data": { + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "subagent", + "workflow" + ] + }, + "reason": "initial" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 5, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-mount", + "name": "cordis_mount", + "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 6, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 7, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 8, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 9, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "content": [ + { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 4, + 5, + 6, + 7, + 8 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 10, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "callId": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 11, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "callId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "mounted dyn-1 (plugin \"\", state: active)" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 10 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 12, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 13, + "time": 0, + "data": { + "turn": 1, + "step": 2 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 14, + "time": 0, + "data": { + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "snapshot_double", + "subagent", + "workflow" + ] + }, + "reason": "fallback" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 15, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 16, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-code", + "name": "run_code", + "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 17, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 18, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 19, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 20, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 15, + 16, + 17, + 18, + 19 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 21, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "callId": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/code-dispatch", + "seq": 22, + "time": 0, + "data": { + "parentCallId": "advanced-code", + "subCallId": "advanced-code:code:1", + "name": "snapshot_double", + "arguments": { + "value": 21 + }, + "isError": false, + "resultSummary": "42" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 23, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "callId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false, + "meta": { + "logs": [], + "dispatches": 1 + } + }, + "sourceEventSeqs": [ + 21 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 24, + "time": 0, + "data": { + "turn": 1, + "step": 2 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 25, + "time": 0, + "data": { + "turn": 1, + "step": 3 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 26, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 27, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-direct-child", + "name": "subagent", + "argumentsDelta": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 28, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 29, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 30, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 31, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "content": [ + { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 26, + 27, + 28, + 29, + 30 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 32, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "callId": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + } + }, + { + "method": "subagent.started", + "payload": { + "parentSessionId": "{{parent}}", + "childSessionId": "{{child-1}}" + } + }, + { + "method": "subagent.finished", + "payload": { + "provider": "spawn", + "agentId": "{{agent-1}}", + "parentSessionId": "{{parent}}", + "childSessionId": "{{child-1}}", + "status": "ok", + "stopReason": "completed", + "lastAssistantMessage": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ] + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 33, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "callId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 32 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 34, + "time": 0, + "data": { + "turn": 1, + "step": 3 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 35, + "time": 0, + "data": { + "turn": 1, + "step": 4 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 36, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 37, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-workflow", + "name": "workflow", + "argumentsDelta": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 38, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 39, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 40, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 41, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "content": [ + { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 36, + 37, + 38, + 39, + 40 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 42, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "callId": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + } + }, + { + "method": "subagent.started", + "payload": { + "parentSessionId": "{{parent}}", + "childSessionId": "{{child-2}}" + } + }, + { + "method": "subagent.finished", + "payload": { + "provider": "spawn", + "agentId": "{{agent-2}}", + "parentSessionId": "{{parent}}", + "childSessionId": "{{child-2}}", + "status": "ok", + "stopReason": "completed", + "lastAssistantMessage": [ + { + "type": "text", + "text": "WORKFLOW_CHILD_OK" + } + ] + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 43, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "callId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 42 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 44, + "time": 0, + "data": { + "turn": 1, + "step": 4 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 45, + "time": 0, + "data": { + "turn": 1, + "step": 5 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 46, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 47, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-unmount", + "name": "cordis_unmount", + "argumentsDelta": "{\"id\": \"dyn-1\"}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 48, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 49, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 50, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 51, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 46, + 47, + 48, + 49, + 50 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 52, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 53, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "unmounted dyn-1 (plugin \"\")" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 52 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 54, + "time": 0, + "data": { + "turn": 1, + "step": 5 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 55, + "time": 0, + "data": { + "turn": 1, + "step": 6 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header-delta", + "seq": 56, + "time": 0, + "data": { + "system": { + "keepStart": 62, + "keepEnd": 34, + "insert": [] + }, + "tools": { + "added": [], + "removed": [ + "snapshot_double" + ], + "changed": [] + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 57, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 58, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "text-delta", + "index": 0, + "text": "ADVANCED_EXECUTABLE_OK" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 59, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 60, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 61, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 62, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 57, + 58, + 59, + 60, + 61 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 63, + "time": 0, + "data": { + "turn": 1, + "step": 6 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "turn/end", + "seq": 64, + "time": 0, + "data": { + "turn": 1, + "reason": { + "kind": "completed" + } + } + } + } + }, + { + "method": "session.finished", + "payload": { + "sessionId": "{{parent}}", + "status": "ok", + "reason": { + "kind": "completed" + } + } + } + ], + "session_root": "{{cwd}}/sessions" +} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl new file mode 100644 index 0000000000..beb80d3c85 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl new file mode 100644 index 0000000000..948c835148 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl new file mode 100644 index 0000000000..bb0ee1d4d0 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -0,0 +1,66 @@ +{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"fallback"}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}} +{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}} +{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"} +{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} +{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} +{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"} +{"type":"tool/call","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} +{"type":"tool/result","seq":43,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[42],"surfaceOp":"append"} +{"type":"step/end","seq":44,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":45,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":51,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"} +{"type":"tool/call","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"} +{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}} +{"type":"request/header-delta","seq":56,"time":0,"data":{"system":{"keepStart":62,"keepEnd":34,"insert":[]},"tools":{"added":[],"removed":["snapshot_double"],"changed":[]}}} +{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} +{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} +{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":62,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":64,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} From 15d6a0c69bb9a342c176b81b359f48b0321ffb16 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:56:58 +0800 Subject: [PATCH 18/18] test(acp): snapshot advanced toolchain flow The executable golden proves the packaged VFS and Python SDK boundary, but it does not pin the editor-facing ACP updates, and the existing ACP scenarios exercised Code Mode, subagents, and workflows only as separate stories. Add a dedicated both-mode replay composition that mounts a Cordis marker, inspects it through a real Code Mode worker dispatch, runs a direct spawn child and a workflow-owned spawn child, then unmounts the marker. Commit normalized ACP frames, parent and child logs, and the composition-specific request-header pin. Use cordis_inspect as the Code Mode sub-dispatch instead of registering another dynamic tool: the executable snapshot already owns tool-set mutation and header-delta coverage, while this keeps the ACP header contract stable and focuses the new golden on transport presentation and cross-feature composition. --- .../acp-agent/advanced.cordis.snapshot.yml | 29 ++++ examples/acp-agent/advanced.cordis.yml | 25 +++ examples/acp-agent/tests/acp.snapshot.ts | 16 ++ .../snapshots/advanced-toolchain/input.json | 7 + .../advanced-toolchain/session.1.jsonl | 13 ++ .../advanced-toolchain/session.2.jsonl | 13 ++ .../advanced-toolchain/session.jsonl | 64 ++++++++ .../advanced-toolchain/stdout.golden.jsonl | 14 ++ .../system-prompt.golden.md | 151 ++++++++++++++++++ 9 files changed, 332 insertions(+) create mode 100644 examples/acp-agent/advanced.cordis.snapshot.yml create mode 100644 examples/acp-agent/advanced.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/advanced-toolchain/input.json create mode 100644 examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl create mode 100644 examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md diff --git a/examples/acp-agent/advanced.cordis.snapshot.yml b/examples/acp-agent/advanced.cordis.snapshot.yml new file mode 100644 index 0000000000..64802e1da9 --- /dev/null +++ b/examples/acp-agent/advanced.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless replay counterpart to advanced.cordis.yml. It composes the same +# Code Mode + Cordis + subagent + workflow tree while replacing only the live +# model adapter with the committed multi-session replay script. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + tools: + mode: both + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/advanced.cordis.yml b/examples/acp-agent/advanced.cordis.yml new file mode 100644 index 0000000000..772369578f --- /dev/null +++ b/examples/acp-agent/advanced.cordis.yml @@ -0,0 +1,25 @@ +# Advanced ACP snapshot overlay: keep every native tool on the wire, add the +# Code Mode worker, and opt into the self-referential Cordis toolset. The base +# tree already supplies direct spawn subagents and the workflow worker, so this +# composition exercises all four boundaries in one editor-facing ACP turn. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + tools: + mode: both + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index eaa4e0381a..c6ef7201ca 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -26,6 +26,7 @@ const AGENT = { // replay swap resolves each one's sibling `*cordis.snapshot.yml`). const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url)) +const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { @@ -75,6 +76,21 @@ const SCENARIOS: Scenario[] = [ // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. { name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 }, + // ACP-facing counterpart to the packaged Python SDK snapshot: Cordis mounts + // a live marker, Code Mode inspects it through the worker bridge, then a + // direct child and a workflow child run before the mount is disposed. This + // class adds both Code Mode and the opt-in Cordis tools to the base tree, so + // it owns a distinct request-header pin. The scripted fixture is authored: + // the value is deterministic cross-boundary composition, not live-model prose. + { + name: 'advanced-toolchain', + hasModelTurn: true, + recorded: false, + childSessions: 2, + pinsHeader: true, + headerClass: 'advanced', + configPath: ADVANCED_CONFIG, + }, // Hook matrix — one scenario per hook point × its headline Decision outcome, // across BOTH bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in // workspace/). The block scenarios need no model call: a UserPromptSubmit hook diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json new file mode 100644 index 0000000000..b66945d9a7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl new file mode 100644 index 0000000000..1eabba53b3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl new file mode 100644 index 0000000000..7787ed2ab1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl new file mode 100644 index 0000000000..bb74382b22 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -0,0 +1,64 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp"} +{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}} +{"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} +{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} +{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1783957884594,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}} +{"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}} +{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} +{"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1783957884718,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":56,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} +{"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl new file mode 100644 index 0000000000..2e9174f026 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl @@ -0,0 +1,14 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-code","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-direct-child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Check direct child","prompt":"Reply with exactly DIRECT_CHILD_OK and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-direct-child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"DIRECT_CHILD_OK"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-workflow","title":"workflow: advanced-acp-snapshot","kind":"other","status":"in_progress","rawInput":"phase('Delegate')\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\nreturn { reply }"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-workflow","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-unmount","title":"Unmount dyn-1","kind":"delete","status":"in_progress"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-unmount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md new file mode 100644 index 0000000000..e70cfcce44 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -0,0 +1,151 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +## Writing code for run_code + +Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. +- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue. +- Calls execute sequentially, even under `Promise.all`. +- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. + +The available tools: + +```ts +declare const tools: { + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */ + bash(args: { + /** The bash command to execute. */ + command: string; + /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ + description: string; + /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */ + timeoutMs?: number; + /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */ + workdir?: string; + /** Run in the background and return a task id immediately. No timeout applies. */ + run_in_background?: boolean; + }): Promise; + /** Ask the executor to kill a running background bash task by task id. */ + bash_kill(args: { + /** Task id returned by the bash tool. */ + task_id: string; + }): Promise; + /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */ + bash_output(args: { + /** Task id returned by the bash tool. */ + task_id: string; + }): Promise; + /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. */ + cordis_inspect(args: { + /** Limit the report to one section. Omit for all sections. */ + what?: "services" | "plugins" | "tools" | "dynamic" | "api" | "events"; + }): Promise; + /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ + cordis_mount(args: { + /** Body of an async JS function; must `return` the plugin to mount. */ + code: string; + }): Promise; + /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */ + cordis_unmount(args: { + /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ + id: string; + }): Promise; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit(args: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + }): Promise; + /** Read a UTF-8 text file and return line-numbered content. */ + read(args: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + }): Promise; + /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ + skill(args: { + /** The exact skill name from the available skills list. */ + name: string; + }): Promise; + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */ + subagent(args: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ + prompt: string; + }): Promise; + /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ + subagent_fork(args: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ + prompt: string; + }): Promise; + /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ + todo_write(args: { + /** The COMPLETE task list, replacing any previous list. */ + todos: ({ + /** What the task is — a short imperative line. */ + content: string; + /** pending (not started) | in_progress (now) | completed (done). */ + status: "pending" | "in_progress" | "completed"; + })[]; + }): Promise; + /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ + workflow(args: { + /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ + script: string; + /** The workflow identity block (plain JSON — never code). */ + meta: { + /** Short kebab-case workflow name. */ + name: string; + /** One-line description of what the workflow does. */ + description: string; + /** Optional guidance on when this workflow applies. */ + whenToUse?: string; + /** Optional phase declarations matched by phase() calls. */ + phases?: { + /** The phase title phase() calls match by exact string. */ + title: string; + /** Optional one-line description of the phase. */ + detail?: string; + /** Optional model override this phase is expected to use. */ + model?: string; + }[]; + }; + /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ + args?: Record; + }): Promise; + /** Create or fully replace a UTF-8 text file. */ + write(args: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + }): Promise; +} +```