From 665c21693b927daef27b9e81b581d777909c7628 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:12:27 +0800 Subject: [PATCH 01/41] feat(tools): add persistent bash and str-replace editor --- ...rsistent-bash-str-replace-editor.i18n.yaml | 6 + ...7-29-persistent-bash-str-replace-editor.md | 33 ++ ...9-persistent-bash-str-replace-editor.zh.md | 33 ++ docs/config-catalog.md | 57 ++- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 12 + docs/tool-catalog.md | 95 +++++ packages/core/system-prompt/README.i18n.yaml | 6 +- packages/core/system-prompt/README.md | 5 +- packages/core/system-prompt/README.zh.md | 5 +- packages/core/system-prompt/src/index.ts | 15 +- .../system-prompt/tests/system-prompt.spec.ts | 12 + .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 8 +- .../examples/agent-spine-demo/README.zh.md | 8 +- .../examples/agent-spine-demo/src/index.ts | 24 +- .../agent-spine-demo/tests/agent-core.spec.ts | 21 +- packages/fs/README.i18n.yaml | 6 +- packages/fs/README.md | 1 + packages/fs/README.zh.md | 1 + .../tool-str-replace-editor/README.i18n.yaml | 6 + packages/fs/tool-str-replace-editor/README.md | 54 +++ .../fs/tool-str-replace-editor/README.zh.md | 54 +++ .../fs/tool-str-replace-editor/package.json | 48 +++ .../fs/tool-str-replace-editor/src/index.ts | 403 ++++++++++++++++++ .../tool-str-replace-editor/src/invariant.ts | 30 ++ .../tests/tools.spec.ts | 313 ++++++++++++++ .../fs/tool-str-replace-editor/tsconfig.json | 14 + packages/pty/README.i18n.yaml | 6 +- packages/pty/README.md | 1 + packages/pty/README.zh.md | 1 + .../pty/tool-bash-persistent/README.i18n.yaml | 6 + packages/pty/tool-bash-persistent/README.md | 50 +++ .../pty/tool-bash-persistent/README.zh.md | 50 +++ .../pty/tool-bash-persistent/package.json | 55 +++ .../pty/tool-bash-persistent/src/index.ts | 380 +++++++++++++++++ .../pty/tool-bash-persistent/src/invariant.ts | 30 ++ .../tests/loader-composition.spec.ts | 156 +++++++ .../tool-bash-persistent/tests/tools.spec.ts | 385 +++++++++++++++++ .../pty/tool-bash-persistent/tsconfig.json | 16 + patches/node-pty@1.1.0.patch | 60 +++ pnpm-lock.yaml | 105 ++++- pnpm-workspace.yaml | 4 + python/README.i18n.yaml | 6 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 6 +- python/sdk-runtime/README.md | 6 +- python/sdk-runtime/README.zh.md | 6 +- python/sdk-runtime/hatch_build.py | 16 +- python/sdk-runtime/package.json | 5 + .../src/deepseek_harness_runtime/__init__.py | 13 +- python/sdk/tests/test_release_version.py | 38 ++ scripts/build-exe-for-python-sdk.ts | 84 +++- scripts/build-python-release.py | 29 +- scripts/gen-tool-catalog.ts | 28 ++ scripts/smoke-python-runtime.py | 145 ++++++- tsconfig.host.json | 2 + 59 files changed, 2880 insertions(+), 91 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md create mode 100644 packages/fs/tool-str-replace-editor/README.i18n.yaml create mode 100644 packages/fs/tool-str-replace-editor/README.md create mode 100644 packages/fs/tool-str-replace-editor/README.zh.md create mode 100644 packages/fs/tool-str-replace-editor/package.json create mode 100644 packages/fs/tool-str-replace-editor/src/index.ts create mode 100644 packages/fs/tool-str-replace-editor/src/invariant.ts create mode 100644 packages/fs/tool-str-replace-editor/tests/tools.spec.ts create mode 100644 packages/fs/tool-str-replace-editor/tsconfig.json create mode 100644 packages/pty/tool-bash-persistent/README.i18n.yaml create mode 100644 packages/pty/tool-bash-persistent/README.md create mode 100644 packages/pty/tool-bash-persistent/README.zh.md create mode 100644 packages/pty/tool-bash-persistent/package.json create mode 100644 packages/pty/tool-bash-persistent/src/index.ts create mode 100644 packages/pty/tool-bash-persistent/src/invariant.ts create mode 100644 packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts create mode 100644 packages/pty/tool-bash-persistent/tests/tools.spec.ts create mode 100644 packages/pty/tool-bash-persistent/tsconfig.json create mode 100644 patches/node-pty@1.1.0.patch diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml new file mode 100644 index 0000000000..e3b6121e19 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +2026-07-29-persistent-bash-str-replace-editor.md: 286a53c1c686cc515b65119ed4b1a01a57b0614b +2026-07-29-persistent-bash-str-replace-editor.zh.md: d2417708c8a1334e9f8930481f4218cbefc5a87b diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md new file mode 100644 index 0000000000..286a53c1c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -0,0 +1,33 @@ +# Agent Note: Persistent Bash and string-replacement editor tools + +Status: implemented + +English | [中文](2026-07-29-persistent-bash-str-replace-editor.zh.md) + +## Problem + +Some deployments need a one-call Bash schema whose shell state survives across model turns, while others need a Claude-style `str_replace_editor` independent of their terminal choice. Bundling the two tools or naming them after one benchmark would prevent reuse and blur configuration ownership. + +## Decision + +`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. + +`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. The public schema and failures use only `old_str`; canonical mode requires absolute paths and expands tabs before mutations. Deployments with an intentional session-cwd contract can disable the absolute-path requirement. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. + +`dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. + +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present (or when `DSH_NODE_PTY_SPAWN_HELPER` explicitly selects one), preserving upstream lookup in ordinary Node runs; the executable and runtime-wheel builders fail before publication when the helper is absent, mismatched, or not executable. + +## Alternatives considered + +**One combined compatibility plugin.** Rejected because neither tool requires the other and the combined name would tie reusable capabilities to one benchmark. + +**Reuse one-shot Bash.** Rejected because `bash -c` cannot preserve cwd or environment state across calls. + +**Expose terminal management tools.** Rejected because open/send/read/close is a different model action space from one persistent `bash` call. + +**Modify native read/write/edit.** Rejected because it would distort their general-purpose contracts instead of adding an independently composable editor. + +## Consequences + +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation, but the wheel now contains a main executable plus its private native helper rather than one physical file. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md new file mode 100644 index 0000000000..d2417708c8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -0,0 +1,33 @@ +# Agent Note:持久 Bash 与字符串替换编辑器工具 + +状态:已实现 + +[English](2026-07-29-persistent-bash-str-replace-editor.md) | 中文 + +## 问题 + +部分部署需要只调用一次的 Bash schema,同时要求 shell 状态跨模型轮次保留;另一些部署需要与终端选择无关的 Claude 风格 `str_replace_editor`。把两个工具绑在一起或按某个基准命名,会阻碍复用并模糊配置归属。 + +## 决策 + +`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 + +`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。公开 schema 与错误只使用 `old_str`;规范模式要求绝对路径,并在变更前展开制表符。有明确 session-cwd 契约的部署可以关闭绝对路径要求。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 + +`dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 + +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它(也可由 `DSH_NODE_PTY_SPAWN_HELPER` 显式指定),普通 Node 运行仍保留上游查找方式;若 helper 缺失、架构不匹配或不可执行,可执行文件与 runtime wheel 构建会在发布前失败。 + +## 考虑过的替代方案 + +**单一组合兼容插件。** 被拒绝,因为两个工具互不依赖,组合命名还会把可复用能力绑定到某个基准。 + +**复用一次性 Bash。** 被拒绝,因为 `bash -c` 无法跨调用保留 cwd 或环境状态。 + +**暴露终端管理工具。** 被拒绝,因为 open/send/read/close 与单个持久 `bash` 调用是不同的模型动作空间。 + +**修改原生 read/write/edit。** 被拒绝,因为这会扭曲其通用契约,而不是增加一个可独立组合的编辑器。 + +## 后果 + +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。runtime wheel 的使用者仍不需要安装 Node,但 wheel 现在包含主可执行文件及其私有原生 helper,而不是单个物理文件。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 68343a6a19..585ea18b84 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -116,9 +116,9 @@ Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loo /** * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP - * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt - * plugin (the deployment's persona section and the explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * bridge, simply omits it), `includeHarnessIdentity`, `persona`, and `toolOrder` + * to the system-prompt plugin (the fixed opener, deployment persona, and explicit + * model-facing tool order), the `tools` object to the tool registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -131,13 +131,16 @@ Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loo * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their - * own config. + * own config. Set `toolBash: false` when another plugin owns the model-facing + * `bash` name. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] /** Agent-loop concurrency cap; `1` is serial. */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] + /** Whether the system prompt includes the fixed Harness identity (default true). */ + includeHarnessIdentity?: SystemPromptConfig['includeHarnessIdentity'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -152,8 +155,8 @@ export interface Config { workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig - /** Model-facing bash tool config, including this producer's background opt-in. */ - toolBash?: toolBash.Config + /** Model-facing bash tool config, or false when another plugin owns `bash`. */ + toolBash?: toolBash.Config | false /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -185,7 +188,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:88`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:89`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -1509,6 +1512,8 @@ Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagen ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ export interface Config { + /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */ + includeHarnessIdentity?: boolean /** * Deployment-wide order-0 persona template. A scoped section named * `deployment:persona` shadows it; `{{variable}}` references are strict. @@ -1566,6 +1571,26 @@ export interface Config { Source: [`packages/bash/tool-bash/src/index.ts:41`](../packages/bash/tool-bash/src/index.ts) +## `@deepseek-ai/dsh-tool-bash-persistent` + +Requires: `tools` · `pty` + +```ts config-catalog +/** Configuration for the persistent Bash tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/pty/tool-bash-persistent/src/index.ts:340`](../packages/pty/tool-bash-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-cordis` Requires: `tools` @@ -1724,6 +1749,24 @@ export interface Config { Source: [`packages/skill/tool-skill/src/index.ts:21`](../packages/skill/tool-skill/src/index.ts) +## `@deepseek-ai/dsh-tool-str-replace-editor` + +Requires: `tools` · `fs` + +```ts config-catalog +/** Configuration for the string-replacement editor tool. */ +export interface Config { + /** Maximum returned view characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description. */ + description?: string + /** Require local absolute paths like the canonical editor contract (default true). */ + requireAbsolutePath?: boolean +} +``` + +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:373`](../packages/fs/tool-str-replace-editor/src/index.ts) + ## `@deepseek-ai/dsh-tool-subagent` Requires: `tools` · `subagents` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e0dd17fa02..c564cf53a5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1870,7 +1870,7 @@ async assemble(context: AssembleContext = {}): Promise Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md) -Source: [`packages/core/system-prompt/src/index.ts:246`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:248`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tasks` — `TaskService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9d66b52bfb..919f891685 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 703f4c8abf..b8fe4a23c7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -48,6 +48,7 @@ flowchart TD pkg_fs_sandbox["fs-sandbox"] pkg_tool_fs["tool-fs"] pkg_tool_fs_search["tool-fs-search"] + pkg_tool_str_replace_editor["tool-str-replace-editor"] end subgraph group_skill["packages/skill"] pkg_skill["skill"] @@ -202,6 +203,7 @@ flowchart TD subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] + pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pty["tool-pty"] end subgraph group_sandbox["packages/sandbox"] @@ -663,6 +665,9 @@ flowchart TD pkg_tool_fs_search --> pkg_spill pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_tools + pkg_tool_str_replace_editor --> pkg_fs + pkg_tool_str_replace_editor --> pkg_invariants + pkg_tool_str_replace_editor --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm @@ -777,6 +782,11 @@ flowchart TD pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_tools + pkg_tool_bash_persistent --> pkg_agent + pkg_tool_bash_persistent --> pkg_invariants + pkg_tool_bash_persistent --> pkg_pty + pkg_tool_bash_persistent --> pkg_timeout + pkg_tool_bash_persistent --> pkg_tools pkg_tool_pty --> pkg_agent pkg_tool_pty --> pkg_invariants pkg_tool_pty --> pkg_llm @@ -1077,6 +1087,7 @@ flowchart TD | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | @@ -1098,6 +1109,7 @@ flowchart TD | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 995a8c669d..bed13d1a2d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,6 +20,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | +| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | @@ -279,6 +281,99 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. +## `@deepseek-ai/dsh-tool-bash-persistent` + +### `bash` + +Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/pty/tool-bash-persistent/src/index.ts`](../packages/pty/tool-bash-persistent/src/index.ts) + +One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. + +## `@deepseek-ai/dsh-tool-str-replace-editor` + +### `str_replace_editor` + +Custom editing tool for viewing, creating and editing files +* State is persistent across command calls and discussions with the user +* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep +* The `create` command cannot be used if the specified `path` already exists as a file +* If a `command` generates a long output, it will be truncated and marked with `` + +Notes for using the `str_replace` command: +* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces! +* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique +* The `new_str` parameter should contain the edited lines that should replace the `old_str` + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] +} +``` + +Source: [`packages/fs/tool-str-replace-editor/src/index.ts`](../packages/fs/tool-str-replace-editor/src/index.ts) + +Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. + ## `@deepseek-ai/dsh-tool-fs` ### `edit` diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index 9d82f19bfd..cea643baa7 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -1,6 +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: 79badba0b84b27c01f25e9c31b5df78c556411ea -README.zh.md: 1d983e44721dbc637efc10824065aa3b88087e1d +# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md +README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f +README.zh.md: 94c4cef4e289b94e7db79a8ad4815f7d4038cf80 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 79badba0b8..23bc0e8177 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -8,6 +8,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem | Key | Default | Meaning | |---|---|---| +| `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by the DeepSeek Harness SDK.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. | | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). | @@ -48,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple #### What the model sees -Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. +By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. ##### Harness identity @@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK. #### Token effect -Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content. +Identity is a fixed per-request cost when enabled. Persona and plugin text are repeated per request and scale with their rendered content. #### KV Cache effect diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 1d983e4472..94c4cef4e2 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -8,6 +8,7 @@ | 键 | 默认值 | 含义 | |---|---|---| +| `includeHarnessIdentity` | `true` | 是否包含固定的 `You are an AI agent powered by the DeepSeek Harness SDK.`、顺序为 −100 的开场白。仅当兼容部署拥有完整系统提示词时设为 false。 | | `persona` | `''` | 全局部署 persona 默认值:唯一由配置创作的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(已交付循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | | `toolOrder` | 无 | 显式的面向模型工具顺序:一个 `ToolSchema.name` 列表,包含一个 `''` 其余项(`TOOL_ORDER_REST`)。已列工具占据列出的位置;未列工具按名称字典序落在其余项位置。缺席 ⇒ 直接按名称字典序排列。在 `system-prompt/assemble` waterfall 之前应用于已收集工具;与段的 `order` 排序一样,它会规范化注册表贡献的内容(注册顺序是插件加载工件),而修改列表的 waterfall 监听器拥有其输出的确定性。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在已交付循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md)。 | @@ -48,7 +49,7 @@ #### 模型所见 -每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 +默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 ##### Harness 身份 @@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK. #### Token 影响 -身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。 +启用时,身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。 #### KV Cache 影响 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index c46c38515c..2e4e5e65b7 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -145,6 +145,8 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number { /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ export interface Config { + /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */ + includeHarnessIdentity?: boolean /** * Deployment-wide order-0 persona template. A scoped section named * `deployment:persona` shadows it; `{{variable}}` references are strict. @@ -245,6 +247,7 @@ class PromptLayer implements ScopeLayer { /** Registry service for the prompt inputs assembled before each model step. */ export class SystemPrompt extends Service { static Config: z = z.object({ + includeHarnessIdentity: z.boolean().default(true), persona: z.string().default(''), // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), @@ -260,11 +263,13 @@ export class SystemPrompt extends Service { super(ctx, 'systemPrompt') this.toolOrder = validateToolOrder(config.toolOrder) // Keep harness-owned openers independent of the selected loop plugin. - this.section({ - name: 'harness:identity', - order: -100, - text: 'You are an AI agent powered by the DeepSeek Harness SDK.', - }) + if (config.includeHarnessIdentity ?? true) { + this.section({ + name: 'harness:identity', + order: -100, + text: 'You are an AI agent powered by the DeepSeek Harness SDK.', + }) + } this.section({ name: 'deployment:persona', order: 0, diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index d4fdbdd684..02ac58889d 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -37,6 +37,18 @@ describe('SystemPrompt', () => { expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY) }) + it('can omit the harness identity for a deployment that owns the complete persona', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { + includeHarnessIdentity: false, + persona: 'You are a helpful software engineer assistant.', + }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.map(section => section.name)).toEqual(['deployment:persona']) + expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.') + }) + it('tolerates a schema-bypassing direct construction (persona omitted)', async () => { // ctx.plugin validates + defaults the config first; a direct construction // skips the schema, so the ctor's `?? ''` narrowing is what fires. diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index a7c71523b2..fd6d54918e 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md -README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9 -README.zh.md: 57fec3f32f5bbc8f3d82ff8971d36d376d722753 +README.md: 6bbd99217bcdce0e8a0e8fd22a8d39d0c64224b9 +README.zh.md: b02b035685f1902dc9b2bd1056606495e212aa4f diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 359e7153be..6bbd99217b 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -31,7 +31,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-scope/invariant @deepseek-ai/dsh-agent-loop/invariant package-owned relational checks -@deepseek-ai/dsh-tool-bash the model-facing bash schema +@deepseek-ai/dsh-tool-bash the model-facing bash schema (unless toolBash=false) @deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices @@ -55,11 +55,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. @@ -79,5 +79,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. +- **Most of the spine set is fixed in code** — `apply()` always mounts the core services; config can omit bundled goals, skills, bash, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. - **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 57fec3f32f..b02b035685 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -31,7 +31,7 @@ @deepseek-ai/dsh-scope/invariant @deepseek-ai/dsh-agent-loop/invariant package-owned relational checks -@deepseek-ai/dsh-tool-bash the model-facing bash schema +@deepseek-ai/dsh-tool-bash the model-facing bash schema (unless toolBash=false) @deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 +组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 @@ -79,5 +79,5 @@ YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默 ## 已知限制与延后工作 -- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务与 `tool-bash`;配置可以省略组合包内的目标、skill 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。 +- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务;配置可以省略组合包内的目标、skill、bash 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。 - **不变式 seam 与配套插件仍是固定成员**:`invariants.enabled: false` 或包筛选器会抑制检查,但不会移除服务或配套插件注册;Session 始终启用的校验与冻结是另一套机制。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 92434f91da..cfa5ac3ccd 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -68,9 +68,9 @@ export interface GoalConfig { /** * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP - * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt - * plugin (the deployment's persona section and the explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * bridge, simply omits it), `includeHarnessIdentity`, `persona`, and `toolOrder` + * to the system-prompt plugin (the fixed opener, deployment persona, and explicit + * model-facing tool order), the `tools` object to the tool registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -83,13 +83,16 @@ export interface GoalConfig { * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their - * own config. + * own config. Set `toolBash: false` when another plugin owns the model-facing + * `bash` name. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] /** Agent-loop concurrency cap; `1` is serial. */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] + /** Whether the system prompt includes the fixed Harness identity (default true). */ + includeHarnessIdentity?: SystemPromptConfig['includeHarnessIdentity'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -104,8 +107,8 @@ export interface Config { workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig - /** Model-facing bash tool config, including this producer's background opt-in. */ - toolBash?: toolBash.Config + /** Model-facing bash tool config, or false when another plugin owns `bash`. */ + toolBash?: toolBash.Config | false /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -127,7 +130,8 @@ export const SessionTitleConfigSchema: z = SessionTitleServi .default(EXAMPLE_SESSION_TITLE_CONFIG) /** The bash-tool config schema exported for app packages that forward `toolBash`. */ -export const ToolBashConfigSchema: z = toolBash.Config +export const ToolBashConfigSchema: z = + z.union([z.const(false), toolBash.Config]) /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config @@ -163,6 +167,7 @@ export const Config = z.intersect([ export function pickSpineConfig(config: Omit): Omit { return { ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, + ...config.includeHarnessIdentity !== undefined ? { includeHarnessIdentity: config.includeHarnessIdentity } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, @@ -201,6 +206,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SessionTitleService, config.sessionTitle ?? EXAMPLE_SESSION_TITLE_CONFIG) // Owner schemas resolve defaults; forward toolOrder only when explicitly set. ctx.plugin(SystemPrompt, { + includeHarnessIdentity: config.includeHarnessIdentity ?? true, persona: config.persona ?? '', ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, }) @@ -223,7 +229,9 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(agentInvariant) ctx.plugin(scopeInvariant) ctx.plugin(agentLoopInvariant) - ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) + if (config.toolBash !== false) { + ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) + } if (config.workspaceContext !== false) { ctx.plugin(workspaceContext, config.workspaceContext) } diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 5a6f89525c..f32e04acf8 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' +import { renderPrompt, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' @@ -515,9 +515,27 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('can omit the bundled bash tool and Harness identity for a compatibility deployment', async () => { + const ctx = await mount({ + includeHarnessIdentity: false, + persona: 'You are a helpful software engineer assistant.', + workspaceContext: false, + skills: { enabled: false }, + toolBash: false, + toolTasks: false, + }, true) + + expect(ctx.tools.schemas()).toEqual([]) + expect(renderPrompt(await ctx.systemPrompt.assemble())) + .toBe('You are a helpful software engineer assistant.') + + await ctx.fiber.dispose() + }) + it('picks shared spine config without leaking front-door fields', () => { const appConfig = { model: 'front-door-only', + includeHarnessIdentity: false, persona: 'You are merged.', toolOrder: ['zulu'], tools: { mode: 'native' as const }, @@ -531,6 +549,7 @@ describe('dsh-agent-spine-demo bundle', () => { } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ + includeHarnessIdentity: appConfig.includeHarnessIdentity, persona: appConfig.persona, toolOrder: appConfig.toolOrder, tools: appConfig.tools, diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml index 1ead814be1..ecfa2e134a 100644 --- a/packages/fs/README.i18n.yaml +++ b/packages/fs/README.i18n.yaml @@ -1,6 +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: 4d954455ea920be4882530bcfe90b48a364c29b5 -README.zh.md: e818210abaded987edbb8bf38c6d1b43d40ad9c7 +# pnpm run verify-translation-pairing --write packages/fs/README.md +README.md: b5e0ac9d1c0c550eb372b8a66fc6358711fddc07 +README.zh.md: c5c00f76715f97b43c673b85b3e9055a500d1b5a diff --git a/packages/fs/README.md b/packages/fs/README.md index 4d954455ea..b5e0ac9d1c 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -12,6 +12,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona | `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | | `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | +| `tool-str-replace-editor/` | Model-facing `str_replace_editor` with view/create/unique literal replace/line insert operations over `ctx.fs` | (registers on `ctx.tools`) | The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md index e818210aba..c5c00f7671 100644 --- a/packages/fs/README.zh.md +++ b/packages/fs/README.zh.md @@ -12,6 +12,7 @@ | `fs-policy/` | 政策门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) | | `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升级字段 | (注册到 `ctx.tools`) | | `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) | +| `tool-str-replace-editor/` | 基于 `ctx.fs` 提供查看/创建/唯一字面量替换/按行插入的模型可见 `str_replace_editor` | (注册到 `ctx.tools`) | 接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema;`fs-sandbox` 是第一个这样的替代实现(基于共享沙箱模式的进程内路径围栏;见[跨能力族 fs 沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。 diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml new file mode 100644 index 0000000000..1f72b1a211 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/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 packages/fs/tool-str-replace-editor/README.md +README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b +README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md new file mode 100644 index 0000000000..2d98b51d56 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/README.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-tool-str-replace-editor + +English | [中文](README.zh.md) + +Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed with persistent Bash, one-shot Bash, sandboxed Bash, or another terminal surface. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | +| `description` | Editor command guide | Model-facing tool description. | +| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | + +## Tool + +The schema provides `view`, `create`, `str_replace`, and `insert`. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor), including the configured `description`. The plugin contributes no standalone system-prompt section. + +#### Token effect + +Fixed schema cost while `str_replace_editor` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. + +#### Token effect + +Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- Operations target UTF-8 text; binary files are unsupported. +- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. +- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor. +- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md new file mode 100644 index 0000000000..a2ee8f1e36 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-tool-str-replace-editor + +[English](README.md) | 中文 + +基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端表面组合。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | +| `description` | 编辑器命令指南 | 面向模型的工具描述。 | +| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | + +## 工具 + +Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。 + +## 模型体验 + +### 工具 schema + +#### 模型所见 + +生成的 [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor),其中包含配置的 `description`。本插件不贡献独立系统提示词段。 + +#### Token 影响 + +`str_replace_editor` 可见时产生固定的 schema 成本。 + +#### KV Cache 影响 + +配置的描述与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型所见 + +查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 + +#### Token 影响 + +随数据变化,并受 `maxOutputChars` 与固定截断提示约束。 + +#### KV Cache 影响 + +工具结果以追加方式位于可复用请求前缀之后。 + +## 已知限制与延后工作 + +- 操作面向 UTF-8 文本,不支持二进制文件。 +- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 +- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。 +- 安全与先读后改策略委托给挂载的文件系统和策略插件。 diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json new file mode 100644 index 0000000000..88d3af8d53 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-tool-str-replace-editor", + "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts new file mode 100644 index 0000000000..c4a16e5437 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -0,0 +1,403 @@ +/** + * Model-facing `str_replace_editor` over the Harness filesystem seam. + * @module @deepseek-ai/dsh-tool-str-replace-editor + */ + +import { isAbsolute } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' + +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' + +const DEFAULT_DESCRIPTION = ` +Custom editing tool for viewing, creating and editing files +* State is persistent across command calls and discussions with the user +* If \`path\` is a file, \`view\` displays the result of applying \`cat -n\`. If \`path\` is a directory, \`view\` lists non-hidden files and directories up to 2 levels deep +* The \`create\` command cannot be used if the specified \`path\` already exists as a file +* If a \`command\` generates a long output, it will be truncated and marked with \`\` + +Notes for using the \`str_replace\` command: +* The \`old_str\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces! +* If the \`old_str\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \`old_str\` to make it unique +* The \`new_str\` parameter should contain the edited lines that should replace the \`old_str\` +`.trim() + +function maybeTruncate(content: string, maxOutputChars: number): string { + return content.length <= maxOutputChars + ? content + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function expandTabs(content: string, tabSize = 8): string { + let column = 0 + let result = '' + for (const character of content) { + if (character === '\t') { + const spaces = tabSize - (column % tabSize) + result += ' '.repeat(spaces) + column += spaces + continue + } + result += character + if (character === '\n' || character === '\r') column = 0 + else column += 1 + } + return result +} + +async function resolveTarget( + ctx: Context, + path: string, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + if (path.trim().length === 0) throw new Error('path must be a non-empty string') + if (requireAbsolutePath && !isAbsolute(path)) { + throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) + } + const cwd = exec.agent?.session.header.cwd + return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal }) +} + +async function statExisting( + ctx: Context, + target: FsTarget, + command: 'view' | 'str_replace' | 'insert', + exec: ToolRunContext, +): Promise { + const info = await ctx.fs.stat(target, exec.signal) + if (info === undefined) { + throw new FsError( + `The path ${target.displayPath} does not exist. Please provide a valid path.`, + 'FS_NOT_FOUND', + ) + } + if (info.type === 'directory' && command !== 'view') { + throw new FsError( + `The path ${target.displayPath} is a directory and only the \`view\` command can be used on directories`, + 'FS_NOT_REGULAR_FILE', + ) + } + return info +} + +function requiredForCommand( + value: string | undefined, + parameter: string, + command: string, + allowEmpty = true, +): string { + if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`) + if (!allowEmpty && value.length === 0) { + throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`) + } + return value +} + +function formatFileView( + path: string, + content: string, + maxOutputChars: number, + viewRange?: number[], +): string { + const allLines = content.split('\n') + let lines = allLines + let initialLine = 1 + let finalLine: number | undefined + let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)` + if (viewRange !== undefined) { + const [requestedInitialLine, requestedFinalLine] = viewRange + if ( + viewRange.length !== 2 + || requestedInitialLine === undefined + || requestedFinalLine === undefined + || !viewRange.every(Number.isInteger) + ) { + throw new Error('Invalid `view_range`. It should be a list of two integers.') + } + initialLine = requestedInitialLine + finalLine = requestedFinalLine + if (initialLine < 1 || initialLine > allLines.length) { + throw new Error( + `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`, + ) + } + if (finalLine > allLines.length) { + throw new Error( + `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``, + ) + } + if (finalLine !== -1 && finalLine < initialLine) { + throw new Error( + `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``, + ) + } + lines = finalLine === -1 + ? allLines.slice(initialLine - 1) + : allLines.slice(initialLine - 1, finalLine) + prompt += ` with view_range=[${initialLine}, ${finalLine}]` + } + const numbered = expandTabs(lines + .map((line, index) => `${String(initialLine + index).padStart(6, ' ')}\t${line}`) + .join('\n')) + return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars) +} + +async function listDirectory( + ctx: Context, + target: FsTarget, + maxOutputChars: number, + exec: ToolRunContext, +): Promise { + async function visit(dir: FsTarget, depth: number): Promise { + const entries = await ctx.fs.listDir(dir, exec.signal) + const rows: string[] = [] + for (const entry of entries.filter(candidate => + !candidate.name.startsWith('.') + && !candidate.name.startsWith('node_modules') + && !candidate.name.startsWith('__pycache__'))) { + const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?' + rows.push(`${type}\t${entry.target.displayPath}`) + if (entry.type === 'directory' && depth < 2) { + rows.push(...await visit(entry.target, depth + 1)) + } + } + return rows + } + const rows = [`d\t${target.displayPath}`, ...await visit(target, 1)] + rows.sort((left, right) => { + const leftPath = left.slice(left.indexOf('\t') + 1) + const rightPath = right.slice(right.indexOf('\t') + 1) + return leftPath.localeCompare(rightPath) + }) + const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars) + return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n` +} + +async function viewPath( + ctx: Context, + path: string, + viewRange: number[] | undefined, + maxOutputChars: number, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const info = await statExisting(ctx, target, 'view', exec) + if (info.type === 'directory') { + if (viewRange !== undefined) { + throw new Error('The `view_range` parameter is not allowed when `path` points to a directory.') + } + return listDirectory(ctx, target, maxOutputChars, exec) + } + if (info.type !== 'file') { + throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE') + } + const content = await ctx.fs.readText(target, exec.signal) + ctx.emit('fs/observed', target, info.version, exec) + return formatFileView(target.displayPath, content, maxOutputChars, viewRange) +} + +async function createFile( + ctx: Context, + path: string, + fileText: string | undefined, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + const content = requiredForCommand(fileText, 'file_text', 'create') + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + if (await ctx.fs.stat(target, exec.signal) !== undefined) { + throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) + } + const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal) + ctx.emit('fs/observed', target, outcome.version, exec) + return `New file created successfully at: ${target.displayPath}` +} + +async function replaceInFile( + ctx: Context, + path: string, + oldStr: string | undefined, + newStr: string | undefined, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false)) + const newValue = expandTabs(newStr ?? '') + const info = await statExisting(ctx, target, 'str_replace', exec) + if (info.type !== 'file') { + throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + const before = expandTabs(await ctx.fs.readText(target, exec.signal)) + const occurrences = before.split(oldValue).length - 1 + if (occurrences === 0) { + throw new FsError( + `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, + 'FS_EDIT_NOT_FOUND', + ) + } + if (occurrences > 1) { + const lines = before.split('\n') + .flatMap((line, index) => line.includes(oldValue) ? [index + 1] : []) + throw new FsError( + `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`, + 'FS_AMBIGUOUS_EDIT', + ) + } + const outcome = await ctx.fs.writeText( + target, + before.replace(oldValue, newValue), + { kind: 'replaceIfVersion', version: info.version }, + exec.signal, + ) + ctx.emit('fs/observed', target, outcome.version, exec) + return `The file ${target.displayPath} has been edited successfully.` +} + +async function insertInFile( + ctx: Context, + path: string, + insertLine: number | undefined, + newStr: string | undefined, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') + const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert')) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const info = await statExisting(ctx, target, 'insert', exec) + if (info.type !== 'file') { + throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + const before = expandTabs(await ctx.fs.readText(target, exec.signal)) + const lines = before.split('\n') + if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { + throw new Error( + `Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`, + ) + } + const after = [ + ...lines.slice(0, insertLine), + ...value.split('\n'), + ...lines.slice(insertLine), + ].join('\n') + const outcome = await ctx.fs.writeText( + target, + after, + { kind: 'replaceIfVersion', version: info.version }, + exec.signal, + ) + ctx.emit('fs/observed', target, outcome.version, exec) + return `The file ${target.displayPath} has been edited successfully.` +} + +interface ResolvedConfig { + maxOutputChars: number + description: string + requireAbsolutePath: boolean +} + +/** Register the model-facing `str_replace_editor` tool. */ +function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { + ctx.tools.register(defineTool({ + name: 'str_replace_editor', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + enum: ['view', 'create', 'str_replace', 'insert'], + description: 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.', + }, + path: { + type: 'string', + required: true, + description: 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.', + }, + file_text: { + type: 'string', + description: 'Required parameter of `create` command, with the content of the file to be created.', + }, + insert_line: { + type: 'integer', + description: 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.', + }, + new_str: { + type: 'string', + description: 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.', + }, + old_str: { + type: 'string', + description: 'Required parameter of `str_replace` command containing the string in `path` to replace.', + }, + view_range: { + type: 'array', + items: { type: 'integer' }, + description: 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + switch (args.command) { + case 'view': + return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) + case 'create': + return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec) + case 'str_replace': + return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec) + case 'insert': + return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec) + } + }, + presentCall: args => ({ + card: 'generic', + title: `${args.command} ${args.path}`, + kind: args.command === 'view' ? 'read' : 'edit', + }), + })) +} + +export const name = 'tool-str-replace-editor' +export const inject = ['tools', 'fs'] + +/** Configuration for the string-replacement editor tool. */ +export interface Config { + /** Maximum returned view characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description. */ + description?: string + /** Require local absolute paths like the canonical editor contract (default true). */ + requireAbsolutePath?: boolean +} + +/** Runtime configuration schema for the string-replacement editor tool. */ +export const Config: z = z.object({ + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), + requireAbsolutePath: z.boolean().default(true), +}) + +/** Register one `str_replace_editor` tool over `ctx.fs`. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + requireAbsolutePath: config.requireAbsolutePath ?? true, + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-str-replace-editor: description must be non-empty') + } + registerStrReplaceEditor(ctx, resolved) +} diff --git a/packages/fs/tool-str-replace-editor/src/invariant.ts b/packages/fs/tool-str-replace-editor/src/invariant.ts new file mode 100644 index 0000000000..99547c02ee --- /dev/null +++ b/packages/fs/tool-str-replace-editor/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-str-replace-editor`. + * @module @deepseek-ai/dsh-tool-str-replace-editor/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-str-replace-editor' + +/** Cordis companion plugin name. */ +export const name = 'tool-str-replace-editor-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the tool adapter owns no independent durable state; + * filesystem mutation relations stay with the provider and policy plugins. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts new file mode 100644 index 0000000000..7cf9ba5212 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -0,0 +1,313 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FsVersion } from '@deepseek-ai/dsh-fs' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' + +const contexts: Context[] = [] +const roots: string[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId(`str-replace-editor-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const value: Agent = { + id, + options: {}, + session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }), + status: 'idle', + acceptsNextStep: false, + ctx: scope.ctx, + followup: () => {}, + steer: () => {}, + inject: () => {}, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call(ctx: Context, owner: Agent | undefined, args: unknown) { + return ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId(`str-replace-editor-${++callNumber}`), + name: 'str_replace_editor', + arguments: args, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +async function setup(config: ToolStrReplaceEditor.Config = {}) { + const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-')) + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: root }) + await ctx.plugin(ToolStrReplaceEditor, config) + return { ctx, root, owner: agent(ctx, root) } +} + +describe('tool-str-replace-editor', () => { + it('registers the standalone schema and configurable description', async () => { + const { ctx } = await setup({ description: 'custom editor description' }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor']) + expect(schema?.description).toBe('custom editor description') + const properties = (schema?.parameters as { + properties: Record + }).properties + expect(properties).not.toHaveProperty('replace_all') + expect(properties.insert_line?.type).toBe('integer') + expect(properties.view_range?.items?.type).toBe('integer') + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'view', + path: '/workspace/a.txt', + })).toMatchObject({ card: 'generic', kind: 'read' }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'insert', + path: '/workspace/a.txt', + insert_line: 0, + new_str: 'x', + })).toMatchObject({ card: 'generic', kind: 'edit' }) + }) + + it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { + const { ctx, root, owner } = await setup() + const sample = join(root, 'sample.txt') + expect(text(await call(ctx, owner, { + command: 'create', + path: sample, + file_text: 'one\ntwo\nthree\n', + }))).toBe(`New file created successfully at: ${sample}`) + + expect(text(await call(ctx, owner, { + command: 'view', + path: sample, + view_range: [2, -1], + }))).toBe([ + `Here's the content of ${sample} with line numbers (which has a total of 4 lines) with view_range=[2, -1]:`, + ' 2 two', + ' 3 three', + ' 4 ', + '', + ].join('\n')) + + expect(text(await call(ctx, owner, { + command: 'str_replace', + path: sample, + old_str: 'two', + new_str: 'TWO', + }))).toBe(`The file ${sample} has been edited successfully.`) + expect(text(await call(ctx, owner, { + command: 'str_replace', + path: sample, + old_str: 'TWO', + }))).toBe(`The file ${sample} has been edited successfully.`) + expect(text(await call(ctx, owner, { + command: 'insert', + path: sample, + insert_line: 1, + new_str: 'between', + }))).toBe(`The file ${sample} has been edited successfully.`) + expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n') + }) + + it('lists visible entries to depth two and clips at the configured view limit', async () => { + const { ctx, root, owner } = await setup({ maxOutputChars: 10 }) + await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) + await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true }) + await mkdir(join(root, 'dir', '__pycache__'), { recursive: true }) + await writeFile(join(root, 'dir', 'visible.txt'), 'ok') + await writeFile(join(root, 'dir', '.hidden'), 'hidden') + await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child') + await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep') + await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency') + await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache') + const listDir = ctx.fs.listDir.bind(ctx.fs) + const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other')) + ctx.fs.listDir = async (target, signal) => { + const entries = await listDir(target, signal) + return target.displayPath === join(root, 'dir') + ? [...entries, { name: 'other', type: 'other', target: otherTarget }] + : entries + } + + const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') })) + expect(listing).toContain('') + expect(listing).not.toContain('.hidden') + expect(listing).not.toContain('too-deep.txt') + expect(listing).not.toContain('index.js') + expect(listing).not.toContain('module.pyc') + + await writeFile(join(root, 'large.txt'), 'x'.repeat(100)) + expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') }))) + .toContain('') + }) + + it('matches canonical empty-line, range, and end-insert behavior', async () => { + const { ctx, root, owner } = await setup() + const empty = join(root, 'empty.txt') + const newline = join(root, 'newline.txt') + const plain = join(root, 'plain.txt') + await writeFile(empty, '') + await writeFile(newline, '\n') + await writeFile(plain, 'one\ntwo') + + expect(text(await call(ctx, owner, { command: 'view', path: empty }))) + .toContain('(which has a total of 1 lines):\n 1 \n') + expect(text(await call(ctx, owner, { command: 'view', path: newline }))) + .toContain('(which has a total of 2 lines):\n 1 \n 2 \n') + expect(text(await call(ctx, owner, { + command: 'view', + path: plain, + view_range: [1, 2], + }))).toContain(' 2 two') + expect(text(await call(ctx, undefined, { + command: 'view', + path: plain, + }))).toContain(' 1 one') + + await call(ctx, owner, { + command: 'insert', + path: plain, + insert_line: 2, + new_str: 'three', + }) + expect(await readFile(plain, 'utf8')).toBe('one\ntwo\nthree') + + await writeFile(newline, 'one\n') + await call(ctx, owner, { + command: 'insert', + path: newline, + insert_line: 2, + new_str: 'three', + }) + expect(await readFile(newline, 'utf8')).toBe('one\n\nthree') + }) + + it('uses old_str-only replacement failures and rejects relative paths', async () => { + const { ctx, root, owner } = await setup() + const ambiguous = join(root, 'ambiguous.txt') + await writeFile(ambiguous, 'same\nother\nsame') + + const missing = await call(ctx, owner, { + command: 'str_replace', + path: ambiguous, + old_str: 'absent', + new_str: 'x', + }) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain(`old_str \`absent\` did not appear verbatim in ${ambiguous}`) + expect(text(missing)).not.toContain('old_string') + + const repeated = await call(ctx, owner, { + command: 'str_replace', + path: ambiguous, + old_str: 'same', + new_str: 'x', + }) + expect(repeated.isError).toBe(true) + expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]') + expect(text(repeated)).not.toContain('replace_all') + + const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) + expect(relative.isError).toBe(true) + expect(text(relative)).toContain('is not an absolute path') + expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame') + }) + + it('reports invalid commands or arguments without mutating files', async () => { + const { ctx, root, owner } = await setup() + const ambiguous = join(root, 'ambiguous.txt') + const empty = join(root, 'empty.txt') + const trailingNewline = join(root, 'trailing-newline.txt') + const threeLines = join(root, 'three-lines.txt') + const directory = join(root, 'directory') + await writeFile(ambiguous, 'same same') + await writeFile(empty, '') + await writeFile(trailingNewline, 'one\n') + await writeFile(threeLines, 'one\ntwo\nthree') + await mkdir(directory) + + const cases = [ + { command: 'view', path: '' }, + { command: 'view', path: join(root, 'missing.txt') }, + { command: 'view', path: ambiguous, view_range: [1] }, + { command: 'view', path: ambiguous, view_range: [0, 1] }, + { command: 'view', path: ambiguous, view_range: [1.5, 2] }, + { command: 'view', path: threeLines, view_range: [1, 99] }, + { command: 'view', path: threeLines, view_range: [2, 1] }, + { command: 'view', path: directory, view_range: [1, 1] }, + { command: 'create', path: join(root, 'new.txt') }, + { command: 'create', path: ambiguous, file_text: 'overwrite' }, + { command: 'str_replace', path: ambiguous, new_str: 'x' }, + { command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' }, + { command: 'insert', path: ambiguous, new_str: 'x' }, + { command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' }, + { command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' }, + { command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' }, + { command: 'insert', path: empty, insert_line: 2, new_str: 'x' }, + { command: 'insert', path: directory, insert_line: 0, new_str: 'x' }, + ] + for (const args of cases) { + expect((await call(ctx, owner, args)).isError).toBe(true) + } + expect(await readFile(ambiguous, 'utf8')).toBe('same same') + + ctx.fs.stat = async () => ({ version: FsVersion('special'), type: 'other' }) + const special = await call(ctx, owner, { command: 'view', path: join(root, 'special') }) + expect(special.isError).toBe(true) + expect(special.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) + expect((await call(ctx, owner, { + command: 'str_replace', + path: join(root, 'special'), + old_str: 'x', + new_str: 'y', + })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) + expect((await call(ctx, owner, { + command: 'insert', + path: join(root, 'special'), + insert_line: 0, + new_str: 'x', + })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) + }) + + it('can opt into session-relative paths for non-canonical deployments', async () => { + const { ctx, root, owner } = await setup({ requireAbsolutePath: false }) + await writeFile(join(root, 'relative.txt'), 'relative') + expect(text(await call(ctx, owner, { command: 'view', path: 'relative.txt' }))) + .toContain("Here's the content of") + }) + + it('rejects invalid plugin config', () => { + expect(() => { + ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolStrReplaceEditor.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json new file mode 100644 index 0000000000..2c6eb3688c --- /dev/null +++ b/packages/fs/tool-str-replace-editor/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../core/tools" }, + { "path": "../fs" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/pty/README.i18n.yaml b/packages/pty/README.i18n.yaml index ef4c7b5c13..f986146eae 100644 --- a/packages/pty/README.i18n.yaml +++ b/packages/pty/README.i18n.yaml @@ -1,6 +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: a9121455519a5f83a63a005cb857fec0f0e06b92 -README.zh.md: 9fc262787b960d5bf03a59cd01bf36bd5c76614b +# pnpm run verify-translation-pairing --write packages/pty/README.md +README.md: e54dcf64db083665f37b7dc19a7a92e21494442b +README.zh.md: e06ea496e389d329671c358fbde5c0849aa03cf8 diff --git a/packages/pty/README.md b/packages/pty/README.md index a912145551..e54dcf64db 100644 --- a/packages/pty/README.md +++ b/packages/pty/README.md @@ -9,5 +9,6 @@ English | [中文](README.zh.md) | [`pty`](pty/README.md) (`@deepseek-ai/dsh-pty`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.pty` | | `pty-local` (`@deepseek-ai/dsh-pty-local`) | Local `node-pty` backend, readiness detection, bounded terminal state, sandboxing, and process-session supervision | registers on `ctx.pty` | | `tool-pty` (`@deepseek-ai/dsh-tool-pty`) | Six model-facing tools and generic task integration for background sends | registers on `ctx.tools` | +| `tool-bash-persistent` (`@deepseek-ai/dsh-tool-bash-persistent`) | One model-facing `bash` backed by an owner-scoped reusable PTY shell | consumes `ctx.pty`, registers on `ctx.tools` | The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md). diff --git a/packages/pty/README.zh.md b/packages/pty/README.zh.md index 9fc262787b..e06ea496e3 100644 --- a/packages/pty/README.zh.md +++ b/packages/pty/README.zh.md @@ -9,5 +9,6 @@ | [`pty`](pty/README.md)(`@deepseek-ai/dsh-pty`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.pty` | | `pty-local`(`@deepseek-ai/dsh-pty-local`) | 本地 `node-pty` 后端、就绪检测、有界终端状态、沙箱与进程会话监管 | 注册到 `ctx.pty` | | `tool-pty`(`@deepseek-ai/dsh-tool-pty`) | 6 个面向模型的工具,并为后台发送集成通用任务 | 注册到 `ctx.tools` | +| `tool-bash-persistent`(`@deepseek-ai/dsh-tool-bash-persistent`) | 一个由所有者隔离可复用 PTY shell 支撑的模型可见 `bash` | 消费 `ctx.pty`,注册到 `ctx.tools` | 设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。 diff --git a/packages/pty/tool-bash-persistent/README.i18n.yaml b/packages/pty/tool-bash-persistent/README.i18n.yaml new file mode 100644 index 0000000000..2f15d109c1 --- /dev/null +++ b/packages/pty/tool-bash-persistent/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 packages/pty/tool-bash-persistent/README.md +README.md: 04c714d5489dbae8572e9339a4387a148450a0e9 +README.zh.md: adfb38b10409174d9558b963f5a2359cf819f04b diff --git a/packages/pty/tool-bash-persistent/README.md b/packages/pty/tool-bash-persistent/README.md new file mode 100644 index 0000000000..04c714d548 --- /dev/null +++ b/packages/pty/tool-bash-persistent/README.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-tool-bash-persistent + +English | [中文](README.zh.md) + +Model-facing `bash(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend and sandbox policy. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `backendType` | `shell` | Registered PTY backend used for each Agent shell. | +| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | +| `maxOutputChars` | `16000` | Prefix characters retained before the clipping notice. | +| `description` | Persistent-shell description | Model-facing environment contract. | + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance. + +#### Token effect + +Fixed schema cost while `bash` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and tells the model that the next call starts fresh. + +#### Token effect + +Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- The tool requires an owning Agent and a real PTY backend. +- Explicit `exit`, timeout, or cancellation discards shell state; the next call starts a fresh shell. +- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default. diff --git a/packages/pty/tool-bash-persistent/README.zh.md b/packages/pty/tool-bash-persistent/README.zh.md new file mode 100644 index 0000000000..adfb38b104 --- /dev/null +++ b/packages/pty/tool-bash-persistent/README.zh.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-tool-bash-persistent + +[English](README.md) | 中文 + +模型可见的 `bash(command)`,底层复用一个按所有者隔离的 `ctx.pty` shell。该包拥有工具契约和 shell 复用;PTY 后端与沙箱策略由部署选择。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY 后端。 | +| `timeoutMs` | `300000` | 单条命令的墙钟时间上限;超时会关闭 shell。 | +| `maxOutputChars` | `16000` | 截断提示前保留的前缀字符数。 | +| `description` | 持久 shell 描述 | 面向模型的环境契约。 | + +## 模型体验 + +### 工具 schema + +#### 模型所见 + +生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent),其中包含配置的 `description`。本插件不贡献独立系统提示词段;persona 与环境指导由部署负责。 + +#### Token 影响 + +`bash` 可见时产生固定的 schema 成本。 + +#### KV Cache 影响 + +配置的描述与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型所见 + +每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并告知模型下次调用从新 shell 开始。 + +#### Token 影响 + +随数据变化,并受 `maxOutputChars` 与固定截断提示约束。 + +#### KV Cache 影响 + +工具结果以追加方式位于可复用请求前缀之后。 + +## 已知限制与延后工作 + +- 工具需要拥有它的 Agent 和真实 PTY 后端。 +- 显式 `exit`、超时或取消会丢弃 shell 状态;下次调用创建新 shell。 +- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。 diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json new file mode 100644 index 0000000000..5022026733 --- /dev/null +++ b/packages/pty/tool-bash-persistent/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-tool-bash-persistent", + "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", + "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" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-pty": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts new file mode 100644 index 0000000000..b812fe8992 --- /dev/null +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -0,0 +1,380 @@ +/** + * Model-facing persistent `bash` tool over the owner-scoped PTY seam. + * @module @deepseek-ai/dsh-tool-bash-persistent + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { defineTool } from '@deepseek-ai/dsh-tools' + +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' +const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' +const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' +const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' +const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' +const SCROLLBACK_PAGE_LINES = 1_000 + +const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.' + +interface ResolvedConfig { + backendType: string + timeoutMs: number + maxOutputChars: number + description: string +} + +interface CommandMarkers { + start: string + end: string +} + +interface RetainedOutput { + text: string + truncated: boolean +} + +interface CapturedOutput { + text: string + incomplete: boolean +} + +interface PersistentShells { + get(owner: Agent, signal: AbortSignal): Promise + reset(owner: Agent, reason: string): Promise +} + +function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string { + if (content.length <= maxOutputChars && !incomplete) return content + return content.length <= maxOutputChars + ? content + TRUNCATED_MESSAGE + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function markers(): CommandMarkers { + const nonce = randomUUID() + return { + start: `__DSH_PERSISTENT_BASH_START_${nonce}__`, + end: `__DSH_PERSISTENT_BASH_END_${nonce}:`, + } +} + +function quoteForBash(value: string): string { + return `$'${value + .replaceAll('\\', '\\\\') + .replaceAll("'", "\\'") + .replaceAll('\r', '\\r') + .replaceAll('\n', '\\n')}'` +} + +function wrapCommand(command: string, marker: CommandMarkers): string { + // Keep the wrapper on one physical line. An interactive bash prints PS2 for + // embedded newlines before executing the buffer, which would leak terminal + // prompts and marker source text into the model-facing result. + return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"` +} + +function stripPrompt(text: string): string { + let result = text + while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) { + result = result.slice(0, result.endsWith('\r\n') + ? -SHELL_PROMPT.length - 2 + : -SHELL_PROMPT.length - 1) + } + while (result.endsWith(SHELL_PROMPT)) { + result = result.slice(0, -SHELL_PROMPT.length) + } + return result.endsWith('\n') ? result.slice(0, -1) : result +} + +function commandOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, +): CapturedOutput | undefined { + const text = snapshot.text + const end = text.lastIndexOf(marker.end) + if (end < 0) return undefined + const startMarker = text.lastIndexOf(marker.start, end) + const start = startMarker < 0 ? 0 : startMarker + marker.start.length + return { + text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), + incomplete: startMarker < 0 || snapshot.truncated, + } +} + +function promptCompleted(result: PtySendResult): boolean { + return result.viewport.endsWith(SHELL_PROMPT) + || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) + || result.viewport.endsWith(`${SHELL_PROMPT}\n`) +} + +function partialOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + fallback: string, +): CapturedOutput { + const startMarker = snapshot.text.lastIndexOf(marker.start) + if (startMarker >= 0) { + return { + text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), + incomplete: snapshot.truncated, + } + } + return { + text: stripPrompt(fallback), + incomplete: snapshot.truncated, + } +} + +async function pause(): Promise { + await new Promise(resolve => setTimeout(resolve, 25)) +} + +function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { + if (page.text.length === 0 || page.lineEnd <= offset) return undefined + return page.lineEnd +} + +function retainedScrollback( + ctx: Context, + owner: Agent, + id: PtySessionId, +): RetainedOutput { + const pages: string[] = [] + let offset = 0 + let truncated = false + while (true) { + const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + truncated ||= page.truncated + if (page.text.length > 0) pages.unshift(page.text) + const next = nextScrollbackOffset(page, offset) + if (next === undefined || next >= page.totalLines) break + offset = next + } + return { text: pages.join('\n'), truncated } +} + +function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { + const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) + return output.incomplete && output.text.length > 0 + ? LOST_PREFIX_MESSAGE + rendered + : rendered +} + +function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { + const pending = new WeakMap>() + const live = new Map() + const ownerCleanupInstalled = new WeakSet() + + const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { + if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.pty.kill(owner, id, reason) + } + + ctx.effect(() => async () => { + const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) + await Promise.all(closing) + live.clear() + }, 'tool-bash-persistent shell cleanup') + + const reset = async (owner: Agent, reason: string): Promise => { + pending.delete(owner) + const id = live.get(owner) + live.delete(owner) + if (id !== undefined) await close(owner, id, reason) + } + + const get = (owner: Agent, signal: AbortSignal): Promise => { + const existing = pending.get(owner) + if (existing !== undefined) return existing + const creating = (async () => { + try { + const cwd = owner.session.header.cwd + const spawned = await ctx.pty.spawn(owner, { + type: config.backendType, + ...cwd === undefined ? {} : { cwd }, + }, signal) + live.set(owner, spawned.sessionId) + if (!ownerCleanupInstalled.has(owner)) { + ownerCleanupInstalled.add(owner) + owner.ctx.effect(() => () => { + pending.delete(owner) + live.delete(owner) + }, 'tool-bash-persistent owner cache cleanup') + } + const setup = ctx.pty.startSend(owner, spawned.sessionId, { + text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`, + submit: true, + signal, + }) + const result = await setup.done + if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { + throw new Error('persistent bash shell did not accept initialization') + } + return spawned.sessionId + } catch (error: unknown) { + await reset(owner, 'persistent bash initialization failed') + throw error + } + })() + pending.set(owner, creating) + return creating + } + + return { get, reset } +} + +async function executeCommand( + ctx: Context, + shells: PersistentShells, + owner: Agent, + command: string, + config: ResolvedConfig, + upstream: AbortSignal, +): Promise { + using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE) + const id = await shells.get(owner, commandDeadline.signal) + const marker = markers() + const wrapped = wrapCommand(command, marker) + let first = true + let fallback = '' + + while (true) { + const operation = ctx.pty.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + const result = await operation.done + fallback += result.viewport + const snapshot = retainedScrollback(ctx, owner, id) + const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) + if (timedOut !== undefined) { + const partial = renderCaptured( + partialOutput(snapshot, marker, fallback), + config.maxOutputChars, + ) + await shells.reset(owner, 'persistent bash command timed out') + return [ + `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, + partial, + SHELL_RESET_MESSAGE, + ].join('\n') + } + const complete = commandOutput(snapshot, marker) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + if (result.sessionStatus.kind === 'exited') { + await shells.reset(owner, 'persistent bash shell exited') + return [ + renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') + } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent bash command aborted') + commandDeadline.signal.throwIfAborted() + } + if (promptCompleted(result)) { + return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated) + } + await pause() + } +} + +/** + * Register the model-facing persistent `bash` tool. + * @param ctx - plugin context carrying tools and the owner-scoped PTY service. + * @param config - selected PTY backend and command deadline. + */ +function registerPersistentBash(ctx: Context, config: ResolvedConfig): void { + const shells = persistentShells(ctx, config) + const queues = new WeakMap>() + + const serialized = async (owner: Agent, operation: () => Promise): Promise => { + const prior = queues.get(owner) ?? Promise.resolve() + const run = prior.then(operation, operation) + const tail = run.then(() => undefined, () => undefined) + queues.set(owner, tail) + try { + return await run + } finally { + if (queues.get(owner) === tail) queues.delete(owner) + } + } + + ctx.tools.register(defineTool({ + name: 'bash', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + description: 'The bash command to run. Relative path is preferred in the command.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + const owner = exec.agent + if (owner === undefined) throw new Error('bash requires an owning agent session') + return serialized(owner, async () => { + exec.signal.throwIfAborted() + return executeCommand(ctx, shells, owner, args.command, config, exec.signal) + }) + }, + presentCall: args => ({ card: 'terminal', title: args.command }), + })) +} + +export const name = 'tool-bash-persistent' +export const inject = ['tools', 'pty'] + +/** Configuration for the persistent Bash tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} + +/** Runtime configuration schema for the persistent Bash tool. */ +export const Config: z = z.object({ + backendType: z.string().default('shell'), + timeoutMs: z.number().default(300_000), + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), +}) + +/** Register one owner-scoped persistent `bash` tool. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + backendType: config.backendType ?? 'shell', + timeoutMs: config.timeoutMs ?? 300_000, + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + } + if (resolved.backendType.trim().length === 0) { + throw new Error('tool-bash-persistent: backendType must be non-empty') + } + if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) { + throw new Error('tool-bash-persistent: timeoutMs must be a positive safe integer') + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-bash-persistent: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-bash-persistent: description must be non-empty') + } + registerPersistentBash(ctx, resolved) +} diff --git a/packages/pty/tool-bash-persistent/src/invariant.ts b/packages/pty/tool-bash-persistent/src/invariant.ts new file mode 100644 index 0000000000..f6b5acfbc7 --- /dev/null +++ b/packages/pty/tool-bash-persistent/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash-persistent`. + * @module @deepseek-ai/dsh-tool-bash-persistent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash-persistent' + +/** Cordis companion plugin name. */ +export const name = 'tool-bash-persistent-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the tool adapter owns no independent durable state; + * PTY ownership and filesystem mutation relations stay with their services. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..b8586161f2 --- /dev/null +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -0,0 +1,156 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import * as PtyLocal from '@deepseek-ai/dsh-pty-local' +import SandboxProvider from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +class PassthroughSandbox extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + } +} + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId('persistent-bash-loader-agent') + const scope = ctx.plugin(() => {}) + const value: Agent = { + id, + options: {}, + session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }), + status: 'idle', + acceptsNextStep: false, + ctx: scope.ctx, + followup: () => {}, + steer: () => {}, + inject: () => {}, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip + +suite('persistent Bash through a real cordis.yml Loader composition', () => { + it('preserves cwd and environment across calls', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-persistent-bash-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-pty'", + "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-sandbox-policy'", + ' config:', + ' mode: danger-full-access', + ` workspaceRoot: ${JSON.stringify(root)}`, + "- name: '@deepseek-ai/dsh-pty-local'", + ' config:', + ' pollIntervalMs: 10', + ' exactProbeAfterMs: 20', + ' idleSilenceMs: 100', + ' handoffGraceMs: 100', + ' scrollbackLines: 20000', + ' timeoutMs: 2000', + ' disposeGraceMs: 500', + "- name: '@deepseek-ai/dsh-tool-bash-persistent'", + ' config:', + ' timeoutMs: 5000', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-pty', PtyService], + ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], + ['@deepseek-ai/dsh-pty-local', PtyLocal], + ['@deepseek-ai/dsh-tool-bash-persistent', ToolBashPersistent], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await context.loader.await() + + const owner = agent(context, root) + const signal = new AbortController().signal + const execute = (id: string, command: string) => context!.tools.execute({ + signal, + callId: CallId(id), + name: 'bash', + arguments: { command }, + agent: owner, + }) + + expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash']) + await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested') + const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"')) + expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) + expect(observed).not.toContain('DSH_PERSISTENT_BASH') + + const multiline = text(await execute( + 'multiline', + 'value="line one"\nprintf "%s:%s\\n" "$value" "it\'s fine"', + )) + expect(multiline).toBe("line one:it's fine") + expect(multiline).not.toContain('DSH_PERSISTENT_BASH') + + const heredoc = text(await execute( + 'heredoc', + "cat <<'EOF'\nalpha\nbeta\nEOF", + )) + expect(heredoc).toBe('alpha\nbeta') + + const large = text(await execute('large-output', 'seq 1 12050')) + expect(large.startsWith('1\n2\n3\n')).toBe(true) + expect(large).toContain('') + expect(large).not.toContain('beginning of this command output was dropped') + + const exited = text(await execute('exit', 'exit')) + expect(exited).toContain('next bash call starts from the workspace') + expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root) + }, 20_000) +}) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts new file mode 100644 index 0000000000..f757d6d1f8 --- /dev/null +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -0,0 +1,385 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import type { + PtyBackend, + PtyBackendSession, + PtyReadRequest, + PtySendOperation, + PtySendRequest, + PtySessionStatus, + PtySignal, + PtyWaitReason, +} from '@deepseek-ai/dsh-pty' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' + +const contexts: Context[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function agent(ctx: Context, cwd: string | undefined): Agent { + const id = SessionId(`persistent-bash-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const value: Agent = { + id, + options: {}, + session: new Session(id, [], { + version: 0, + id, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }), + status: 'idle', + acceptsNextStep: false, + ctx: scope.ctx, + followup: () => {}, + steer: () => {}, + inject: () => {}, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call( + ctx: Context, + owner: Agent | undefined, + command: string, + signal = new AbortController().signal, +) { + return ctx.tools.execute({ + signal, + callId: CallId(`persistent-bash-${++callNumber}`), + name: 'bash', + arguments: { command }, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +type StubMode = + | 'normal' + | 'prompt-only' + | 'prompt-crlf' + | 'empty-read' + | 'stalled-read' + | 'exit' + | 'wait-for-abort' + | 'idle-then-normal' + | 'large' + | 'end-only' + | 'init-exit' + | 'init-timeout' + | 'spawn-error' + +class StubPtySession implements PtyBackendSession { + readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' + readonly pid = 123 + statusValue: PtySessionStatus = { kind: 'running' } + scrollback = this.motd + closed: string[] = [] + mode: StubMode + sends = 0 + pendingText = '' + + constructor(mode: StubMode) { + this.mode = mode + } + + startSend(request: PtySendRequest): PtySendOperation { + this.sends += 1 + if (request.text.startsWith('stty -echo')) { + if (this.mode === 'init-exit') { + this.statusValue = { kind: 'exited', exitCode: 1, signal: null } + return this.operation(Promise.resolve(this.result('', 'session_exit'))) + } + if (this.mode === 'init-timeout') { + return this.operation(Promise.resolve(this.result('', 'timeout'))) + } + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) + } + if (this.mode === 'wait-for-abort') { + const done = new Promise>((resolve) => { + request.signal?.addEventListener('abort', () => { + this.scrollback += 'partial output' + resolve(this.result('partial output', 'stdin_read')) + }, { once: true }) + }) + return this.operation(done) + } + if (this.mode === 'idle-then-normal') { + this.mode = 'normal' + this.pendingText = request.text + return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) + } + if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { + const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' + const output = `bash: syntax error${newline}${this.motd}${newline}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const sent = request.text.length > 0 ? request.text : this.pendingText + this.pendingText = '' + const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0] + const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0] + if (this.mode === 'end-only') { + const output = `recovered output\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub' + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + if (this.mode === 'exit') { + const exitedOutput = `${start ?? ''}\nhello from stub\n` + this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) + } + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + + read(_request: PtyReadRequest) { + if (this.mode === 'empty-read') { + return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'stalled-read') { + return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } + } + const lines = this.scrollback.split('\n') + return { + text: this.scrollback, + totalLines: lines.length, + lineBegin: 0, + lineEnd: lines.length, + truncated: false, + } + } + + signal(_signal: PtySignal) { + return Promise.resolve({ delivered: true as const, targetPgid: 123 }) + } + + status() { + return this.statusValue + } + + async close(reason: string) { + this.closed.push(reason) + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + } + + private result(viewport: string, waitReason: PtyWaitReason) { + return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } + } + + private operation(done: Promise>): PtySendOperation { + return { + done, + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + } +} + +function stubBackend(initialMode: StubMode = 'normal') { + const sessions: StubPtySession[] = [] + const backend: PtyBackend = { + type: 'stub', + async spawn() { + if (initialMode === 'spawn-error') throw new Error('stub spawn failed') + const session = new StubPtySession(initialMode) + sessions.push(session) + return session + }, + } + return { backend, sessions } +} + +async function setup( + config: ToolBashPersistent.Config = { backendType: 'stub' }, + initialMode: StubMode = 'normal', +) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const stub = stubBackend(initialMode) + ctx.pty.registerBackend(stub.backend) + const fiber = await ctx.plugin(ToolBashPersistent, config) + return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } +} + +describe('tool-bash-persistent', () => { + it('registers a configurable schema and reuses one owner shell', async () => { + const { ctx, owner, stub } = await setup({ + backendType: 'stub', + description: 'deployment-specific persistent shell', + }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['bash']) + expect(schema?.description).toBe('deployment-specific persistent shell') + expect(schema?.parameters).toMatchObject({ + required: ['command'], + properties: { command: { type: 'string' } }, + }) + expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd' })) + .toEqual({ card: 'terminal', title: 'pwd' }) + + expect(text(await call(ctx, owner, 'echo one'))).toBe('hello from stub') + expect(text(await call(ctx, owner, 'echo two'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(1) + expect(stub.sessions[0]?.sends).toBe(3) + + const ownerWithoutCwd = agent(ctx, undefined) + expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + maxOutputChars: 10, + }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'idle-then-normal' + expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from') + + session.mode = 'prompt-only' + const promptFallback = text(await call(ctx, owner, 'bad {')) + expect(promptFallback).toContain('bash: synt') + expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + + session.mode = 'prompt-crlf' + session.scrollback = '' + const crlfPromptFallback = text(await call(ctx, owner, 'bad {')) + expect(crlfPromptFallback).toContain('bash: synt') + expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + + session.mode = 'end-only' + session.scrollback = '' + const missingStart = text(await call(ctx, owner, 'recover marker')) + expect(missingStart).toContain('recovered') + expect(missingStart).toContain('beginning of this command output was dropped') + expect(missingStart).toContain('') + + session.mode = 'large' + expect(text(await call(ctx, owner, 'large'))).toContain('') + + session.mode = 'exit' + const exited = text(await call(ctx, owner, 'exit')) + expect(exited).toContain('hello from') + expect(exited).toContain('next bash call starts from the workspace') + expect(session.closed).toContain('persistent bash shell exited') + + await call(ctx, owner, 'new shell') + expect(stub.sessions).toHaveLength(2) + const externallyClosed = ctx.pty.list(owner)[0]?.sessionId + expect(externallyClosed).toBeDefined() + await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') + await fiber.dispose() + expect(stub.sessions[1]?.closed).toEqual(['external cleanup']) + }) + + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'end-only' + session.scrollback = '' + expect(text(await call(ctx, owner, 'missing start'))) + .toContain('beginning of this command output was dropped') + + session.mode = 'empty-read' + expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub') + + session.mode = 'stalled-read' + expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + }) + + it('closes a timed-out shell and reports bounded partial output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const result = await call(ctx, owner, 'hang') + expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error') + expect(text(result)).toContain('partial output') + expect(text(result)).toContain('next bash call starts from the workspace') + expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out') + }) + + it('cancels in-flight work, resets the shell, and releases a queued call', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) + + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted') + expect(stub.sessions).toHaveLength(2) + }) + + it.each(['init-exit', 'init-timeout'] as const)( + 'fails initialization and closes the unusable shell for %s', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed') + }, + ) + + it('clears a failed spawn without trying to close an unpublished shell', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error') + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + }) + + it('rejects invalid config and invalid calls', async () => { + const { ctx, owner, stub } = await setup() + expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) + expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + + expect(() => { + ToolBashPersistent.apply(new Context(), { backendType: '' }) + }).toThrow('backendType must be non-empty') + expect(() => { + ToolBashPersistent.apply(new Context(), { timeoutMs: 0 }) + }).toThrow('timeoutMs must be a positive safe integer') + expect(() => { + ToolBashPersistent.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolBashPersistent.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/pty/tool-bash-persistent/tsconfig.json b/packages/pty/tool-bash-persistent/tsconfig.json new file mode 100644 index 0000000000..b1baf9db9a --- /dev/null +++ b/packages/pty/tool-bash-persistent/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../core/agent" }, + { "path": "../../core/tools" }, + { "path": "../pty" }, + { "path": "../../support/invariants" }, + { "path": "../../util/timeout" } + ] +} diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch new file mode 100644 index 0000000000..f0de7b9054 --- /dev/null +++ b/patches/node-pty@1.1.0.patch @@ -0,0 +1,60 @@ +diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js +index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644 +--- a/lib/unixTerminal.js ++++ b/lib/unixTerminal.js +@@ -26,10 +26,22 @@ var terminal_1 = require("./terminal"); + var utils_1 = require("./utils"); + var native = utils_1.loadNativeModule('pty'); + var pty = native.module; +-var helperPath = native.dir + '/spawn-helper'; +-helperPath = path.resolve(__dirname, helperPath); +-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; ++if (helperPath) { ++ helperPath = path.resolve(helperPath); ++} ++else { ++ var executableSibling = process.execPath + '-spawn-helper'; ++ if (fs.existsSync(executableSibling)) { ++ helperPath = executableSibling; ++ } ++ else { ++ helperPath = native.dir + '/spawn-helper'; ++ helperPath = path.resolve(__dirname, helperPath); ++ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); ++ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++ } ++} + var DEFAULT_FILE = 'sh'; + var DEFAULT_NAME = 'xterm'; + var DESTROY_SOCKET_TIMEOUT_MS = 200; +diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts +index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644 +--- a/src/unixTerminal.ts ++++ b/src/unixTerminal.ts +@@ -14,10 +14,20 @@ import { assign, loadNativeModule } from './utils'; + + const native = loadNativeModule('pty'); + const pty: IUnixNative = native.module; +-let helperPath = native.dir + '/spawn-helper'; +-helperPath = path.resolve(__dirname, helperPath); +-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; ++if (helperPath) { ++ helperPath = path.resolve(helperPath); ++} else { ++ const executableSibling = process.execPath + '-spawn-helper'; ++ if (fs.existsSync(executableSibling)) { ++ helperPath = executableSibling; ++ } else { ++ helperPath = native.dir + '/spawn-helper'; ++ helperPath = path.resolve(__dirname, helperPath); ++ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); ++ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++ } ++} + + const DEFAULT_FILE = 'sh'; + const DEFAULT_NAME = 'xterm'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18cb1ec0d1..0fc60b40cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: patchedDependencies: '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 + node-pty@1.1.0: fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985 importers: @@ -631,7 +632,7 @@ importers: devDependencies: node-pty: specifier: 1.1.0 - version: 1.1.0 + version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) packages/acp/acp: dependencies: @@ -2535,6 +2536,40 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/fs/tool-str-replace-editor: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../fs-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/command-goal: devDependencies: '@cordisjs/plugin-loader': @@ -3307,7 +3342,7 @@ importers: dependencies: node-pty: specifier: ^1.1.0 - version: 1.1.0 + version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -3337,6 +3372,55 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/pty/tool-bash-persistent: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../pty-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/pty/tool-pty: dependencies: schemastery: @@ -5520,6 +5604,12 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../../packages/pty/pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../../packages/pty/pty-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard @@ -5529,6 +5619,9 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../packages/sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-local '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../packages/sandbox/sandbox-policy @@ -5619,6 +5712,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../packages/bash/tool-bash + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:^ + version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis @@ -5631,6 +5727,9 @@ importers: '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../packages/fs/tool-str-replace-editor '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent @@ -15119,7 +15218,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0: + node-pty@1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985): dependencies: node-addon-api: 7.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40716f3403..8aad3a1f3d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,6 +39,9 @@ allowBuilds: node-addon-require-builtin: false # JSONL durability calls MoveFileExW with write-through publication on Windows. koffi: true + # The Python runtime deploy includes the reviewed workspace postinstall that + # places node-pty's spawn helper beside the compiled PTY backend. + '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true # The Landlock launcher family is our own sibling-repo release, consumed # fresh (hours old at each coordinated bump) — the release-age quarantine @@ -58,3 +61,4 @@ minimumReleaseAgeExclude: patchedDependencies: '@earendil-works/pi-tui@0.80.7': patches/@earendil-works__pi-tui@0.80.7.patch + node-pty@1.1.0: patches/node-pty@1.1.0.patch diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 5df1ca291d..f0d6c67967 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -1,6 +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: d2b6a1cfe9897026d567b2def301799069c350fb -README.zh.md: 2ffccef922d52923f7c6e373a01ed8b19d9a55c1 +# pnpm run verify-translation-pairing --write python/README.md +README.md: aee682e25fc33287c49131d0f5b92b136ed16bae +README.zh.md: 4404114fcdab78468991769a4657370f85997a88 diff --git a/python/README.md b/python/README.md index d2b6a1cfe9..aee682e25f 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/` 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. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform 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). +Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` plus the matching `-spawn-helper` required by `node-pty` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform 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 2ffccef922..4404114fcd 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/`,并同步进本包的 `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 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 +产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` 及 `node-pty` 所需的同名 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 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 90433c4f5f..44fcf94b60 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -1,6 +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: f2ccd8939e497d10359aafe8b1bd8b364875ed98 -README.zh.md: 30bdf46fee03c38a1f4b6e8b2b39d87e8174a3e0 +# pnpm run verify-translation-pairing --write python/sdk-runtime/README.md +README.md: 977bce41191d6c7716548dafde81d2c7ec14dec7 +README.zh.md: ade8455c56c27fcbe3e43a68abeaad98421cf720 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index f2ccd8939e..977bce4119 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,19 +8,19 @@ 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 distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built and validated as one runtime product. No Node installation is 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-demo/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. A missing exe raises `FileNotFoundError` naming both 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. A missing dev-only node carrier names its sole route, the build script. 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. 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. +Each wheel contains exactly one runtime executable and its matching native spawn helper. 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 runtime files, non-executable files, 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 - `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_runtime_path() -> Path` — the platform exe path (exe carrier only; it validates that the required sibling `-spawn-helper` is also installed). 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. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 30bdf46fee..ade8455c56 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,19 +8,19 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `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。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;构建与校验会把两者视作同一个运行时产物。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 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` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。固定标签为 `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 元组启动)。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 1c5b22e11a..108e77cf2c 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -13,6 +13,7 @@ _PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } +_SPAWN_HELPER_SUFFIX = "-spawn-helper" def _host_platform_tag() -> str: @@ -46,14 +47,23 @@ class RuntimeBuildHook(BuildHookInterface): ) 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 []) + runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] + helpers = [path for path in runtime_files if path.name.endswith(_SPAWN_HELPER_SUFFIX)] 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]}") + expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}" + if [path.name for path in helpers] != [expected_helper]: + found = ", ".join(path.name for path in helpers) or "none" + raise RuntimeError( + f"runtime wheel {platform_tag} must contain only {expected_helper}; found {found}" + ) + for executable in [executables[0], helpers[0]]: + if executable.stat().st_mode & stat.S_IXUSR == 0: + raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a1d8728d4c..cbec3a4923 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -42,6 +42,8 @@ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", @@ -49,6 +51,7 @@ "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -75,7 +78,9 @@ "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index b38df5a211..9228281ab2 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -3,9 +3,10 @@ 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 +- **exe (production)**: single-file Node executables named ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in - {x64, arm64}); the target machine needs no Node installation. + {x64, arm64}) plus a sibling ``-spawn-helper`` used by ``node-pty``; 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-demo/lib/bin.js`` on a @@ -27,6 +28,7 @@ import sys from pathlib import Path PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" +SPAWN_HELPER_SUFFIX = "-spawn-helper" RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" @@ -82,6 +84,12 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) + helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") + if not helper.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " + + _EXE_ACQUISITION_HINT + ) return path @@ -144,6 +152,7 @@ def _node_launch_args() -> tuple[str, str]: __all__ = [ "PACKAGE_METADATA_FILENAME", "RUNTIME_MODE_ENV_VAR", + "SPAWN_HELPER_SUFFIX", "bundled_default_config_path", "bundled_package_dir", "bundled_runtime_path", diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 38cb40862d..7b7c8254b1 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import runpy +import stat from pathlib import Path from types import SimpleNamespace @@ -37,3 +38,40 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: with pytest.raises(ValueError, match="must be stable X.Y.Z"): build_python_release.repository_version(tmp_path) + + +def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(b"helper") + spawn_helper.chmod(0o751) + destination = tmp_path / "staging" + + build_python_release.stage_runtime( + destination, + "1.2.3", + executable, + executable.name, + ) + + runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" + assert (runtime_dir / executable.name).read_bytes() == b"runtime" + copied_helper = runtime_dir / spawn_helper.name + assert copied_helper.read_bytes() == b"helper" + assert copied_helper.stat().st_mode & stat.S_IXUSR + + +def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + + with pytest.raises(FileNotFoundError, match="spawn helper"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index b72f73671e..03c82b25b0 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process' import { existsSync, mkdirSync, statSync } from 'node:fs' -import { copyFile, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' import { basename, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -19,6 +19,7 @@ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' /** The app entry inside the deployed closure. */ const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' +const SPAWN_HELPER_SUFFIX = '-spawn-helper' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' /** Pinned for reproducible builds. */ @@ -52,6 +53,11 @@ const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] +interface RuntimeProduct { + executable: string + spawnHelper: string +} + function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -254,6 +260,8 @@ class SingleExeBuild { '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', + // The production closure intentionally omits patched dev-only packages. + '--config.allow-unused-patches=true', this.staging, ]) if (this.cli.dryRun) { @@ -287,8 +295,9 @@ class SingleExeBuild { * @param target - the pkg target triple to build. * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. */ - async pack(target: Target): Promise { + async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) await this.run(`pkg ${target.spec}`, pnpmBin(), [ 'dlx', @@ -303,22 +312,59 @@ class SingleExeBuild { 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 + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) + } else { + const source = this.resolveSpawnHelper(target) + await copyFile(source, spawnHelper) + await chmod(spawnHelper, statSync(source).mode & 0o777) + } + return { executable: product, spawnHelper } + } + + /** + * Resolve the node-pty helper that matches a pkg target. + * @param target - the pkg target whose helper must be shipped. + * @returns a physical executable outside pkg's virtual snapshot. + */ + private resolveSpawnHelper(target: Target): string { + const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty') + const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' + const candidates = [ + join(nodePtyRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'spawn-helper'), + ] + const hostPlatform = process.platform === 'darwin' ? 'macos' : process.platform + const hostArch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined + if (target.platform === hostPlatform && target.arch === hostArch) { + candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper')) + } + const helper = candidates.find(candidate => existsSync(candidate)) + if (helper === undefined) { + throw new Error( + `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; ` + + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, + ) + } + if (statSync(helper).mode & 0o111) return helper + throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) } /** * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. */ - printProducts(products: string[]): void { + printProducts(products: RuntimeProduct[]): 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}`) + console.log(` ${product.executable}`) + console.log(` ${product.spawnHelper}`) continue } - const megabytes = statSync(product).size / (1024 * 1024) - console.log(` ${product} (${megabytes.toFixed(1)} MB)`) + for (const path of [product.executable, product.spawnHelper]) { + const megabytes = statSync(path).size / (1024 * 1024) + console.log(` ${path} (${megabytes.toFixed(1)} MB)`) + } } } @@ -327,19 +373,24 @@ class SingleExeBuild { * carrier is already in place, and `dist-exe/` retains upload copies. * @param products - the product paths returned by {@link pack}. */ - async syncToPythonRuntime(products: string[]): Promise { + async syncToPythonRuntime(products: RuntimeProduct[]): 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))}`) + for (const path of [product.executable, product.spawnHelper]) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) + } } 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}`) + for (const path of [product.executable, product.spawnHelper]) { + const destination = join(destDir, basename(path)) + await copyFile(path, destination) + await chmod(destination, statSync(path).mode & 0o777) + console.log(`build-exe-for-python-sdk: synced ${destination}`) + } } } @@ -358,7 +409,12 @@ class SingleExeBuild { } console.log(`build-exe-for-python-sdk: ${label}: ${printable}`) await new Promise((resolvePromise, reject) => { - const child = spawn(command, args, { cwd: root, stdio: 'inherit' }) + const child = spawn(command, args, { + cwd: root, + stdio: 'inherit', + // Artifact builds must not mutate or validate a developer's Git hooks. + env: { ...process.env, CI: 'true' }, + }) child.once('error', (error) => { reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`)) }) @@ -383,7 +439,7 @@ async function main(): Promise { await pipeline.build() await pipeline.deployStaging() await pipeline.injectPkgConfig() - const products: string[] = [] + const products: RuntimeProduct[] = [] for (const target of cli.targets) products.push(await pipeline.pack(target)) pipeline.printProducts(products) await pipeline.syncToPythonRuntime(products) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index e0e90aa818..915968b30d 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -22,6 +22,7 @@ PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } +SPAWN_HELPER_SUFFIX = "-spawn-helper" def main() -> None: @@ -136,6 +137,11 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ 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}") + spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") + if not spawn_helper.is_file(): + raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") + if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -143,6 +149,9 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ destination_executable = runtime_dir / executable_name shutil.copyfile(executable, destination_executable) destination_executable.chmod(executable.stat().st_mode & 0o777) + destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" + shutil.copyfile(spawn_helper, destination_helper) + destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) def verify_wheel( @@ -161,16 +170,24 @@ def verify_wheel( 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] + runtime_files = [ + name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name + ] + helpers = [name for name in runtime_files if name.endswith(SPAWN_HELPER_SUFFIX)] + executables = [name for name in runtime_files if not name.endswith(SPAWN_HELPER_SUFFIX)] 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}") + expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" + if len(helpers) != 1 or not helpers[0].endswith(f"/runtime/{expected_helper}"): + raise RuntimeError(f"{wheel} must contain exactly {expected_helper}, found {helpers}") + for executable in [executables[0], helpers[0]]: + mode = archive.getinfo(executable).external_attr >> 16 + if mode & stat.S_IXUSR == 0: + raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") + elif runtime_files: + raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": requirements = metadata.get_all("Requires-Dist") or [] expected_requirement = f"deepseek-harness-runtime-bin=={version}" diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 7bdc8b68a8..dab261d11e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -33,9 +33,11 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' import PtyService from '@deepseek-ai/dsh-pty' import * as ToolPty from '@deepseek-ai/dsh-tool-pty' import * as ToolGoal from '@deepseek-ai/dsh-tool-goal' @@ -217,6 +219,32 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.', }, + { + pkg: '@deepseek-ai/dsh-tool-bash-persistent', + dir: 'tool-bash-persistent', + source: 'packages/pty/tool-bash-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'], + writes: ['tool/call', 'PTY shell state', 'tool/result'], + async mount(ctx) { + await ctx.plugin(PtyService) + await ctx.plugin(ToolBashPersistent) + }, + note: + 'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.', + }, + { + pkg: '@deepseek-ai/dsh-tool-str-replace-editor', + dir: 'tool-str-replace-editor', + source: 'packages/fs/tool-str-replace-editor/src/index.ts', + requires: ['ctx.tools', 'ctx.fs'], + writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'], + async mount(ctx) { + await ctx.plugin(LocalFileSystem) + await ctx.plugin(ToolStrReplaceEditor) + }, + note: + 'Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface.', + }, { pkg: '@deepseek-ai/dsh-tool-fs', dir: 'tool-fs', diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 0019654fdc..11500b5ec7 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -25,6 +25,14 @@ 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" +PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." +PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" +PERSISTENT_EDITOR_PATH: str | None = None +PERSISTENT_BASH_COMMAND = ( + "counter=$(( ${counter:-0} + 1 )); export counter; " + "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " + "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" +) 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." @@ -96,6 +104,49 @@ CUSTOM_CORDIS = """\ - id: cordis-tool name: '@deepseek-ai/dsh-tool-cordis' """ +PERSISTENT_TOOLS_CORDIS = """\ +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' +- id: llm + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD +- id: pty + name: '@deepseek-ai/dsh-pty' +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' +- id: fs + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.env.DSH_CWD +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: 'You are a helpful software engineer assistant.' + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT + compression: 'none' +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' +""" class MockModelHandler(BaseHTTPRequestHandler): @@ -132,6 +183,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) + persistent = persistent_tool_followup(body, call_id, tool_name, tool_text) + if persistent is not None: + return persistent advanced = advanced_tool_followup(body, call_id, tool_name, tool_text) if advanced is not None: return advanced @@ -144,6 +198,15 @@ 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 == PERSISTENT_TOOLS_PROMPT: + names = advertised_tool_names(body) + if names != {"bash", "str_replace_editor"}: + raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") + return tool_call_chunks( + "persistent-bash-1", + "bash", + {"command": PERSISTENT_BASH_COMMAND}, + ) if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: return text_chunks("DIRECT_CHILD_OK") if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: @@ -178,6 +241,44 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(EXPECTED_TEXT) +def persistent_tool_followup( + body: dict[str, object], + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Verify packaged PTY persistence, then invoke the packaged editor.""" + if not call_id.startswith("persistent-"): + return None + if call_id == "persistent-bash-1" and tool_name == "bash": + if "COUNT=1" not in tool_text: + raise AssertionError(f"first persistent bash call lost its output: {tool_text}") + return tool_call_chunks( + "persistent-bash-2", + "bash", + {"command": PERSISTENT_BASH_COMMAND}, + ) + if call_id == "persistent-bash-2" and tool_name == "bash": + if "COUNT=2 CWD=/tmp" not in tool_text: + raise AssertionError(f"persistent bash did not retain state: {tool_text}") + if PERSISTENT_EDITOR_PATH is None: + raise AssertionError("persistent editor smoke path was not initialized") + return tool_call_chunks( + "persistent-editor", + "str_replace_editor", + { + "command": "create", + "path": PERSISTENT_EDITOR_PATH, + "file_text": "created by packaged editor\n", + }, + ) + if call_id == "persistent-editor" and tool_name == "str_replace_editor": + if "New file created successfully" not in tool_text: + raise AssertionError(f"packaged editor did not create its file: {tool_text}") + return text_chunks(PERSISTENT_TOOLS_TEXT) + raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}") + + def advanced_tool_followup( body: dict[str, object], call_id: str, @@ -357,14 +458,14 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "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", "sdk-snapshot", "direct"} and args.exe is None: - parser.error("--exe is required for custom, snapshot, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None: + parser.error("--exe is required for custom, persistent, 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(): @@ -376,6 +477,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-persistent"}: + assert args.exe is not None + smoke_sdk_persistent_tools(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) @@ -439,6 +543,41 @@ 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_persistent_tools(base_url: str, executable: Path) -> None: + """Exercise native PTY state and the editor through the packaged executable.""" + global PERSISTENT_EDITOR_PATH + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: + root = Path(temporary).resolve() + PERSISTENT_EDITOR_PATH = str(root / "created.txt") + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(PERSISTENT_TOOLS_CORDIS) + with DeepSeekHarness( + provider="deepseek", + 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(PERSISTENT_TOOLS_PROMPT, session_id="persistent-tools-smoke") + + assert result.status == "ok", result + event_text = json.dumps(result.events) + if PERSISTENT_TOOLS_TEXT not in event_text: + raise AssertionError(f"packaged tools run emitted no final response: {result.events}") + created = root / "created.txt" + if created.read_text() != "created by packaged editor\n": + raise AssertionError(f"packaged editor wrote unexpected content: {created.read_text()!r}") + assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + PERSISTENT_EDITOR_PATH = None + + 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 diff --git a/tsconfig.host.json b/tsconfig.host.json index e2112b7f6a..254afc9535 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -103,6 +103,7 @@ { "path": "./packages/bash/bash" }, { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, + { "path": "./packages/pty/tool-bash-persistent" }, { "path": "./packages/pty/tool-pty" }, { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime-worker" }, @@ -120,6 +121,7 @@ { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-fs-search" }, + { "path": "./packages/fs/tool-str-replace-editor" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, { "path": "./packages/compact/compact-tool-result-prune" }, From d5e7212b31c0546ddb17eab466d7932c5e29fc13 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:23:34 +0800 Subject: [PATCH 02/41] fix(build): refresh node-pty patch hash --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b271ff848b..4c57d106a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 - node-pty@1.1.0: fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985 + node-pty@1.1.0: 4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15 importers: @@ -650,7 +650,7 @@ importers: devDependencies: node-pty: specifier: 1.1.0 - version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) + version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) packages/acp/acp: dependencies: @@ -3384,7 +3384,7 @@ importers: dependencies: node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) + version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -15269,7 +15269,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985): + node-pty@1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15): dependencies: node-addon-api: 7.1.1 From dbdfb8d3b7de697a66c449172310868fb69a8f4b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:29:16 +0800 Subject: [PATCH 03/41] test(tools): include persistent tool schemas --- packages/core/tools/tests/gen-tool-catalog.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 3754595f56..1ab8bc2730 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { From 260ea24594613a00460c1e38a0dabf37579d8ba8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 15:21:56 +0800 Subject: [PATCH 04/41] fix(tools): harden persistent tool integrations --- docs/config-catalog.md | 12 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 4 +- examples/jsonrpc-agent/README.i18n.yaml | 6 +- examples/jsonrpc-agent/README.md | 13 + examples/jsonrpc-agent/README.zh.md | 13 + .../jsonrpc-agent/persistent-tools.cordis.yml | 58 ++++ .../tests/persistent-tools.snapshot.spec.ts | 214 +++++++++++++++ .../persistent-tools/behavior.expected.json | 57 ++++ examples/package.json | 2 + .../examples/agent-spine-demo/src/index.ts | 6 +- .../tool-str-replace-editor/README.i18n.yaml | 4 +- packages/fs/tool-str-replace-editor/README.md | 7 +- .../fs/tool-str-replace-editor/README.zh.md | 7 +- .../fs/tool-str-replace-editor/package.json | 6 + .../fs/tool-str-replace-editor/src/index.ts | 250 +++++++++++++++--- .../tests/tools.spec.ts | 131 ++++++++- .../fs/tool-str-replace-editor/tsconfig.json | 2 + .../pty/tool-bash-persistent/src/index.ts | 96 +++++-- .../tool-bash-persistent/tests/tools.spec.ts | 82 +++++- pnpm-lock.yaml | 18 ++ python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- scripts/build-exe-for-python-sdk.ts | 4 +- scripts/smoke-python-runtime.py | 35 ++- 26 files changed, 927 insertions(+), 112 deletions(-) create mode 100644 examples/jsonrpc-agent/persistent-tools.cordis.yml create mode 100644 examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1e3ed7b6ea..0e92e4e06e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -153,7 +153,11 @@ export interface Config { sessionTitle?: SessionTitleConfig /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ workspaceContext: workspaceContext.Config | false - /** Skill registry, local provider, and model-facing consumer config. */ + /** + * Skill registry, local provider, and model-facing consumer config. + * Skills use `enabled` because one nested config controls a provider stack; + * single model-tool plugins use `Config | false` to disable that one consumer. + */ skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false @@ -1589,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:340`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1762,10 +1766,12 @@ export interface Config { description?: string /** Require local absolute paths like the canonical editor contract (default true). */ requireAbsolutePath?: boolean + /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ + expandTabsOnMutation?: boolean } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:373`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:539`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 63741d22a0..ba952ea8bb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,9 +26,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 0c3b1f6280..ec2fbb2ccc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -670,6 +670,8 @@ flowchart TD pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_invariants + pkg_tool_str_replace_editor --> pkg_sandbox + pkg_tool_str_replace_editor --> pkg_sandbox_policy pkg_tool_str_replace_editor --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants @@ -1095,7 +1097,7 @@ flowchart TD | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 59c18e0131..1ff308b60f 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -1,6 +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: 6ee4e9d824315bde76b7a534679f018df9a6d3e8 -README.zh.md: dc9b6233e7074e7a9b13bf10bcd2f310b0ad7bf3 +# pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md +README.md: 9a4c715e8988f52b647dbbd6b14a478cc1357d92 +README.zh.md: fa792f9cf6bdad4f32a980f478a131f19de97611 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 6ee4e9d824..9a4c715e89 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -25,3 +25,16 @@ The surrounding runtime also loads JSONL session persistence and automatic conte | `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. + +## Persistent tools variant + +[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) is a minimal runnable variant whose model-facing surface is exactly: + +- owner-scoped persistent `bash` +- `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` + +It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless behavior snapshot drives the shipped JSON-RPC runtime through both tools and proves that shell cwd/environment survive across calls: + +```bash +pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +``` diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index dc9b6233e7..fa792f9cf6 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -25,3 +25,16 @@ | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。 + +## 持久工具变体 + +[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) 是一个最小可运行变体,面向模型的能力严格只有: + +- agent 独占、状态持久的 `bash` +- 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` + +它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥行为快照会通过正式 JSON-RPC runtime 驱动这两个工具,并验证 shell 的 cwd 与环境变量能跨调用保留: + +```bash +pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +``` diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml new file mode 100644 index 0000000000..b5ae81b100 --- /dev/null +++ b/examples/jsonrpc-agent/persistent-tools.cordis.yml @@ -0,0 +1,58 @@ +# Minimal unattended composition for the persistent Bash and string-replace +# editor. It is runnable through the JSON-RPC example runtime and intentionally +# keeps the model-facing surface to exactly these two tools. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() + +- id: pty + name: '@deepseek-ai/dsh-pty' + +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' + +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: 'You are a helpful software engineer assistant.' + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false + +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + compression: none diff --git a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts new file mode 100644 index 0000000000..18d1454837 --- /dev/null +++ b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts @@ -0,0 +1,214 @@ +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' + +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) +const configPath = fileURLToPath(new URL('../persistent-tools.cordis.yml', import.meta.url)) +const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const expectedPath = fileURLToPath(new URL('./snapshots/persistent-tools/behavior.expected.json', import.meta.url)) + +interface ModelRequest { + messages?: Array> + tools?: Array<{ function?: { name?: string; parameters?: { required?: string[] } } }> +} + +function sseToolCall(id: string, name: string, args: Record): string[] { + return [ + 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', + `data: ${JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id, + type: 'function', + function: { name, arguments: JSON.stringify(args) }, + }], + }, + }], + })}\n\n`, + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', + 'data: [DONE]\n\n', + ] +} + +function sseText(text: string): string[] { + return [ + 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', + `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`, + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', + 'data: [DONE]\n\n', + ] +} + +function messageText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content.flatMap((block) => { + if (typeof block !== 'object' || block === null) return [] + const text = (block as { text?: unknown }).text + return typeof text === 'string' ? [text] : [] + }).join('') +} + +function latestToolCall(messages: Array>): { id: string; name: string } { + for (const message of messages.toReversed()) { + const calls = message.tool_calls + if (!Array.isArray(calls)) continue + const call = (calls as unknown[]).at(-1) + if (typeof call !== 'object' || call === null) continue + const id = (call as { id?: unknown }).id + const fn = (call as { function?: { name?: unknown } }).function + if (typeof id === 'string' && typeof fn?.name === 'string') return { id, name: fn.name } + } + throw new Error('model request has no preceding tool call') +} + +function normalize(value: string, cwd: string): string { + return value.replaceAll(cwd, '{{cwd}}') +} + +describe('jsonrpc persistent tools snapshot', () => { + it('runs persistent shell state and editor mutations keylessly', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-persistent-tools-')) + const sessionRoot = join(cwd, '.sessions') + const target = join(cwd, 'note.txt') + const requests: ModelRequest[] = [] + const modelServer = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + const parsed = JSON.parse(body) as ModelRequest + requests.push(parsed) + const messages = parsed.messages ?? [] + const latest = messages.at(-1) + if (latest === undefined) throw new Error('model request has no messages') + let chunks: string[] + if (latest.role !== 'tool') { + chunks = sseToolCall('bash-1', 'bash', { + command: 'cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', + }) + } else { + const call = latestToolCall(messages) + const toolText = messageText(latest.content) + if (call.id === 'bash-1') { + expect(toolText).toContain('COUNT=1 CWD=/tmp') + chunks = sseToolCall('bash-2', 'bash', { + command: 'DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', + }) + } else if (call.id === 'bash-2') { + expect(toolText).toContain('COUNT=2 CWD=/tmp') + chunks = sseToolCall('editor-create', 'str_replace_editor', { + command: 'create', + path: target, + file_text: 'alpha\n', + }) + } else if (call.id === 'editor-create') { + expect(toolText).toContain('New file created successfully') + chunks = sseToolCall('editor-replace', 'str_replace_editor', { + command: 'str_replace', + path: target, + old_str: 'alpha', + new_str: 'beta', + }) + } else if (call.id === 'editor-replace') { + expect(toolText).toContain('has been edited successfully') + chunks = sseText('PERSISTENT_TOOLS_OK') + } else { + throw new Error(`unexpected tool call ${call.id}`) + } + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const chunk of chunks) response.write(chunk) + response.end() + }) + }) + await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) + const address = modelServer.address() + if (address === null || typeof address === 'string') throw new Error('model server did not bind') + const launch = resolveExampleLaunch({ + srcBin: runtimeBin, + configArgs: [], + tsconfigPath: repoTsconfig, + }) + const harness = new DeepSeekHarness({ + launch: { + command: launch.command, + args: launch.args, + cwd: repoRoot, + env: { + ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, + ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, + DSH_CORDIS_CONFIG: configPath, + DSH_CWD: cwd, + DSH_SESSION_ROOT: sessionRoot, + DEEPSEEK_API_KEY: 'keyless-local-mock', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + requestTimeoutMs: 60_000, + }, + cwd, + provider: 'deepseek', + model: 'deepseek-v4-flash', + }) + + try { + const result = await harness.run( + 'Prove that bash state persists, then create and edit note.txt.', + { sessionId: 'persistent-tools-snapshot' }, + ) + const calls = result.events.flatMap((event) => { + if (event.type !== 'tool/call') return [] + return [{ + name: event.data.name, + arguments: normalize(event.data.arguments, cwd), + }] + }) + const results = result.events.flatMap((event) => { + if (event.type !== 'tool/result') return [] + return event.data.message.content.flatMap((block) => { + if (block.type !== 'tool-result') return [] + return block.content.flatMap(content => + content.type === 'text' + ? [{ text: normalize(content.text, cwd) }] + : []) + }) + }) + const tools = (requests[0]?.tools ?? []).map(tool => ({ + name: tool.function?.name, + required: tool.function?.parameters?.required ?? [], + })).sort((left, right) => { + const leftName = String(left.name) + const rightName = String(right.name) + return leftName < rightName ? -1 : leftName > rightName ? 1 : 0 + }) + const behavior = { + tools, + calls, + results, + final: { + status: result.status, + reason: result.reason, + response: result.finalResponse, + file: await readFile(target, 'utf8'), + }, + } + if (process.env.DSH_SNAPSHOT === 'refresh') { + await writeFile(expectedPath, `${JSON.stringify(behavior, null, 2)}\n`) + } + expect(behavior).toEqual(JSON.parse(await readFile(expectedPath, 'utf8'))) + } finally { + await harness.close() + await new Promise(resolve => modelServer.close(() => { resolve() })) + await rm(cwd, { recursive: true, force: true }) + } + }, 75_000) +}) diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json new file mode 100644 index 0000000000..18b54e9312 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json @@ -0,0 +1,57 @@ +{ + "tools": [ + { + "name": "bash", + "required": [ + "command" + ] + }, + { + "name": "str_replace_editor", + "required": [ + "command", + "path" + ] + } + ], + "calls": [ + { + "name": "bash", + "arguments": "{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" + }, + { + "name": "bash", + "arguments": "{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" + }, + { + "name": "str_replace_editor", + "arguments": "{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}" + }, + { + "name": "str_replace_editor", + "arguments": "{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}" + } + ], + "results": [ + { + "text": "COUNT=1 CWD=/tmp" + }, + { + "text": "COUNT=2 CWD=/tmp" + }, + { + "text": "New file created successfully at: {{cwd}}/note.txt" + }, + { + "text": "The file {{cwd}}/note.txt has been edited successfully." + } + ], + "final": { + "status": "ok", + "reason": { + "kind": "completed" + }, + "response": "PERSISTENT_TOOLS_OK", + "file": "beta\n" + } +} diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..d98371a5ef 100644 --- a/examples/package.json +++ b/examples/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-timeout-policy": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index cfa5ac3ccd..32e348bf75 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -105,7 +105,11 @@ export interface Config { sessionTitle?: SessionTitleConfig /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ workspaceContext: workspaceContext.Config | false - /** Skill registry, local provider, and model-facing consumer config. */ + /** + * Skill registry, local provider, and model-facing consumer config. + * Skills use `enabled` because one nested config controls a provider stack; + * single model-tool plugins use `Config | false` to disable that one consumer. + */ skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 1f72b1a211..10b1f19182 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-str-replace-editor/README.md -README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b -README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d +README.md: 8ac6a22f24ddcdd3818b346e3426e58e718027e2 +README.zh.md: cf82b132b63730af209c159066a70f6a18b77f39 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 2d98b51d56..8ac6a22f24 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -11,6 +11,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w | `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | | `description` | Editor command guide | Model-facing tool description. | | `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | +| `expandTabsOnMutation` | `true` | Preserve the canonical Claude SWE behavior that expands tabs across the whole file before replace/insert. Set `false` for atomic literal replacement that preserves unrelated tabs. | ## Tool @@ -36,7 +37,7 @@ Prefix-stable while the configured description and schema remain unchanged. #### What the model sees -Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. +Views return numbered text or a shallow directory listing. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. #### Token effect @@ -50,5 +51,5 @@ Append-only tool results follow the reusable request prefix. - Operations target UTF-8 text; binary files are unsupported. - `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. -- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor. -- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins. +- Canonical mode (`expandTabsOnMutation: true`) expands tabs in the entire file before replacement or insertion, including lines outside the edited region. Set it to `false` for Makefiles and other tab-sensitive files. +- Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index a2ee8f1e36..cf82b132b6 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -11,6 +11,7 @@ | `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | | `description` | 编辑器命令指南 | 面向模型的工具描述。 | | `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | +| `expandTabsOnMutation` | `true` | 保留 Claude SWE 参考行为:替换/插入前展开整个文件的制表符。设为 `false` 时使用原子字面量替换,并保留未触及的制表符。 | ## 工具 @@ -36,7 +37,7 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使 #### 模型所见 -查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 +查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 #### Token 影响 @@ -50,5 +51,5 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使 - 操作面向 UTF-8 文本,不支持二进制文件。 - `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 -- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。 -- 安全与先读后改策略委托给挂载的文件系统和策略插件。 +- 规范模式(`expandTabsOnMutation: true`)会在替换或插入前展开整个文件中的制表符,包括未编辑区域。Makefile 等依赖制表符的文件应设为 `false`。 +- 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。 diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 88d3af8d53..0fc6e54ec6 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -28,6 +28,8 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,8 +40,12 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index c4a16e5437..5d9855c1da 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -7,9 +7,12 @@ import { isAbsolute } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs' +import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { ToolCallView, ToolRunContext } from '@deepseek-ai/dsh-tools' const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' @@ -49,17 +52,68 @@ function expandTabs(content: string, tabSize = 8): string { return result } +function codepointCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function matchOffsets(content: string, search: string): number[] { + const offsets: number[] = [] + let offset = 0 + while (true) { + const match = content.indexOf(search, offset) + if (match < 0) return offsets + offsets.push(match) + offset = match + search.length + } +} + +function lineNumbersAt(content: string, offsets: readonly number[]): number[] { + let line = 1 + let cursor = 0 + return offsets.map((offset) => { + while (cursor < offset) { + if (content[cursor] === '\n') line += 1 + cursor += 1 + } + return line + }) +} + +class MutationPolicy { + private readonly policy: SandboxPolicyService | undefined + + constructor(ctx: Context) { + this.policy = ctx.fs.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (ctx.fs.sandboxMode !== undefined && this.policy === undefined) { + throw new Error('tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing') + } + } + + resolve(exec: ToolRunContext): SandboxExecutionPolicy | undefined { + return this.policy?.resolve({ + ...exec.agent === undefined ? {} : { session: exec.agent.session }, + }) + } + + mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown { + if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error + const mode = (policy as SandboxExecutionPolicy).mode + return new FsError(sandboxDenialMarker(mode), 'FS_SANDBOX_DENIED', { cause: error }) + } +} + async function resolveTarget( ctx: Context, path: string, requireAbsolutePath: boolean, exec: ToolRunContext, + workspaceRoot?: string, ): Promise { if (path.trim().length === 0) throw new Error('path must be a non-empty string') if (requireAbsolutePath && !isAbsolute(path)) { throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) } - const cwd = exec.agent?.session.header.cwd + const cwd = exec.agent?.session.header.cwd ?? workspaceRoot return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal }) } @@ -158,8 +212,8 @@ async function listDirectory( const rows: string[] = [] for (const entry of entries.filter(candidate => !candidate.name.startsWith('.') - && !candidate.name.startsWith('node_modules') - && !candidate.name.startsWith('__pycache__'))) { + && candidate.name !== 'node_modules' + && candidate.name !== '__pycache__')) { const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?' rows.push(`${type}\t${entry.target.displayPath}`) if (entry.type === 'directory' && depth < 2) { @@ -172,7 +226,7 @@ async function listDirectory( rows.sort((left, right) => { const leftPath = left.slice(left.indexOf('\t') + 1) const rightPath = right.slice(right.indexOf('\t') + 1) - return leftPath.localeCompare(rightPath) + return codepointCompare(leftPath, rightPath) }) const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars) return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n` @@ -204,78 +258,124 @@ async function viewPath( async function createFile( ctx: Context, + policy: MutationPolicy, path: string, fileText: string | undefined, requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { const content = requiredForCommand(fileText, 'file_text', 'create') - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const sandboxPolicy = policy.resolve(exec) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) if (await ctx.fs.stat(target, exec.signal) !== undefined) { throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) } - const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal) + const intent = await ctx.waterfall( + 'fs/write-intent', + target, + exec, + () => ({ kind: 'createIfAbsent' } as const), + ) + let outcome + try { + outcome = await ctx.fs.writeText( + target, + content, + intent ?? { kind: 'createIfAbsent' }, + exec.signal, + sandboxPolicy, + ) + } catch (error: unknown) { + throw policy.mapError(error, sandboxPolicy) + } ctx.emit('fs/observed', target, outcome.version, exec) return `New file created successfully at: ${target.displayPath}` } async function replaceInFile( ctx: Context, + policy: MutationPolicy, path: string, oldStr: string | undefined, newStr: string | undefined, requireAbsolutePath: boolean, + expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) - const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false)) - const newValue = expandTabs(newStr ?? '') + const sandboxPolicy = policy.resolve(exec) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) + const rawOldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) + const oldValue = expandTabsOnMutation ? expandTabs(rawOldValue) : rawOldValue + const newValue = expandTabsOnMutation ? expandTabs(newStr ?? '') : newStr ?? '' const info = await statExisting(ctx, target, 'str_replace', exec) if (info.type !== 'file') { throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const before = expandTabs(await ctx.fs.readText(target, exec.signal)) - const occurrences = before.split(oldValue).length - 1 - if (occurrences === 0) { + const rawBefore = await ctx.fs.readText(target, exec.signal) + const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore + const offsets = matchOffsets(before, oldValue) + if (offsets.length === 0) { throw new FsError( `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, 'FS_EDIT_NOT_FOUND', ) } - if (occurrences > 1) { - const lines = before.split('\n') - .flatMap((line, index) => line.includes(oldValue) ? [index + 1] : []) + if (offsets.length > 1) { + const lines = lineNumbersAt(before, offsets) throw new FsError( `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`, 'FS_AMBIGUOUS_EDIT', ) } - const outcome = await ctx.fs.writeText( - target, - before.replace(oldValue, newValue), - { kind: 'replaceIfVersion', version: info.version }, - exec.signal, - ) + let outcome + try { + outcome = expandTabsOnMutation + ? await ctx.fs.writeText( + target, + before.replace(oldValue, newValue), + intent === undefined + ? { kind: 'replaceIfVersion', version: info.version } + : { kind: 'replaceIfVersion', version: intent.version }, + exec.signal, + sandboxPolicy, + ) + : await ctx.fs.editText( + target, + { oldString: oldValue, newString: newValue, replaceAll: false }, + intent ?? { version: info.version }, + exec.signal, + sandboxPolicy, + ) + } catch (error: unknown) { + throw policy.mapError(error, sandboxPolicy) + } ctx.emit('fs/observed', target, outcome.version, exec) return `The file ${target.displayPath} has been edited successfully.` } async function insertInFile( ctx: Context, + policy: MutationPolicy, path: string, insertLine: number | undefined, newStr: string | undefined, requireAbsolutePath: boolean, + expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') - const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert')) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const rawValue = requiredForCommand(newStr, 'new_str', 'insert') + const value = expandTabsOnMutation ? expandTabs(rawValue) : rawValue + const sandboxPolicy = policy.resolve(exec) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const info = await statExisting(ctx, target, 'insert', exec) if (info.type !== 'file') { throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const before = expandTabs(await ctx.fs.readText(target, exec.signal)) + const rawBefore = await ctx.fs.readText(target, exec.signal) + const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore const lines = before.split('\n') if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { throw new Error( @@ -287,12 +387,15 @@ async function insertInFile( ...value.split('\n'), ...lines.slice(insertLine), ].join('\n') - const outcome = await ctx.fs.writeText( - target, - after, - { kind: 'replaceIfVersion', version: info.version }, - exec.signal, - ) + const expected: FsWriteIntent = intent === undefined + ? { kind: 'replaceIfVersion', version: info.version } + : { kind: 'replaceIfVersion', version: intent.version } + let outcome + try { + outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy) + } catch (error: unknown) { + throw policy.mapError(error, sandboxPolicy) + } ctx.emit('fs/observed', target, outcome.version, exec) return `The file ${target.displayPath} has been edited successfully.` } @@ -301,10 +404,59 @@ interface ResolvedConfig { maxOutputChars: number description: string requireAbsolutePath: boolean + expandTabsOnMutation: boolean +} + +function presentEditorCall(args: { + command: 'view' | 'create' | 'str_replace' | 'insert' + path: string + file_text?: string + insert_line?: number + new_str?: string + old_str?: string +}): ToolCallView { + switch (args.command) { + case 'view': + return { + card: 'generic', + title: `view ${args.path}`, + kind: 'read', + locations: [{ path: args.path }], + } + case 'create': + return { + card: 'diff', + title: `create ${args.path}`, + diffs: [{ path: args.path, oldText: null, newText: args.file_text ?? '' }], + locations: [{ path: args.path }], + } + case 'str_replace': + return { + card: 'diff', + title: `str_replace ${args.path}`, + diffs: [{ + path: args.path, + oldText: args.old_str ?? null, + newText: args.new_str ?? '', + }], + locations: [{ path: args.path }], + } + case 'insert': + return { + card: 'generic', + title: `insert ${args.path}`, + kind: 'edit', + locations: [{ + path: args.path, + ...args.insert_line === undefined ? {} : { line: Math.max(1, args.insert_line + 1) }, + }], + } + } } /** Register the model-facing `str_replace_editor` tool. */ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { + const policy = new MutationPolicy(ctx) ctx.tools.register(defineTool({ name: 'str_replace_editor', description: config.description, @@ -351,18 +503,32 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { case 'view': return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) case 'create': - return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec) + return createFile(ctx, policy, args.path, args.file_text, config.requireAbsolutePath, exec) case 'str_replace': - return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec) + return replaceInFile( + ctx, + policy, + args.path, + args.old_str, + args.new_str, + config.requireAbsolutePath, + config.expandTabsOnMutation, + exec, + ) case 'insert': - return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec) + return insertInFile( + ctx, + policy, + args.path, + args.insert_line, + args.new_str, + config.requireAbsolutePath, + config.expandTabsOnMutation, + exec, + ) } }, - presentCall: args => ({ - card: 'generic', - title: `${args.command} ${args.path}`, - kind: args.command === 'view' ? 'read' : 'edit', - }), + presentCall: presentEditorCall, })) } @@ -377,6 +543,8 @@ export interface Config { description?: string /** Require local absolute paths like the canonical editor contract (default true). */ requireAbsolutePath?: boolean + /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ + expandTabsOnMutation?: boolean } /** Runtime configuration schema for the string-replacement editor tool. */ @@ -384,6 +552,7 @@ export const Config: z = z.object({ maxOutputChars: z.number().default(16_000), description: z.string().default(DEFAULT_DESCRIPTION), requireAbsolutePath: z.boolean().default(true), + expandTabsOnMutation: z.boolean().default(true), }) /** Register one `str_replace_editor` tool over `ctx.fs`. */ @@ -392,6 +561,7 @@ export function apply(ctx: Context, config: Config): void { maxOutputChars: config.maxOutputChars ?? 16_000, description: config.description ?? DEFAULT_DESCRIPTION, requireAbsolutePath: config.requireAbsolutePath ?? true, + expandTabsOnMutation: config.expandTabsOnMutation ?? true, } if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 7cf9ba5212..213161e147 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -9,6 +9,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' +import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' @@ -57,7 +60,10 @@ function call(ctx: Context, owner: Agent | undefined, args: unknown) { }) } -async function setup(config: ToolStrReplaceEditor.Config = {}) { +async function setup( + config: ToolStrReplaceEditor.Config = {}, + options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {}, +) { const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-')) roots.push(root) const ctx = new Context() @@ -65,7 +71,13 @@ async function setup(config: ToolStrReplaceEditor.Config = {}) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalFileSystem, { cwd: root }) + if (options.sandboxMode === undefined) { + await ctx.plugin(LocalFileSystem, { cwd: root }) + } else { + await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root }) + await ctx.plugin(SandboxedFileSystem, { cwd: root }) + } + if (options.fsPolicy === true) await ctx.plugin(FsPolicy) await ctx.plugin(ToolStrReplaceEditor, config) return { ctx, root, owner: agent(ctx, root) } } @@ -85,13 +97,38 @@ describe('tool-str-replace-editor', () => { expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'view', path: '/workspace/a.txt', - })).toMatchObject({ card: 'generic', kind: 'read' }) + })).toMatchObject({ + card: 'generic', + kind: 'read', + locations: [{ path: '/workspace/a.txt' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'create', + path: '/workspace/a.txt', + file_text: 'hello', + })).toMatchObject({ + card: 'diff', + diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'str_replace', + path: '/workspace/a.txt', + old_str: 'old', + new_str: 'new', + })).toMatchObject({ + card: 'diff', + diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }], + }) expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'insert', path: '/workspace/a.txt', insert_line: 0, new_str: 'x', - })).toMatchObject({ card: 'generic', kind: 'edit' }) + })).toMatchObject({ + card: 'generic', + kind: 'edit', + locations: [{ path: '/workspace/a.txt', line: 1 }], + }) }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { @@ -136,16 +173,20 @@ describe('tool-str-replace-editor', () => { }) it('lists visible entries to depth two and clips at the configured view limit', async () => { - const { ctx, root, owner } = await setup({ maxOutputChars: 10 }) + const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 }) await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true }) + await mkdir(join(root, 'dir', 'node_modules_old'), { recursive: true }) await mkdir(join(root, 'dir', '__pycache__'), { recursive: true }) + await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true }) await writeFile(join(root, 'dir', 'visible.txt'), 'ok') await writeFile(join(root, 'dir', '.hidden'), 'hidden') await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child') await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep') await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency') + await writeFile(join(root, 'dir', 'node_modules_old', 'kept.js'), 'visible source') await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache') + await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source') const listDir = ctx.fs.listDir.bind(ctx.fs) const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other')) ctx.fs.listDir = async (target, signal) => { @@ -156,14 +197,19 @@ describe('tool-str-replace-editor', () => { } const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') })) - expect(listing).toContain('') expect(listing).not.toContain('.hidden') expect(listing).not.toContain('too-deep.txt') expect(listing).not.toContain('index.js') expect(listing).not.toContain('module.pyc') + expect(listing).toContain('node_modules_old/kept.js') + expect(listing).toContain('__pycache__backup/kept.py') - await writeFile(join(root, 'large.txt'), 'x'.repeat(100)) - expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') }))) + const clipped = await setup({ maxOutputChars: 10 }) + await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100)) + expect(text(await call(clipped.ctx, clipped.owner, { + command: 'view', + path: join(clipped.root, 'large.txt'), + }))) .toContain('') }) @@ -233,10 +279,20 @@ describe('tool-str-replace-editor', () => { expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]') expect(text(repeated)).not.toContain('replace_all') + await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta') + const repeatedMultiline = await call(ctx, owner, { + command: 'str_replace', + path: ambiguous, + old_str: 'alpha\nbeta', + new_str: 'x', + }) + expect(text(repeatedMultiline)) + .toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]') + const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) expect(relative.isError).toBe(true) expect(text(relative)).toContain('is not an absolute path') - expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame') + expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta') }) it('reports invalid commands or arguments without mutating files', async () => { @@ -302,6 +358,63 @@ describe('tool-str-replace-editor', () => { .toContain("Here's the content of") }) + it('delegates read-before-edit decisions to fs-policy', async () => { + const { ctx, root, owner } = await setup({}, { fsPolicy: true }) + const existing = join(root, 'existing.txt') + const created = join(root, 'created.txt') + await writeFile(existing, 'before') + + const blindEdit = await call(ctx, owner, { + command: 'str_replace', + path: existing, + old_str: 'before', + new_str: 'after', + }) + expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + expect(await readFile(existing, 'utf8')).toBe('before') + + await call(ctx, owner, { command: 'view', path: existing }) + expect((await call(ctx, owner, { + command: 'str_replace', + path: existing, + old_str: 'before', + new_str: 'after', + })).isError).toBe(false) + expect(await readFile(existing, 'utf8')).toBe('after') + + expect((await call(ctx, owner, { + command: 'create', + path: created, + file_text: 'new', + })).isError).toBe(false) + expect(await readFile(created, 'utf8')).toBe('new') + }) + + it('passes the session sandbox policy to every mutation', async () => { + const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' }) + const path = join(root, 'blocked.txt') + const result = await call(ctx, owner, { + command: 'create', + path, + file_text: 'blocked', + }) + expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) + expect(text(result)).toContain('[sandbox: file access denied under read-only mode]') + }) + + it('can preserve tabs outside the edited region', async () => { + const { ctx, root, owner } = await setup({ expandTabsOnMutation: false }) + const path = join(root, 'Makefile') + await writeFile(path, 'target:\n\told\n') + await call(ctx, owner, { + command: 'str_replace', + path, + old_str: 'old', + new_str: 'new', + }) + expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n') + }) + it('rejects invalid plugin config', () => { expect(() => { ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 }) diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json index 2c6eb3688c..6ee0dc19eb 100644 --- a/packages/fs/tool-str-replace-editor/tsconfig.json +++ b/packages/fs/tool-str-replace-editor/tsconfig.json @@ -9,6 +9,8 @@ { "path": "../../../vendor/cordis" }, { "path": "../../core/tools" }, { "path": "../fs" }, + { "path": "../../sandbox/sandbox" }, + { "path": "../../sandbox/sandbox-policy" }, { "path": "../../support/invariants" } ] } diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index b812fe8992..8ba51388cf 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -16,7 +16,10 @@ const LOST_PREFIX_MESSAGE = 'The beginning of this comma const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' +// One page is enough to find a just-emitted completion marker; the full +// scrollback is assembled only when a command settles or needs partial output. const SCROLLBACK_PAGE_LINES = 1_000 +const POLL_INTERVAL_MS = 25 const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.' @@ -101,7 +104,7 @@ function commandOutput( const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), - incomplete: startMarker < 0 || snapshot.truncated, + incomplete: startMarker < 0, } } @@ -115,22 +118,29 @@ function partialOutput( snapshot: RetainedOutput, marker: CommandMarkers, fallback: string, + fallbackTruncated = false, ): CapturedOutput { const startMarker = snapshot.text.lastIndexOf(marker.start) if (startMarker >= 0) { return { text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), - incomplete: snapshot.truncated, + incomplete: false, } } + const fallbackStart = fallback.lastIndexOf(marker.start) + const afterStart = fallbackStart < 0 + ? fallback + : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '') + const fallbackEnd = afterStart.lastIndexOf(marker.end) + const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd) return { - text: stripPrompt(fallback), - incomplete: snapshot.truncated, + text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')), + incomplete: fallbackTruncated || fallbackStart < 0, } } async function pause(): Promise { - await new Promise(resolve => setTimeout(resolve, 25)) + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) } function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { @@ -142,11 +152,13 @@ function retainedScrollback( ctx: Context, owner: Agent, id: PtySessionId, + latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), ): RetainedOutput { - const pages: string[] = [] - let offset = 0 - let truncated = false + const pages: string[] = latest.text.length === 0 ? [] : [latest.text] + let offset = latest.lineEnd + let truncated = latest.truncated while (true) { + if (offset >= latest.totalLines) break const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) truncated ||= page.truncated if (page.text.length > 0) pages.unshift(page.text) @@ -167,7 +179,10 @@ function renderCaptured(output: CapturedOutput, maxOutputChars: number): string function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { const pending = new WeakMap>() const live = new Map() + const creating = new Set>() const ownerCleanupInstalled = new WeakSet() + const lifecycle = new AbortController() + let disposed = false const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return @@ -175,6 +190,9 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } ctx.effect(() => async () => { + disposed = true + lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation')) + await Promise.allSettled([...creating]) const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) await Promise.all(closing) live.clear() @@ -188,15 +206,17 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } const get = (owner: Agent, signal: AbortSignal): Promise => { + if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed')) const existing = pending.get(owner) if (existing !== undefined) return existing - const creating = (async () => { + const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) + const creation = (async () => { try { const cwd = owner.session.header.cwd const spawned = await ctx.pty.spawn(owner, { type: config.backendType, ...cwd === undefined ? {} : { cwd }, - }, signal) + }, combinedSignal) live.set(owner, spawned.sessionId) if (!ownerCleanupInstalled.has(owner)) { ownerCleanupInstalled.add(owner) @@ -208,7 +228,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell const setup = ctx.pty.startSend(owner, spawned.sessionId, { text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`, submit: true, - signal, + signal: combinedSignal, }) const result = await setup.done if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { @@ -220,8 +240,12 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell throw error } })() - pending.set(owner, creating) - return creating + const tracked = creation.finally(() => { + creating.delete(tracked) + }) + creating.add(tracked) + pending.set(owner, tracked) + return tracked } return { get, reset } @@ -241,21 +265,32 @@ async function executeCommand( const wrapped = wrapCommand(command, marker) let first = true let fallback = '' + let fallbackTruncated = false while (true) { - const operation = ctx.pty.startSend(owner, id, { - text: first ? wrapped : '', - submit: first, - signal: commandDeadline.signal, - }) - first = false - const result = await operation.done - fallback += result.viewport - const snapshot = retainedScrollback(ctx, owner, id) + let operation + let result + try { + operation = ctx.pty.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + result = await operation.done + } catch (error: unknown) { + await shells.reset(owner, 'persistent bash send failed') + throw error + } + const incremental = operation.readOutput() + fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport + fallbackTruncated ||= incremental.truncated || result.truncated + const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) if (timedOut !== undefined) { + const snapshot = retainedScrollback(ctx, owner, id, latest) const partial = renderCaptured( - partialOutput(snapshot, marker, fallback), + partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars, ) await shells.reset(owner, 'persistent bash command timed out') @@ -265,12 +300,15 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].join('\n') } - const complete = commandOutput(snapshot, marker) - if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + if (latest.text.includes(marker.end)) { + const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + } if (result.sessionStatus.kind === 'exited') { + const snapshot = retainedScrollback(ctx, owner, id, latest) await shells.reset(owner, 'persistent bash shell exited') return [ - renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars), + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } @@ -279,7 +317,11 @@ async function executeCommand( commandDeadline.signal.throwIfAborted() } if (promptCompleted(result)) { - return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated) + const snapshot = retainedScrollback(ctx, owner, id, latest) + return renderCaptured( + partialOutput(snapshot, marker, fallback, fallbackTruncated), + config.maxOutputChars, + ) } await pause() } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index f757d6d1f8..3863ec29ce 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -85,6 +85,8 @@ type StubMode = | 'init-exit' | 'init-timeout' | 'spawn-error' + | 'send-error' + | 'prompt-after-idle' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -95,6 +97,7 @@ class StubPtySession implements PtyBackendSession { mode: StubMode sends = 0 pendingText = '' + historyTruncated = false constructor(mode: StubMode) { this.mode = mode @@ -112,6 +115,7 @@ class StubPtySession implements PtyBackendSession { } return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } + if (this.mode === 'send-error') throw new Error('stub send failed') if (this.mode === 'wait-for-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -126,6 +130,17 @@ class StubPtySession implements PtyBackendSession { this.pendingText = request.text return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) } + if (this.mode === 'prompt-after-idle') { + if (request.text.length > 0) { + const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0] + const output = `${start ?? ''}\npartial syntax output\n` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + const output = `bash: syntax error\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' const output = `bash: syntax error${newline}${this.motd}${newline}` @@ -166,7 +181,7 @@ class StubPtySession implements PtyBackendSession { totalLines: lines.length, lineBegin: 0, lineEnd: lines.length, - truncated: false, + truncated: this.historyTruncated, } } @@ -316,6 +331,29 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') }) + it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'prompt-after-idle' + session.scrollback = '' + const result = text(await call(ctx, owner, 'bad {')) + expect(result).toContain('partial syntax output') + expect(result).toContain('bash: syntax error') + expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + expect(result).not.toContain('DSH_PERSISTENT_BASH_START') + }) + + it('does not attribute old scrollback truncation to a complete current command', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.historyTruncated = true + const result = text(await call(ctx, owner, 'short command')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('') + expect(result).not.toContain('beginning of this command output was dropped') + }) + it('closes a timed-out shell and reports bounded partial output', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) await call(ctx, owner, 'warm up') @@ -359,6 +397,48 @@ describe('tool-bash-persistent', () => { expect(stub.sessions).toHaveLength(0) }) + it('resets a cached shell after startSend fails', async () => { + const { ctx, owner, stub } = await setup() + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'send-error' + expect((await call(ctx, owner, 'fails')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent bash send failed') + expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const spawnStarted = Promise.withResolvers() + const spawnAborted = Promise.withResolvers() + ctx.pty.registerBackend({ + type: 'slow', + spawn: spec => new Promise((_resolve, reject) => { + spawnStarted.resolve(undefined) + spec.signal?.addEventListener('abort', () => { + spawnAborted.resolve(undefined) + const reason: unknown = spec.signal?.reason + reject(reason instanceof Error + ? reason + : new Error('slow PTY spawn aborted', { cause: reason })) + }, { once: true }) + }), + }) + const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' }) + const owner = agent(ctx, '/workspace') + const running = call(ctx, owner, 'pwd') + await spawnStarted.promise + await fiber.dispose() + await spawnAborted.promise + expect((await running).isError).toBe(true) + expect(ctx.pty.list(owner)).toEqual([]) + }) + it('rejects invalid config and invalid calls', async () => { const { ctx, owner, stub } = await setup() expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c57d106a6..73092a292c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -593,6 +593,9 @@ importers: '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:* version: link:../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:* + version: link:../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/cordis/tool-cordis @@ -617,6 +620,9 @@ importers: '@deepseek-ai/dsh-tool-session-query': specifier: workspace:* version: link:../packages/session-query/tool-session-query + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:* + version: link:../packages/fs/tool-str-replace-editor '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent @@ -2593,12 +2599,24 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../fs-sandbox '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 44fcf94b60..4b104ec211 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 python/sdk-runtime/README.md -README.md: 977bce41191d6c7716548dafde81d2c7ec14dec7 -README.zh.md: ade8455c56c27fcbe3e43a68abeaad98421cf720 +README.md: ee3791eddf26b526316d4f3952793a03cc48841e +README.zh.md: 59d40ee56688cb377902ff126b7fa77606c7ad8b diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 977bce4119..ee3791eddf 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 A missing exe raises `FileNotFoundError` naming both 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. A missing dev-only node carrier names its sole route, the build script. 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 runtime executable and its matching native spawn helper. 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 runtime files, non-executable files, 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. +Each wheel contains exactly one runtime executable and its matching native spawn helper. A missing sidecar makes the runtime installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools; old exe-only wheels are intentionally unsupported. 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 runtime files, non-executable files, 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 ade8455c56..59d40ee566 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -15,7 +15,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。固定标签为 `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` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。缺少伴随文件意味着运行时安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此;旧的仅 exe 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 diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 03c82b25b0..38400009ac 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -260,7 +260,9 @@ class SingleExeBuild { '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', - // The production closure intentionally omits patched dev-only packages. + // The production closure intentionally omits the patched dev-only + // @earendil-works/pi-tui package. The root frozen install still validates + // every patch; this exception is scoped only to the production deploy. '--config.allow-unused-patches=true', this.staging, ]) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 11500b5ec7..346818e5fa 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -27,7 +27,7 @@ WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value witho WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" -PERSISTENT_EDITOR_PATH: str | None = None +PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: " PERSISTENT_BASH_COMMAND = ( "counter=$(( ${counter:-0} + 1 )); export counter; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " @@ -198,7 +198,7 @@ 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 == PERSISTENT_TOOLS_PROMPT: + if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"): names = advertised_tool_names(body) if names != {"bash", "str_replace_editor"}: raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") @@ -261,14 +261,27 @@ def persistent_tool_followup( if call_id == "persistent-bash-2" and tool_name == "bash": if "COUNT=2 CWD=/tmp" not in tool_text: raise AssertionError(f"persistent bash did not retain state: {tool_text}") - if PERSISTENT_EDITOR_PATH is None: - raise AssertionError("persistent editor smoke path was not initialized") + messages = body.get("messages") + if not isinstance(messages, list): + raise AssertionError("persistent editor smoke request has no messages") + editor_path = next( + ( + text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip() + for message in messages + if isinstance(message, dict) and message.get("role") == "user" + for text in [message_text(message.get("content"))] + if PERSISTENT_EDITOR_PATH_PREFIX in text + ), + None, + ) + if editor_path is None: + raise AssertionError("persistent editor smoke prompt has no editor path") return tool_call_chunks( "persistent-editor", "str_replace_editor", { "command": "create", - "path": PERSISTENT_EDITOR_PATH, + "path": editor_path, "file_text": "created by packaged editor\n", }, ) @@ -545,12 +558,12 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: """Exercise native PTY state and the editor through the packaged executable.""" - global PERSISTENT_EDITOR_PATH from deepseek_harness import DeepSeekHarness with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: root = Path(temporary).resolve() - PERSISTENT_EDITOR_PATH = str(root / "created.txt") + editor_path = root / "created.txt" + prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}" sessions = root / "sessions" cordis = root / "cordis.yml" cordis.write_text(PERSISTENT_TOOLS_CORDIS) @@ -565,17 +578,15 @@ def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: base_url=base_url, request_timeout_seconds=60, ) as harness: - result = harness.run(PERSISTENT_TOOLS_PROMPT, session_id="persistent-tools-smoke") + result = harness.run(prompt, session_id="persistent-tools-smoke") assert result.status == "ok", result event_text = json.dumps(result.events) if PERSISTENT_TOOLS_TEXT not in event_text: raise AssertionError(f"packaged tools run emitted no final response: {result.events}") - created = root / "created.txt" - if created.read_text() != "created by packaged editor\n": - raise AssertionError(f"packaged editor wrote unexpected content: {created.read_text()!r}") + if editor_path.read_text() != "created by packaged editor\n": + raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") - PERSISTENT_EDITOR_PATH = None def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: From df58af92cda9b7d7a61e648ec212c07e31707c1d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 15:33:46 +0800 Subject: [PATCH 05/41] test(tools): close persistent tool coverage gaps --- docs/config-catalog.md | 2 +- .../fs/tool-str-replace-editor/src/index.ts | 2 +- .../tests/tools.spec.ts | 101 +++++++++++++++++- .../pty/tool-bash-persistent/src/index.ts | 15 +-- .../tool-bash-persistent/tests/tools.spec.ts | 13 ++- 5 files changed, 113 insertions(+), 20 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0e92e4e06e..2e6ffaf3d8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:373`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 5d9855c1da..7ff0773107 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -281,7 +281,7 @@ async function createFile( outcome = await ctx.fs.writeText( target, content, - intent ?? { kind: 'createIfAbsent' }, + intent, exec.signal, sandboxPolicy, ) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 213161e147..fb1e0d4b58 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -129,6 +129,24 @@ describe('tool-str-replace-editor', () => { kind: 'edit', locations: [{ path: '/workspace/a.txt', line: 1 }], }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'create', + path: '/workspace/empty.txt', + })).toMatchObject({ + diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'str_replace', + path: '/workspace/a.txt', + })).toMatchObject({ + diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'insert', + path: '/workspace/a.txt', + })).toMatchObject({ + locations: [{ path: '/workspace/a.txt' }], + }) }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { @@ -192,7 +210,11 @@ describe('tool-str-replace-editor', () => { ctx.fs.listDir = async (target, signal) => { const entries = await listDir(target, signal) return target.displayPath === join(root, 'dir') - ? [...entries, { name: 'other', type: 'other', target: otherTarget }] + ? [ + { name: 'same-target', type: 'other', target: otherTarget }, + { name: 'other', type: 'other', target: otherTarget }, + ...entries.toReversed(), + ] : entries } @@ -235,6 +257,11 @@ describe('tool-str-replace-editor', () => { command: 'view', path: plain, }))).toContain(' 1 one') + expect((await call(ctx, undefined, { + command: 'create', + path: join(root, 'ownerless.txt'), + file_text: 'ownerless', + })).isError).toBe(false) await call(ctx, owner, { command: 'insert', @@ -382,6 +409,14 @@ describe('tool-str-replace-editor', () => { })).isError).toBe(false) expect(await readFile(existing, 'utf8')).toBe('after') + expect((await call(ctx, owner, { + command: 'insert', + path: existing, + insert_line: 1, + new_str: 'tail', + })).isError).toBe(false) + expect(await readFile(existing, 'utf8')).toBe('after\ntail') + expect((await call(ctx, owner, { command: 'create', path: created, @@ -400,19 +435,79 @@ describe('tool-str-replace-editor', () => { }) expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) expect(text(result)).toContain('[sandbox: file access denied under read-only mode]') + + const ownerless = await call(ctx, undefined, { + command: 'create', + path: join(root, 'ownerless-blocked.txt'), + file_text: 'blocked', + }) + expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) }) it('can preserve tabs outside the edited region', async () => { const { ctx, root, owner } = await setup({ expandTabsOnMutation: false }) const path = join(root, 'Makefile') - await writeFile(path, 'target:\n\told\n') + await writeFile(path, 'target:\n\told\nremove\n') await call(ctx, owner, { command: 'str_replace', path, old_str: 'old', new_str: 'new', }) - expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n') + await call(ctx, owner, { + command: 'str_replace', + path, + old_str: 'remove\n', + }) + await call(ctx, owner, { + command: 'insert', + path, + insert_line: 1, + new_str: '\tkept', + }) + expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n') + }) + + it('reports missing sandbox-policy composition during plugin startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-')) + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: root }) + Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' }) + + await expect(ctx.plugin(ToolStrReplaceEditor)) + .rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing') + }) + + it('maps unexpected backend write failures for replace and insert', async () => { + const { ctx, root, owner } = await setup() + const path = join(root, 'backend-error.txt') + await writeFile(path, 'old\n') + ctx.fs.writeText = async () => { + throw new Error('backend write failed') + } + + const replace = await call(ctx, owner, { + command: 'str_replace', + path, + old_str: 'old', + new_str: 'new', + }) + expect(replace.isError).toBe(true) + expect(text(replace)).toContain('backend write failed') + + const insert = await call(ctx, owner, { + command: 'insert', + path, + insert_line: 1, + new_str: 'new', + }) + expect(insert.isError).toBe(true) + expect(text(insert)).toContain('backend write failed') }) it('rejects invalid plugin config', () => { diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 8ba51388cf..136dcbaabd 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -81,12 +81,7 @@ function wrapCommand(command: string, marker: CommandMarkers): string { } function stripPrompt(text: string): string { - let result = text - while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) { - result = result.slice(0, result.endsWith('\r\n') - ? -SHELL_PROMPT.length - 2 - : -SHELL_PROMPT.length - 1) - } + let result = text.replace(/\r?\n$/, '') while (result.endsWith(SHELL_PROMPT)) { result = result.slice(0, -SHELL_PROMPT.length) } @@ -96,10 +91,9 @@ function stripPrompt(text: string): string { function commandOutput( snapshot: RetainedOutput, marker: CommandMarkers, -): CapturedOutput | undefined { +): CapturedOutput { const text = snapshot.text const end = text.lastIndexOf(marker.end) - if (end < 0) return undefined const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { @@ -182,7 +176,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell const creating = new Set>() const ownerCleanupInstalled = new WeakSet() const lifecycle = new AbortController() - let disposed = false const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return @@ -190,7 +183,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } ctx.effect(() => async () => { - disposed = true lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation')) await Promise.allSettled([...creating]) const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) @@ -206,7 +198,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } const get = (owner: Agent, signal: AbortSignal): Promise => { - if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed')) const existing = pending.get(owner) if (existing !== undefined) return existing const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) @@ -302,7 +293,7 @@ async function executeCommand( } if (latest.text.includes(marker.end)) { const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) - if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 3863ec29ce..811d733f2f 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -87,6 +87,7 @@ type StubMode = | 'spawn-error' | 'send-error' | 'prompt-after-idle' + | 'empty-page-after-latest' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -168,19 +169,22 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - read(_request: PtyReadRequest) { + read(request: PtyReadRequest) { if (this.mode === 'empty-read') { return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } } if (this.mode === 'stalled-read') { return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } } + if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) { + return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false } + } const lines = this.scrollback.split('\n') return { text: this.scrollback, - totalLines: lines.length, + totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length, lineBegin: 0, - lineEnd: lines.length, + lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length, truncated: this.historyTruncated, } } @@ -329,6 +333,9 @@ describe('tool-bash-persistent', () => { session.mode = 'stalled-read' expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + + session.mode = 'empty-page-after-latest' + expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub') }) it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { From b9bc1c8a612f4235cabcd7c1259cd1ecf24bfe0b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 16:11:30 +0800 Subject: [PATCH 06/41] fix(python-sdk): read nested assistant messages --- python/sdk/src/deepseek_harness/api.py | 4 +++- python/sdk/tests/test_client.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 5986dc2cdc..29bb4223ac 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -191,7 +191,9 @@ def final_response(events: list[JsonObject]) -> str: data = event.get("data") if not isinstance(data, dict): continue - content = data.get("content") + message = data.get("message") + content_owner = message if isinstance(message, dict) else data + content = content_owner.get("content") if not isinstance(content, list): continue parts: list[str] = [] diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index de2927c598..52ceac9f4d 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -46,7 +46,12 @@ for line in sys.stdin: "sessionId": params["sessionId"], "event": { "type": "assistant/message", - "data": {"content": [{"type": "text", "text": "hello from runtime"}]}, + "data": { + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "hello from runtime"}], + }, + }, }, }, }), flush=True) From f8df313f218734a105fa547ef8df367047e299ca Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:24:33 +0800 Subject: [PATCH 07/41] fix(pty): preserve persistent bash exit status --- .../pty/tool-bash-persistent/src/index.ts | 26 ++++++++++++++++-- .../tool-bash-persistent/tests/tools.spec.ts | 27 +++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 136dcbaabd..6cadf75708 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -43,6 +43,7 @@ interface RetainedOutput { interface CapturedOutput { text: string incomplete: boolean + exitCode?: number } interface PersistentShells { @@ -94,11 +95,13 @@ function commandOutput( ): CapturedOutput { const text = snapshot.text const end = text.lastIndexOf(marker.end) + const exitCode = Number.parseInt(text.slice(end + marker.end.length), 10) const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), incomplete: startMarker < 0, + exitCode, } } @@ -165,9 +168,24 @@ function retainedScrollback( function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) - return output.incomplete && output.text.length > 0 + const withPrefix = output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered + return renderExitStatus(withPrefix, output.exitCode ?? 0, null) +} + +function renderExitStatus( + content: string, + exitCode: number | null, + signal: NodeJS.Signals | null, +): string { + const marker = signal !== null + ? `[killed by signal: ${signal}]` + : exitCode !== null && exitCode !== 0 + ? `[exit code: ${exitCode}]` + : undefined + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` } function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { @@ -299,7 +317,11 @@ async function executeCommand( const snapshot = retainedScrollback(ctx, owner, id, latest) await shells.reset(owner, 'persistent bash shell exited') return [ - renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + renderExitStatus( + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + result.sessionStatus.exitCode, + result.sessionStatus.signal, + ), SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 811d733f2f..990004c1c9 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -78,9 +78,11 @@ type StubMode = | 'empty-read' | 'stalled-read' | 'exit' + | 'signal-exit' | 'wait-for-abort' | 'idle-then-normal' | 'large' + | 'nonzero' | 'end-only' | 'init-exit' | 'init-timeout' @@ -157,13 +159,18 @@ class StubPtySession implements PtyBackendSession { this.scrollback += output return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub' - const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}` + const commandOutput = this.mode === 'large' + ? 'x'.repeat(100) + : this.mode === 'nonzero' ? '' : 'hello from stub' + const exitCode = this.mode === 'nonzero' ? 7 : 0 + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` this.scrollback += output - if (this.mode === 'exit') { + if (this.mode === 'exit' || this.mode === 'signal-exit') { const exitedOutput = `${start ?? ''}\nhello from stub\n` this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput - this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + this.statusValue = this.mode === 'signal-exit' + ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } + : { kind: 'exited', exitCode: 9, signal: null } return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) } return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) @@ -303,19 +310,29 @@ describe('tool-bash-persistent', () => { session.mode = 'large' expect(text(await call(ctx, owner, 'large'))).toContain('') + session.mode = 'nonzero' + expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]') + session.mode = 'exit' const exited = text(await call(ctx, owner, 'exit')) expect(exited).toContain('hello from') + expect(exited).toContain('[exit code: 9]') expect(exited).toContain('next bash call starts from the workspace') expect(session.closed).toContain('persistent bash shell exited') await call(ctx, owner, 'new shell') expect(stub.sessions).toHaveLength(2) + const replacement = stub.sessions[1]! + replacement.mode = 'signal-exit' + expect(text(await call(ctx, owner, 'kill shell'))).toContain('[killed by signal: SIGTERM]') + + await call(ctx, owner, 'another shell') + expect(stub.sessions).toHaveLength(3) const externallyClosed = ctx.pty.list(owner)[0]?.sessionId expect(externallyClosed).toBeDefined() await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') await fiber.dispose() - expect(stub.sessions[1]?.closed).toEqual(['external cleanup']) + expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { From 6b26126b3a743e976d3326300021dc6c205fdfdf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:25:56 +0800 Subject: [PATCH 08/41] fix(fs): preserve tabs during editor mutations --- .../fs/tool-str-replace-editor/src/index.ts | 47 +++++-------------- .../tests/tools.spec.ts | 8 ++-- 2 files changed, 17 insertions(+), 38 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 7ff0773107..98bfd6528d 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -299,21 +299,18 @@ async function replaceInFile( oldStr: string | undefined, newStr: string | undefined, requireAbsolutePath: boolean, - expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) - const rawOldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) - const oldValue = expandTabsOnMutation ? expandTabs(rawOldValue) : rawOldValue - const newValue = expandTabsOnMutation ? expandTabs(newStr ?? '') : newStr ?? '' + const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) + const newValue = newStr ?? '' const info = await statExisting(ctx, target, 'str_replace', exec) if (info.type !== 'file') { throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const rawBefore = await ctx.fs.readText(target, exec.signal) - const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore + const before = await ctx.fs.readText(target, exec.signal) const offsets = matchOffsets(before, oldValue) if (offsets.length === 0) { throw new FsError( @@ -330,23 +327,13 @@ async function replaceInFile( } let outcome try { - outcome = expandTabsOnMutation - ? await ctx.fs.writeText( - target, - before.replace(oldValue, newValue), - intent === undefined - ? { kind: 'replaceIfVersion', version: info.version } - : { kind: 'replaceIfVersion', version: intent.version }, - exec.signal, - sandboxPolicy, - ) - : await ctx.fs.editText( - target, - { oldString: oldValue, newString: newValue, replaceAll: false }, - intent ?? { version: info.version }, - exec.signal, - sandboxPolicy, - ) + outcome = await ctx.fs.editText( + target, + { oldString: oldValue, newString: newValue, replaceAll: false }, + intent ?? { version: info.version }, + exec.signal, + sandboxPolicy, + ) } catch (error: unknown) { throw policy.mapError(error, sandboxPolicy) } @@ -361,12 +348,10 @@ async function insertInFile( insertLine: number | undefined, newStr: string | undefined, requireAbsolutePath: boolean, - expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') - const rawValue = requiredForCommand(newStr, 'new_str', 'insert') - const value = expandTabsOnMutation ? expandTabs(rawValue) : rawValue + const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) @@ -374,8 +359,7 @@ async function insertInFile( if (info.type !== 'file') { throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const rawBefore = await ctx.fs.readText(target, exec.signal) - const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore + const before = await ctx.fs.readText(target, exec.signal) const lines = before.split('\n') if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { throw new Error( @@ -404,7 +388,6 @@ interface ResolvedConfig { maxOutputChars: number description: string requireAbsolutePath: boolean - expandTabsOnMutation: boolean } function presentEditorCall(args: { @@ -512,7 +495,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.old_str, args.new_str, config.requireAbsolutePath, - config.expandTabsOnMutation, exec, ) case 'insert': @@ -523,7 +505,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.insert_line, args.new_str, config.requireAbsolutePath, - config.expandTabsOnMutation, exec, ) } @@ -543,8 +524,6 @@ export interface Config { description?: string /** Require local absolute paths like the canonical editor contract (default true). */ requireAbsolutePath?: boolean - /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ - expandTabsOnMutation?: boolean } /** Runtime configuration schema for the string-replacement editor tool. */ @@ -552,7 +531,6 @@ export const Config: z = z.object({ maxOutputChars: z.number().default(16_000), description: z.string().default(DEFAULT_DESCRIPTION), requireAbsolutePath: z.boolean().default(true), - expandTabsOnMutation: z.boolean().default(true), }) /** Register one `str_replace_editor` tool over `ctx.fs`. */ @@ -561,7 +539,6 @@ export function apply(ctx: Context, config: Config): void { maxOutputChars: config.maxOutputChars ?? 16_000, description: config.description ?? DEFAULT_DESCRIPTION, requireAbsolutePath: config.requireAbsolutePath ?? true, - expandTabsOnMutation: config.expandTabsOnMutation ?? true, } if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index fb1e0d4b58..9518057634 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -444,8 +444,8 @@ describe('tool-str-replace-editor', () => { expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) }) - it('can preserve tabs outside the edited region', async () => { - const { ctx, root, owner } = await setup({ expandTabsOnMutation: false }) + it('preserves tabs outside the edited region', async () => { + const { ctx, root, owner } = await setup() const path = join(root, 'Makefile') await writeFile(path, 'target:\n\told\nremove\n') await call(ctx, owner, { @@ -487,9 +487,10 @@ describe('tool-str-replace-editor', () => { const { ctx, root, owner } = await setup() const path = join(root, 'backend-error.txt') await writeFile(path, 'old\n') - ctx.fs.writeText = async () => { + const failWrite = async (): Promise => { throw new Error('backend write failed') } + ctx.fs.editText = failWrite const replace = await call(ctx, owner, { command: 'str_replace', @@ -500,6 +501,7 @@ describe('tool-str-replace-editor', () => { expect(replace.isError).toBe(true) expect(text(replace)).toContain('backend write failed') + ctx.fs.writeText = failWrite const insert = await call(ctx, owner, { command: 'insert', path, From fbad903dd012c6e3dc5baf8d9cbf564aa1eb41a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:32:19 +0800 Subject: [PATCH 09/41] test(examples): move persistent tools into snapshot lane --- .../persistent-tools.snapshot.cordis.yml | 19 ++ .../tests/persistent-tools.snapshot.spec.ts | 214 ------------------ examples/jsonrpc-agent/tests/sdk.snapshot.ts | 38 +++- .../persistent-tools/behavior.expected.json | 57 ----- .../notifications.expected.jsonl | 54 +++++ .../persistent-tools/result.expected.json | 1 + .../snapshots/persistent-tools/session.jsonl | 54 +++++ 7 files changed, 162 insertions(+), 275 deletions(-) create mode 100644 examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml delete mode 100644 examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts delete mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl diff --git a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml b/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml new file mode 100644 index 0000000000..5bda5ac6a5 --- /dev/null +++ b/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml @@ -0,0 +1,19 @@ +# Keyless replay keeps the persistent-tool composition intact and replaces +# only its live DeepSeek adapter with the fixture-backed provider. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./persistent-tools.cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash diff --git a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts deleted file mode 100644 index 18d1454837..0000000000 --- a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { createServer } from 'node:http' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' -import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' - -const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) -const configPath = fileURLToPath(new URL('../persistent-tools.cordis.yml', import.meta.url)) -const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const expectedPath = fileURLToPath(new URL('./snapshots/persistent-tools/behavior.expected.json', import.meta.url)) - -interface ModelRequest { - messages?: Array> - tools?: Array<{ function?: { name?: string; parameters?: { required?: string[] } } }> -} - -function sseToolCall(id: string, name: string, args: Record): string[] { - return [ - 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', - `data: ${JSON.stringify({ - choices: [{ - delta: { - tool_calls: [{ - index: 0, - id, - type: 'function', - function: { name, arguments: JSON.stringify(args) }, - }], - }, - }], - })}\n\n`, - 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', - 'data: [DONE]\n\n', - ] -} - -function sseText(text: string): string[] { - return [ - 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', - `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`, - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', - 'data: [DONE]\n\n', - ] -} - -function messageText(content: unknown): string { - if (typeof content === 'string') return content - if (!Array.isArray(content)) return '' - return content.flatMap((block) => { - if (typeof block !== 'object' || block === null) return [] - const text = (block as { text?: unknown }).text - return typeof text === 'string' ? [text] : [] - }).join('') -} - -function latestToolCall(messages: Array>): { id: string; name: string } { - for (const message of messages.toReversed()) { - const calls = message.tool_calls - if (!Array.isArray(calls)) continue - const call = (calls as unknown[]).at(-1) - if (typeof call !== 'object' || call === null) continue - const id = (call as { id?: unknown }).id - const fn = (call as { function?: { name?: unknown } }).function - if (typeof id === 'string' && typeof fn?.name === 'string') return { id, name: fn.name } - } - throw new Error('model request has no preceding tool call') -} - -function normalize(value: string, cwd: string): string { - return value.replaceAll(cwd, '{{cwd}}') -} - -describe('jsonrpc persistent tools snapshot', () => { - it('runs persistent shell state and editor mutations keylessly', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-persistent-tools-')) - const sessionRoot = join(cwd, '.sessions') - const target = join(cwd, 'note.txt') - const requests: ModelRequest[] = [] - const modelServer = createServer((request, response) => { - let body = '' - request.setEncoding('utf8') - request.on('data', (chunk: string) => { body += chunk }) - request.on('end', () => { - const parsed = JSON.parse(body) as ModelRequest - requests.push(parsed) - const messages = parsed.messages ?? [] - const latest = messages.at(-1) - if (latest === undefined) throw new Error('model request has no messages') - let chunks: string[] - if (latest.role !== 'tool') { - chunks = sseToolCall('bash-1', 'bash', { - command: 'cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', - }) - } else { - const call = latestToolCall(messages) - const toolText = messageText(latest.content) - if (call.id === 'bash-1') { - expect(toolText).toContain('COUNT=1 CWD=/tmp') - chunks = sseToolCall('bash-2', 'bash', { - command: 'DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', - }) - } else if (call.id === 'bash-2') { - expect(toolText).toContain('COUNT=2 CWD=/tmp') - chunks = sseToolCall('editor-create', 'str_replace_editor', { - command: 'create', - path: target, - file_text: 'alpha\n', - }) - } else if (call.id === 'editor-create') { - expect(toolText).toContain('New file created successfully') - chunks = sseToolCall('editor-replace', 'str_replace_editor', { - command: 'str_replace', - path: target, - old_str: 'alpha', - new_str: 'beta', - }) - } else if (call.id === 'editor-replace') { - expect(toolText).toContain('has been edited successfully') - chunks = sseText('PERSISTENT_TOOLS_OK') - } else { - throw new Error(`unexpected tool call ${call.id}`) - } - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - for (const chunk of chunks) response.write(chunk) - response.end() - }) - }) - await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) - const address = modelServer.address() - if (address === null || typeof address === 'string') throw new Error('model server did not bind') - const launch = resolveExampleLaunch({ - srcBin: runtimeBin, - configArgs: [], - tsconfigPath: repoTsconfig, - }) - const harness = new DeepSeekHarness({ - launch: { - command: launch.command, - args: launch.args, - cwd: repoRoot, - env: { - ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, - ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, - DSH_CORDIS_CONFIG: configPath, - DSH_CWD: cwd, - DSH_SESSION_ROOT: sessionRoot, - DEEPSEEK_API_KEY: 'keyless-local-mock', - DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - }, - requestTimeoutMs: 60_000, - }, - cwd, - provider: 'deepseek', - model: 'deepseek-v4-flash', - }) - - try { - const result = await harness.run( - 'Prove that bash state persists, then create and edit note.txt.', - { sessionId: 'persistent-tools-snapshot' }, - ) - const calls = result.events.flatMap((event) => { - if (event.type !== 'tool/call') return [] - return [{ - name: event.data.name, - arguments: normalize(event.data.arguments, cwd), - }] - }) - const results = result.events.flatMap((event) => { - if (event.type !== 'tool/result') return [] - return event.data.message.content.flatMap((block) => { - if (block.type !== 'tool-result') return [] - return block.content.flatMap(content => - content.type === 'text' - ? [{ text: normalize(content.text, cwd) }] - : []) - }) - }) - const tools = (requests[0]?.tools ?? []).map(tool => ({ - name: tool.function?.name, - required: tool.function?.parameters?.required ?? [], - })).sort((left, right) => { - const leftName = String(left.name) - const rightName = String(right.name) - return leftName < rightName ? -1 : leftName > rightName ? 1 : 0 - }) - const behavior = { - tools, - calls, - results, - final: { - status: result.status, - reason: result.reason, - response: result.finalResponse, - file: await readFile(target, 'utf8'), - }, - } - if (process.env.DSH_SNAPSHOT === 'refresh') { - await writeFile(expectedPath, `${JSON.stringify(behavior, null, 2)}\n`) - } - expect(behavior).toEqual(JSON.parse(await readFile(expectedPath, 'utf8'))) - } finally { - await harness.close() - await new Promise(resolve => modelServer.close(() => { resolve() })) - await rm(cwd, { recursive: true, force: true }) - } - }, 75_000) -}) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index c54812e3e5..26a6461586 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { delimiter, isAbsolute, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { @@ -31,6 +31,8 @@ const testsDir = dirOf(import.meta.url) const snapshotsDir = join(testsDir, 'snapshots') const liveConfig = join(testsDir, '..', 'cordis.yml') const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml') +const persistentToolsLiveConfig = join(testsDir, '..', 'persistent-tools.cordis.yml') +const persistentToolsReplayConfig = join(testsDir, '..', 'persistent-tools.snapshot.cordis.yml') const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -51,6 +53,10 @@ interface SdkScenario { sessionId: string /** How many child sessions the turn persists (subagent scenarios). */ children: number + /** Optional scenario-specific live and replay compositions. */ + configs?: { live: string; replay: string } + /** Files whose final contents are part of the scenario contract. */ + expectedFiles?: Readonly> } const SCENARIOS: SdkScenario[] = [ @@ -72,6 +78,16 @@ const SCENARIOS: SdkScenario[] = [ sessionId: 'sdk-snapshot-subagent', children: 1, }, + { + name: 'persistent-tools', + prompt: 'Prove that bash state persists, then create and edit note.txt.', + sessionId: 'persistent-tools-snapshot', + children: 0, + configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, + // Replay returns recorded tool arguments verbatim, so this cross-platform + // POSIX fixture uses one stable absolute path and cleans it around the run. + expectedFiles: { '/tmp/dsh-persistent-tools-snapshot-note.txt': 'beta\n' }, + }, ] interface PersistedLog { @@ -147,11 +163,15 @@ async function runScenario(scenario: SdkScenario): Promise<{ result: TurnResult notifications: HarnessNotification[] logs: PersistedLog[] + observedFiles: Record cwd: string }> { const cwd = await mkdtemp(join(tmpdir(), `sdk-snapshot-${scenario.name}-`)) const sessionsRoot = join(cwd, '.sessions') const scenarioDir = join(snapshotsDir, scenario.name) + const expectedFilePaths = Object.keys(scenario.expectedFiles ?? {}).map(path => + isAbsolute(path) ? path : join(cwd, path)) + await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) const launch = resolveExampleLaunch({ srcBin: runtimeBin, configArgs: [], @@ -164,7 +184,9 @@ async function runScenario(scenario: SdkScenario): Promise<{ const env: Record = { ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, - DSH_CORDIS_CONFIG: recording ? liveConfig : replayConfig, + DSH_CORDIS_CONFIG: recording + ? scenario.configs?.live ?? liveConfig + : scenario.configs?.replay ?? replayConfig, DSH_SESSION_ROOT: sessionsRoot, DSH_CWD: cwd, DSH_SNAPSHOT: mode, @@ -195,9 +217,16 @@ async function runScenario(scenario: SdkScenario): Promise<{ }) await harness.close() const logs = await persistedLogs(sessionsRoot) - return { result, notifications, logs, cwd } + const observedFiles = Object.fromEntries(await Promise.all( + Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string]> => [ + path, + await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8'), + ]), + )) + return { result, notifications, logs, observedFiles, cwd } } finally { await harness.close() + await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) await rm(cwd, { recursive: true, force: true }) } } @@ -227,7 +256,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const notificationsExpectedPath = join(scenarioDir, 'notifications.expected.jsonl') const resultExpectedPath = join(scenarioDir, 'result.expected.json') - const { result, notifications, logs, cwd } = await runScenario(scenario) + const { result, notifications, logs, observedFiles, cwd } = await runScenario(scenario) const ordered = orderLogs(logs, scenario) const actualContext = contextOf(ordered, cwd) @@ -293,6 +322,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { // Wire-shape invariants that must hold in every mode. expect(result.status).toBe('ok') expect(notifications.at(-1)?.method).toBe('session.finished') + expect(observedFiles).toEqual(scenario.expectedFiles ?? {}) if (scenario.children > 0) { expect(notifications.some(n => n.method === 'subagent.started')).toBe(true) expect(notifications.some(n => n.method === 'subagent.finished')).toBe(true) diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json deleted file mode 100644 index 18b54e9312..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "tools": [ - { - "name": "bash", - "required": [ - "command" - ] - }, - { - "name": "str_replace_editor", - "required": [ - "command", - "path" - ] - } - ], - "calls": [ - { - "name": "bash", - "arguments": "{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" - }, - { - "name": "bash", - "arguments": "{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" - }, - { - "name": "str_replace_editor", - "arguments": "{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}" - }, - { - "name": "str_replace_editor", - "arguments": "{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}" - } - ], - "results": [ - { - "text": "COUNT=1 CWD=/tmp" - }, - { - "text": "COUNT=2 CWD=/tmp" - }, - { - "text": "New file created successfully at: {{cwd}}/note.txt" - }, - { - "text": "The file {{cwd}}/note.txt has been edited successfully." - } - ], - "final": { - "status": "ok", - "reason": { - "kind": "completed" - }, - "response": "PERSISTENT_TOOLS_OK", - "file": "beta\n" - } -} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl new file mode 100644 index 0000000000..481e0a3d08 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -0,0 +1,54 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-1","name":"bash","argumentsDelta":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bash-1"},"content":[{"type":"tool-result","toolCallId":"bash-1","content":[{"type":"text","text":"COUNT=1 CWD=/tmp"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","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","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-2","name":"bash","argumentsDelta":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bash-2"},"content":[{"type":"tool-result","toolCallId":"bash-2","content":[{"type":"text","text":"COUNT=2 CWD=/tmp"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-{{sessionId}}-note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-{{sessionId}}-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":51,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":52,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json new file mode 100644 index 0000000000..989372e15b --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json @@ -0,0 +1 @@ +{"status":"ok","reason":{"kind":"completed"},"finalResponse":"PERSISTENT_TOOLS_OK"} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl new file mode 100644 index 0000000000..d4c36a93ac --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -0,0 +1,54 @@ +{"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785331618313,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785331618325,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":1785331618325,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-1","name":"bash","argumentsDelta":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":9,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":1785331618327,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"68f0912b-5e3a-417e-a324-00871206cdf7"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1785331618327,"data":{"turn":1,"step":1,"callId":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}} +{"type":"tool/result","seq":12,"time":1785331618649,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bash-1"},"content":[{"type":"tool-result","toolCallId":"bash-1","content":[{"type":"text","text":"COUNT=1 CWD=/tmp"}],"isError":false}],"role":"user","id":"a83a469c-0321-4f8b-a40e-913c1b433b9d"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":1785331618649,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":1785331618649,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":16,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-2","name":"bash","argumentsDelta":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"type":"assistant/chunk","seq":18,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":19,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":20,"time":1785331618652,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"425c837c-b7e5-48ef-bc97-282bf5a10221"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1785331618652,"data":{"turn":1,"step":2,"callId":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}} +{"type":"tool/result","seq":22,"time":1785331618759,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bash-2"},"content":[{"type":"tool-result","toolCallId":"bash-2","content":[{"type":"text","text":"COUNT=2 CWD=/tmp"}],"isError":false}],"role":"user","id":"1d3fcea8-51d9-47a1-8e8e-283c7b9cf53a"}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1785331618759,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1785331618759,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":29,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}} +{"type":"tool/result","seq":32,"time":1785331618782,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-persistent-tools-snapshot-note.txt"}],"isError":false}],"role":"user","id":"121833da-381d-492e-9d6c-82eaa9694ef1"}},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1785331618782,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1785331618782,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}} +{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"type":"assistant/chunk","seq":38,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":39,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}} +{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-persistent-tools-snapshot-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1785331618799,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1785331618799,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":46,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} +{"type":"assistant/chunk","seq":47,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":48,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":49,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":50,"time":1785331618802,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b8832049-1795-4127-b0e0-e31528da0e99"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"step/end","seq":51,"time":1785331618802,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":52,"time":1785331618802,"data":{"turn":1,"reason":{"kind":"completed"}}} From 205702adaf7b100a7e841f203471bfdaa5b1af2f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:33:39 +0800 Subject: [PATCH 10/41] fix(pty): reset persistent shell on cancellation --- .../pty/tool-bash-persistent/src/index.ts | 8 ++-- .../tool-bash-persistent/tests/tools.spec.ts | 45 +++++++++++-------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 6cadf75708..9c6f755434 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -309,6 +309,10 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].join('\n') } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent bash command aborted') + commandDeadline.signal.throwIfAborted() + } if (latest.text.includes(marker.end)) { const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) return renderCaptured(complete, config.maxOutputChars) @@ -325,10 +329,6 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } - if (commandDeadline.signal.aborted) { - await shells.reset(owner, 'persistent bash command aborted') - commandDeadline.signal.throwIfAborted() - } if (promptCompleted(result)) { const snapshot = retainedScrollback(ctx, owner, id, latest) return renderCaptured( diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 990004c1c9..81a4f28b87 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -80,6 +80,7 @@ type StubMode = | 'exit' | 'signal-exit' | 'wait-for-abort' + | 'end-on-abort' | 'idle-then-normal' | 'large' | 'nonzero' @@ -119,11 +120,16 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') - if (this.mode === 'wait-for-abort') { + if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { - this.scrollback += 'partial output' - resolve(this.result('partial output', 'stdin_read')) + const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0] + const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(request.text)?.[0] + const output = this.mode === 'end-on-abort' + ? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}` + : 'partial output' + this.scrollback += output + resolve(this.result(output, 'stdin_read')) }, { once: true }) }) return this.operation(done) @@ -389,22 +395,25 @@ describe('tool-bash-persistent', () => { expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out') }) - it('cancels in-flight work, resets the shell, and releases a queued call', async () => { - const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) - await call(ctx, owner, 'warm up') - stub.sessions[0]!.mode = 'wait-for-abort' - const controller = new AbortController() - const cancelled = call(ctx, owner, 'hang', controller.signal) - const queued = call(ctx, owner, 'after cancellation') - setTimeout(() => { - controller.abort(new Error('caller stopped')) - }, 5) + it.each(['wait-for-abort', 'end-on-abort'] as const)( + 'cancels %s work, resets the shell, and releases a queued call', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = mode + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) - expect((await cancelled).isError).toBe(true) - expect(text(await queued)).toBe('hello from stub') - expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted') - expect(stub.sessions).toHaveLength(2) - }) + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted') + expect(stub.sessions).toHaveLength(2) + }, + ) it.each(['init-exit', 'init-timeout'] as const)( 'fails initialization and closes the unusable shell for %s', From 98e6a0573f09a328420194e8195f948339351527 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:37:12 +0800 Subject: [PATCH 11/41] fix(fs): keep one editor path contract --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +-- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- docs/config-catalog.md | 8 ++--- .../tool-str-replace-editor/README.i18n.yaml | 4 +-- packages/fs/tool-str-replace-editor/README.md | 5 +-- .../fs/tool-str-replace-editor/README.zh.md | 5 +-- .../fs/tool-str-replace-editor/src/index.ts | 34 +++++++------------ .../tests/tools.spec.ts | 20 ++++++----- 9 files changed, 33 insertions(+), 51 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index e3b6121e19..df7df7c84c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 286a53c1c686cc515b65119ed4b1a01a57b0614b -2026-07-29-persistent-bash-str-replace-editor.zh.md: d2417708c8a1334e9f8930481f4218cbefc5a87b +2026-07-29-persistent-bash-str-replace-editor.md: 26949e7435bcbf05132662c308f33f322920c7eb +2026-07-29-persistent-bash-str-replace-editor.zh.md: 6d5eadbfa68a59157e8f4bc148d03144bc665cb2 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 286a53c1c6..26949e7435 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -12,7 +12,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. -`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. The public schema and failures use only `old_str`; canonical mode requires absolute paths and expands tabs before mutations. Deployments with an intentional session-cwd contract can disable the absolute-path requirement. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. +`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute, mutations preserve tabs outside the requested edit, and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index d2417708c8..6d5eadbfa6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -12,7 +12,7 @@ `@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 -`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。公开 schema 与错误只使用 `old_str`;规范模式要求绝对路径,并在变更前展开制表符。有明确 session-cwd 契约的部署可以关闭绝对路径要求。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 +`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径,变更会保留请求编辑范围之外的制表符,且公开 schema 与错误只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2e6ffaf3d8..dc2a740349 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:373`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:395`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1764,14 +1764,10 @@ export interface Config { maxOutputChars?: number /** Model-facing tool description. */ description?: string - /** Require local absolute paths like the canonical editor contract (default true). */ - requireAbsolutePath?: boolean - /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ - expandTabsOnMutation?: boolean } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:539`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:514`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 10b1f19182..9c7a2190c4 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-str-replace-editor/README.md -README.md: 8ac6a22f24ddcdd3818b346e3426e58e718027e2 -README.zh.md: cf82b132b63730af209c159066a70f6a18b77f39 +README.md: 12224537ab2ca2d2ba97e93fe8dc2192fa9ac1aa +README.zh.md: 5481723f8a3077ee329ec202b12a67b678abc691 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 8ac6a22f24..12224537ab 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -10,12 +10,10 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w |---|---:|---| | `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | | `description` | Editor command guide | Model-facing tool description. | -| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | -| `expandTabsOnMutation` | `true` | Preserve the canonical Claude SWE behavior that expands tabs across the whole file before replace/insert. Set `false` for atomic literal replacement that preserves unrelated tabs. | ## Tool -The schema provides `view`, `create`, `str_replace`, and `insert`. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. +The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. ## Model Experience @@ -51,5 +49,4 @@ Append-only tool results follow the reusable request prefix. - Operations target UTF-8 text; binary files are unsupported. - `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. -- Canonical mode (`expandTabsOnMutation: true`) expands tabs in the entire file before replacement or insertion, including lines outside the edited region. Set it to `false` for Makefiles and other tab-sensitive files. - Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index cf82b132b6..5481723f8a 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -10,12 +10,10 @@ |---|---:|---| | `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | | `description` | 编辑器命令指南 | 面向模型的工具描述。 | -| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | -| `expandTabsOnMutation` | `true` | 保留 Claude SWE 参考行为:替换/插入前展开整个文件的制表符。设为 `false` 时使用原子字面量替换,并保留未触及的制表符。 | ## 工具 -Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。 +Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 ## 模型体验 @@ -51,5 +49,4 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使 - 操作面向 UTF-8 文本,不支持二进制文件。 - `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 -- 规范模式(`expandTabsOnMutation: true`)会在替换或插入前展开整个文件中的制表符,包括未编辑区域。Makefile 等依赖制表符的文件应设为 `false`。 - 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。 diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 98bfd6528d..801e9200b4 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -105,12 +105,11 @@ class MutationPolicy { async function resolveTarget( ctx: Context, path: string, - requireAbsolutePath: boolean, exec: ToolRunContext, workspaceRoot?: string, ): Promise { if (path.trim().length === 0) throw new Error('path must be a non-empty string') - if (requireAbsolutePath && !isAbsolute(path)) { + if (!isAbsolute(path)) { throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) } const cwd = exec.agent?.session.header.cwd ?? workspaceRoot @@ -237,10 +236,9 @@ async function viewPath( path: string, viewRange: number[] | undefined, maxOutputChars: number, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const target = await resolveTarget(ctx, path, exec) const info = await statExisting(ctx, target, 'view', exec) if (info.type === 'directory') { if (viewRange !== undefined) { @@ -261,12 +259,11 @@ async function createFile( policy: MutationPolicy, path: string, fileText: string | undefined, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { const content = requiredForCommand(fileText, 'file_text', 'create') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) if (await ctx.fs.stat(target, exec.signal) !== undefined) { throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) } @@ -298,11 +295,10 @@ async function replaceInFile( path: string, oldStr: string | undefined, newStr: string | undefined, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) const newValue = newStr ?? '' @@ -327,10 +323,12 @@ async function replaceInFile( } let outcome try { - outcome = await ctx.fs.editText( + outcome = await ctx.fs.writeText( target, - { oldString: oldValue, newString: newValue, replaceAll: false }, - intent ?? { version: info.version }, + before.replace(oldValue, newValue), + intent === undefined + ? { kind: 'replaceIfVersion', version: info.version } + : { kind: 'replaceIfVersion', version: intent.version }, exec.signal, sandboxPolicy, ) @@ -347,13 +345,12 @@ async function insertInFile( path: string, insertLine: number | undefined, newStr: string | undefined, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const info = await statExisting(ctx, target, 'insert', exec) if (info.type !== 'file') { @@ -387,7 +384,6 @@ async function insertInFile( interface ResolvedConfig { maxOutputChars: number description: string - requireAbsolutePath: boolean } function presentEditorCall(args: { @@ -484,9 +480,9 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { async execute(args, exec) { switch (args.command) { case 'view': - return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) + return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, exec) case 'create': - return createFile(ctx, policy, args.path, args.file_text, config.requireAbsolutePath, exec) + return createFile(ctx, policy, args.path, args.file_text, exec) case 'str_replace': return replaceInFile( ctx, @@ -494,7 +490,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.path, args.old_str, args.new_str, - config.requireAbsolutePath, exec, ) case 'insert': @@ -504,7 +499,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.path, args.insert_line, args.new_str, - config.requireAbsolutePath, exec, ) } @@ -522,15 +516,12 @@ export interface Config { maxOutputChars?: number /** Model-facing tool description. */ description?: string - /** Require local absolute paths like the canonical editor contract (default true). */ - requireAbsolutePath?: boolean } /** Runtime configuration schema for the string-replacement editor tool. */ export const Config: z = z.object({ maxOutputChars: z.number().default(16_000), description: z.string().default(DEFAULT_DESCRIPTION), - requireAbsolutePath: z.boolean().default(true), }) /** Register one `str_replace_editor` tool over `ctx.fs`. */ @@ -538,7 +529,6 @@ export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = { maxOutputChars: config.maxOutputChars ?? 16_000, description: config.description ?? DEFAULT_DESCRIPTION, - requireAbsolutePath: config.requireAbsolutePath ?? true, } if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 9518057634..64bdd481ee 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -316,6 +316,16 @@ describe('tool-str-replace-editor', () => { expect(text(repeatedMultiline)) .toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]') + const mixedEol = join(root, 'mixed-eol.txt') + await writeFile(mixedEol, 'alpha\r\nbeta\nmiddle\nalpha\nbeta') + expect((await call(ctx, owner, { + command: 'str_replace', + path: mixedEol, + old_str: 'alpha\r\nbeta', + new_str: 'replaced', + })).isError).toBe(false) + expect(await readFile(mixedEol, 'utf8')).toBe('replaced\nmiddle\nalpha\nbeta') + const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) expect(relative.isError).toBe(true) expect(text(relative)).toContain('is not an absolute path') @@ -378,13 +388,6 @@ describe('tool-str-replace-editor', () => { })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) }) - it('can opt into session-relative paths for non-canonical deployments', async () => { - const { ctx, root, owner } = await setup({ requireAbsolutePath: false }) - await writeFile(join(root, 'relative.txt'), 'relative') - expect(text(await call(ctx, owner, { command: 'view', path: 'relative.txt' }))) - .toContain("Here's the content of") - }) - it('delegates read-before-edit decisions to fs-policy', async () => { const { ctx, root, owner } = await setup({}, { fsPolicy: true }) const existing = join(root, 'existing.txt') @@ -490,7 +493,7 @@ describe('tool-str-replace-editor', () => { const failWrite = async (): Promise => { throw new Error('backend write failed') } - ctx.fs.editText = failWrite + ctx.fs.writeText = failWrite const replace = await call(ctx, owner, { command: 'str_replace', @@ -501,7 +504,6 @@ describe('tool-str-replace-editor', () => { expect(replace.isError).toBe(true) expect(text(replace)).toContain('backend write failed') - ctx.fs.writeText = failWrite const insert = await call(ctx, owner, { command: 'insert', path, From 7b1e8978a989cd4406e5d8fa123c82590c75de2e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:49:21 +0800 Subject: [PATCH 12/41] fix(build): validate packaged spawn helpers --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- patches/node-pty@1.1.0.patch | 6 +- pnpm-lock.yaml | 8 +-- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 33 ++++++++++- python/sdk/tests/test_release_version.py | 55 ++++++++++++++++++- scripts/build-exe-for-python-sdk.ts | 32 ++++++++++- scripts/build-python-release.py | 37 +++++++++++++ 12 files changed, 167 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index df7df7c84c..cb1e7631fd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 26949e7435bcbf05132662c308f33f322920c7eb -2026-07-29-persistent-bash-str-replace-editor.zh.md: 6d5eadbfa68a59157e8f4bc148d03144bc665cb2 +2026-07-29-persistent-bash-str-replace-editor.md: b1be9cc40e11b07b722877e666de0e0328636a05 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 0bf100514e971dfa759f44dcca15a7ad6a2fdd8a diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 26949e7435..b1be9cc40e 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present (or when `DSH_NODE_PTY_SPAWN_HELPER` explicitly selects one), preserving upstream lookup in ordinary Node runs; the executable and runtime-wheel builders fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The executable and runtime-wheel builders inspect ELF or thin Mach-O headers and fail before publication when the helper is absent, mismatched, or not executable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 6d5eadbfa6..0bf100514e 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它(也可由 `DSH_NODE_PTY_SPAWN_HELPER` 显式指定),普通 Node 运行仍保留上游查找方式;若 helper 缺失、架构不匹配或不可执行,可执行文件与 runtime wheel 构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。可执行文件与运行时 wheel 包的构建器会检查 ELF 或 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 ## 考虑过的替代方案 diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch index f0de7b9054..56892a3d58 100644 --- a/patches/node-pty@1.1.0.patch +++ b/patches/node-pty@1.1.0.patch @@ -2,7 +2,7 @@ diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644 --- a/lib/unixTerminal.js +++ b/lib/unixTerminal.js -@@ -26,10 +26,22 @@ var terminal_1 = require("./terminal"); +@@ -26,10 +26,23 @@ var terminal_1 = require("./terminal"); var utils_1 = require("./utils"); var native = utils_1.loadNativeModule('pty'); var pty = native.module; @@ -10,6 +10,7 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf9 -helperPath = path.resolve(__dirname, helperPath); -helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++// A current external embedded-runtime consumer supplies a non-sibling helper. +var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; +if (helperPath) { + helperPath = path.resolve(helperPath); @@ -33,7 +34,7 @@ diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644 --- a/src/unixTerminal.ts +++ b/src/unixTerminal.ts -@@ -14,10 +14,20 @@ import { assign, loadNativeModule } from './utils'; +@@ -14,10 +14,21 @@ import { assign, loadNativeModule } from './utils'; const native = loadNativeModule('pty'); const pty: IUnixNative = native.module; @@ -41,6 +42,7 @@ index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf97712 -helperPath = path.resolve(__dirname, helperPath); -helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++// A current external embedded-runtime consumer supplies a non-sibling helper. +let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; +if (helperPath) { + helperPath = path.resolve(helperPath); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73092a292c..82af864f54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 - node-pty@1.1.0: 4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15 + node-pty@1.1.0: 7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6 importers: @@ -656,7 +656,7 @@ importers: devDependencies: node-pty: specifier: 1.1.0 - version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) + version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) packages/acp/acp: dependencies: @@ -3402,7 +3402,7 @@ importers: dependencies: node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) + version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -15287,7 +15287,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15): + node-pty@1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6): dependencies: node-addon-api: 7.1.1 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 4b104ec211..c25eecf5de 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 python/sdk-runtime/README.md -README.md: ee3791eddf26b526316d4f3952793a03cc48841e -README.zh.md: 59d40ee56688cb377902ff126b7fa77606c7ad8b +README.md: 0869b4a9dce0e261b168f21c90a80faf81ea4a64 +README.zh.md: 3f2342c4c66e24b8213c78d4e7530c3360497022 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index ee3791eddf..0869b4a9dc 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ 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)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built and validated as one runtime product. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built as one runtime product, and ELF or thin Mach-O headers must match the target. No Node installation is 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-demo/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. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 59d40ee566..3f2342c4c6 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;构建与校验会把两者视作同一个运行时产物。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;两者作为一个运行时产物构建,且 ELF 或 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 108e77cf2c..f2df169cfc 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -16,6 +16,34 @@ _PLATFORMS = { _SPAWN_HELPER_SUFFIX = "-spawn-helper" +def _spawn_helper_binary_target(header: bytes) -> str | None: + if ( + len(header) >= 20 + and header[:4] == b"\x7fELF" + and header[4] == 2 + and header[5] == 1 + ): + machine = int.from_bytes(header[18:20], "little") + if machine == 62: + return "linux-x64" + if machine == 183: + return "linux-arm64" + if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": + if int.from_bytes(header[4:8], "little") == 0x0100000C: + return "macos-arm64" + return None + + +def _validate_spawn_helper(path: Path, expected_target: str) -> None: + with path.open("rb") as helper: + actual_target = _spawn_helper_binary_target(helper.read(20)) + if actual_target != expected_target: + raise RuntimeError( + f"runtime spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'} at {path}" + ) + + 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 @@ -39,13 +67,13 @@ class RuntimeBuildHook(BuildHookInterface): ) 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] + matches = [(key, value) for key, value in _PLATFORMS.items() 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] + expected_target, (_, expected_executable) = matches[0] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] @@ -64,6 +92,7 @@ class RuntimeBuildHook(BuildHookInterface): for executable in [executables[0], helpers[0]]: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") + _validate_spawn_helper(helpers[0], expected_target) build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7b7c8254b1..698676a636 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -16,6 +16,18 @@ SCRIPT = ROOT / "scripts" / "build-python-release.py" build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) +def helper_header(target: str) -> bytes: + header = bytearray(20) + if target.startswith("linux-"): + header[:6] = b"\x7fELF\x02\x01" + machine = 62 if target == "linux-x64" else 183 + header[18:20] = machine.to_bytes(2, "little") + else: + header[:4] = b"\xcf\xfa\xed\xfe" + header[4:8] = (0x0100000C).to_bytes(4, "little") + return bytes(header) + + def test_repository_version_matches_root_package_json() -> None: expected = json.loads((ROOT / "package.json").read_text())["version"] @@ -45,7 +57,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non executable.write_bytes(b"runtime") executable.chmod(0o755) spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(b"helper") + spawn_helper.write_bytes(helper_header("macos-arm64")) spawn_helper.chmod(0o751) destination = tmp_path / "staging" @@ -59,7 +71,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" assert (runtime_dir / executable.name).read_bytes() == b"runtime" copied_helper = runtime_dir / spawn_helper.name - assert copied_helper.read_bytes() == b"helper" + assert copied_helper.read_bytes() == helper_header("macos-arm64") assert copied_helper.stat().st_mode & stat.S_IXUSR @@ -75,3 +87,42 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: executable, executable.name, ) + + +@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64"]) +def test_spawn_helper_binary_target(target: str) -> None: + assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target + + +def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(helper_header("linux-arm64")) + spawn_helper.chmod(0o755) + + with pytest.raises(ValueError, match="expected linux-x64, found linux-arm64"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) + + +def test_stage_runtime_rejects_non_binary_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(b"helper") + spawn_helper.chmod(0o755) + + with pytest.raises(ValueError, match="unsupported format or architecture"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 38400009ac..24cd6c0383 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -7,7 +7,7 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' import { basename, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -58,6 +58,24 @@ interface RuntimeProduct { spawnHelper: string } +function spawnHelperBinaryTarget(path: string): string | undefined { + const header = readFileSync(path).subarray(0, 20) + if (header.length >= 20 + && header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + && header[4] === 2 + && header[5] === 1) { + const machine = header.readUInt16LE(18) + if (machine === 62) return 'linux-x64' + if (machine === 183) return 'linux-arm64' + } + if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) { + const cpuType = header.readUInt32LE(4) + if (cpuType === 0x01000007) return 'macos-x64' + if (cpuType === 0x0100000c) return 'macos-arm64' + } + return undefined +} + function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -347,7 +365,17 @@ class SingleExeBuild { + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, ) } - if (statSync(helper).mode & 0o111) return helper + if (statSync(helper).mode & 0o111) { + const expected = `${target.platform}-${target.arch}` + const actual = spawnHelperBinaryTarget(helper) + if (actual !== expected) { + throw new Error( + `build-exe-for-python-sdk: node-pty spawn-helper binary mismatch: expected ${expected}, ` + + `found ${actual ?? 'unsupported format or architecture'} at ${helper}`, + ) + } + return helper + } throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 915968b30d..3011120da1 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -23,6 +23,35 @@ PLATFORMS = { "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } SPAWN_HELPER_SUFFIX = "-spawn-helper" +EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} + + +def spawn_helper_binary_target(header: bytes) -> str | None: + if ( + len(header) >= 20 + and header[:4] == b"\x7fELF" + and header[4] == 2 + and header[5] == 1 + ): + machine = int.from_bytes(header[18:20], "little") + if machine == 62: + return "linux-x64" + if machine == 183: + return "linux-arm64" + if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": + if int.from_bytes(header[4:8], "little") == 0x0100000C: + return "macos-arm64" + return None + + +def validate_spawn_helper(path: Path, expected_target: str) -> None: + with path.open("rb") as helper: + actual_target = spawn_helper_binary_target(helper.read(20)) + if actual_target != expected_target: + raise ValueError( + f"runtime spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'} at {path}" + ) def main() -> None: @@ -142,6 +171,7 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") + validate_spawn_helper(spawn_helper, EXECUTABLE_TARGETS[executable_name]) copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -186,6 +216,13 @@ def verify_wheel( mode = archive.getinfo(executable).external_attr >> 16 if mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") + actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:20]) + expected_target = EXECUTABLE_TARGETS[platform[1]] + if actual_target != expected_target: + raise RuntimeError( + f"{wheel} spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'}" + ) elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 9939236dcbc87e6b2fc3d0fa79ec2ff482dade3a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:50:25 +0800 Subject: [PATCH 13/41] test(tools): prove persistent tool disposal --- .../fs/tool-str-replace-editor/tests/tools.spec.ts | 10 +++++++--- packages/pty/tool-bash-persistent/src/invariant.ts | 5 +++-- packages/pty/tool-bash-persistent/tests/tools.spec.ts | 6 +++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 64bdd481ee..f1fafcfa04 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -78,13 +78,13 @@ async function setup( await ctx.plugin(SandboxedFileSystem, { cwd: root }) } if (options.fsPolicy === true) await ctx.plugin(FsPolicy) - await ctx.plugin(ToolStrReplaceEditor, config) - return { ctx, root, owner: agent(ctx, root) } + const fiber = await ctx.plugin(ToolStrReplaceEditor, config) + return { ctx, root, fiber, owner: agent(ctx, root) } } describe('tool-str-replace-editor', () => { it('registers the standalone schema and configurable description', async () => { - const { ctx } = await setup({ description: 'custom editor description' }) + const { ctx, fiber } = await setup({ description: 'custom editor description' }) const schema = ctx.tools.schemas()[0] expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor']) expect(schema?.description).toBe('custom editor description') @@ -147,6 +147,10 @@ describe('tool-str-replace-editor', () => { })).toMatchObject({ locations: [{ path: '/workspace/a.txt' }], }) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('str_replace_editor')).toBeUndefined() }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { diff --git a/packages/pty/tool-bash-persistent/src/invariant.ts b/packages/pty/tool-bash-persistent/src/invariant.ts index f6b5acfbc7..5e276d4c45 100644 --- a/packages/pty/tool-bash-persistent/src/invariant.ts +++ b/packages/pty/tool-bash-persistent/src/invariant.ts @@ -15,8 +15,9 @@ export const name = 'tool-bash-persistent-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the tool adapter owns no independent durable state; - * PTY ownership and filesystem mutation relations stay with their services. + * No runtime invariant: the adapter's private owner-to-shell cache has no + * observable event or data relation. Lifecycle tests prove its cleanup without + * adding a public surface solely for an invariant. */ const install: InvariantInstaller = () => {} diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 81a4f28b87..1a9d15c476 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -260,7 +260,7 @@ async function setup( describe('tool-bash-persistent', () => { it('registers a configurable schema and reuses one owner shell', async () => { - const { ctx, owner, stub } = await setup({ + const { ctx, owner, stub, fiber } = await setup({ backendType: 'stub', description: 'deployment-specific persistent shell', }) @@ -282,6 +282,10 @@ describe('tool-bash-persistent', () => { const ownerWithoutCwd = agent(ctx, undefined) expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') expect(stub.sessions).toHaveLength(2) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('bash')).toBeUndefined() }) it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { From 7fdde06cf959476fa9f86747b41f5163493820dd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:51:07 +0800 Subject: [PATCH 14/41] chore(pty): mark unsupported diagnostic claims --- docs/config-catalog.md | 2 +- packages/pty/tool-bash-persistent/src/index.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dc2a740349..fcb2bdd2bd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:395`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:397`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 9c6f755434..2ad2bd54b8 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -11,6 +11,7 @@ import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/ds import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' +// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' @@ -304,6 +305,7 @@ async function executeCommand( ) await shells.reset(owner, 'persistent bash command timed out') return [ + // TODO: Report a timeout only; this signal does not establish an OOM. `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, partial, SHELL_RESET_MESSAGE, From c0bd88430ab065fe266024201693910afaa66b2b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:44:19 +0800 Subject: [PATCH 15/41] fix(build): stage platform-specific PTY artifacts --- ...cutable-sdk-runtime-distribution.i18n.yaml | 6 +- ...ile-executable-sdk-runtime-distribution.md | 6 +- ...-executable-sdk-runtime-distribution.zh.md | 6 +- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 4 +- ...9-persistent-bash-str-replace-editor.zh.md | 4 +- pnpm-workspace.yaml | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 29 +++---- python/sdk/tests/test_release_version.py | 38 ++++++---- scripts/build-exe-for-python-sdk.ts | 75 +++++++++++++------ scripts/build-python-release.py | 64 ++++++++-------- 14 files changed, 141 insertions(+), 105 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 1ab141230b..ce64f9d035 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -1,6 +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: 39cfb2999dea7767a18702ad7d160c9e88d7bf20 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: e1a21c40647e1418d4afd02c0bc6b44ef0d4a8cf +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +2026-07-10-single-file-executable-sdk-runtime-distribution.md: fac3d9527b496adaaffdbd8e78401a17c6f0cc0b +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ff6a8be16c5f8591efa9bf383cd47c8fe251fe39 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 39cfb2999d..fac3d9527b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -40,15 +40,15 @@ 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-demo/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. +[`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-demo/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) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products 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. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected 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-demo/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. +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 the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, 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-demo/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) 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. +[`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; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, 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/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index e1a21c4064..ff6a8be16c 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -40,15 +40,15 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建管线与产物 -[`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-demo/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 源码。 +[`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-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。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 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 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 位于 [`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-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/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` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `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、通用标签、混合可执行载荷以及不支持的平台。 +[`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,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 exe“必须显式配置”的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index cb1e7631fd..0e31711c21 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: b1be9cc40e11b07b722877e666de0e0328636a05 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 0bf100514e971dfa759f44dcca15a7ad6a2fdd8a +2026-07-29-persistent-bash-str-replace-editor.md: 6e8a1df7f04340f4a97c0b799aaace0a78526ba5 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 256ffad7945cbb4367b9cc8bb92e9b2968ab4501 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index b1be9cc40e..6e8a1df7f0 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The executable and runtime-wheel builders inspect ELF or thin Mach-O headers and fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch resolves the sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. ## Alternatives considered @@ -30,4 +30,4 @@ Both plugins are included in the Python runtime closure. The persistent Bash clo ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation, but the wheel now contains a main executable plus its private native helper rather than one physical file. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 0bf100514e..256ffad794 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。可执行文件与运行时 wheel 包的构建器会检查 ELF 或 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 ## 考虑过的替代方案 @@ -30,4 +30,4 @@ ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。runtime wheel 的使用者仍不需要安装 Node,但 wheel 现在包含主可执行文件及其私有原生 helper,而不是单个物理文件。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8aad3a1f3d..eb7a7c6322 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,7 +40,7 @@ allowBuilds: # JSONL durability calls MoveFileExW with write-through publication on Windows. koffi: true # The Python runtime deploy includes the reviewed workspace postinstall that - # places node-pty's spawn helper beside the compiled PTY backend. + # restores the executable bit on node-pty's macOS spawn helper. '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true # The Landlock launcher family is our own sibling-repo release, consumed diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index c25eecf5de..ba56be2639 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 python/sdk-runtime/README.md -README.md: 0869b4a9dce0e261b168f21c90a80faf81ea4a64 -README.zh.md: 3f2342c4c66e24b8213c78d4e7530c3360497022 +README.md: 29ffc1dcc3ec3b273ccaee64734739f3c4f34b9c +README.zh.md: d79c87090867c60e09c50016bad4797170b34400 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 0869b4a9dc..29ffc1dcc3 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ 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)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built as one runtime product, and ELF or thin Mach-O headers must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there, and its thin Mach-O header must match the target. No Node installation is 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-demo/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. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 3f2342c4c6..d79c870908 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;两者作为一个运行时产物构建,且 ELF 或 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件,其 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index f2df169cfc..9b54e0c5ed 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -17,26 +17,18 @@ _SPAWN_HELPER_SUFFIX = "-spawn-helper" def _spawn_helper_binary_target(header: bytes) -> str | None: - if ( - len(header) >= 20 - and header[:4] == b"\x7fELF" - and header[4] == 2 - and header[5] == 1 - ): - machine = int.from_bytes(header[18:20], "little") - if machine == 62: - return "linux-x64" - if machine == 183: - return "linux-arm64" if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - if int.from_bytes(header[4:8], "little") == 0x0100000C: + cpu_type = int.from_bytes(header[4:8], "little") + if cpu_type == 0x01000007: + return "macos-x64" + if cpu_type == 0x0100000C: return "macos-arm64" return None def _validate_spawn_helper(path: Path, expected_target: str) -> None: with path.open("rb") as helper: - actual_target = _spawn_helper_binary_target(helper.read(20)) + actual_target = _spawn_helper_binary_target(helper.read(8)) if actual_target != expected_target: raise RuntimeError( f"runtime spawn helper binary mismatch: expected {expected_target}, " @@ -84,15 +76,18 @@ class RuntimeBuildHook(BuildHookInterface): f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" ) expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}" - if [path.name for path in helpers] != [expected_helper]: + expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] + if [path.name for path in helpers] != expected_helpers: + expected = ", ".join(expected_helpers) or "none" found = ", ".join(path.name for path in helpers) or "none" raise RuntimeError( - f"runtime wheel {platform_tag} must contain only {expected_helper}; found {found}" + f"runtime wheel {platform_tag} helper payload mismatch: expected {expected}; found {found}" ) - for executable in [executables[0], helpers[0]]: + for executable in [executables[0], *helpers]: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") - _validate_spawn_helper(helpers[0], expected_target) + if helpers: + _validate_spawn_helper(helpers[0], expected_target) build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 698676a636..fad1d01cda 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -17,14 +17,10 @@ build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) def helper_header(target: str) -> bytes: - header = bytearray(20) - if target.startswith("linux-"): - header[:6] = b"\x7fELF\x02\x01" - machine = 62 if target == "linux-x64" else 183 - header[18:20] = machine.to_bytes(2, "little") - else: - header[:4] = b"\xcf\xfa\xed\xfe" - header[4:8] = (0x0100000C).to_bytes(4, "little") + header = bytearray(8) + header[:4] = b"\xcf\xfa\xed\xfe" + cpu_type = 0x01000007 if target == "macos-x64" else 0x0100000C + header[4:8] = cpu_type.to_bytes(4, "little") return bytes(header) @@ -76,7 +72,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" executable.write_bytes(b"runtime") executable.chmod(0o755) @@ -89,20 +85,36 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: ) -@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64"]) +@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64"]) +def test_stage_runtime_copies_linux_executable_without_spawn_helper( + tmp_path: Path, target: str +) -> None: + executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + destination = tmp_path / "staging" + + build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) + + runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" + runtime_files = [path.name for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")] + assert runtime_files == [executable.name] + + +@pytest.mark.parametrize("target", ["macos-x64", "macos-arm64"]) def test_spawn_helper_binary_target(target: str) -> None: assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" executable.write_bytes(b"runtime") executable.chmod(0o755) spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(helper_header("linux-arm64")) + spawn_helper.write_bytes(helper_header("macos-x64")) spawn_helper.chmod(0o755) - with pytest.raises(ValueError, match="expected linux-x64, found linux-arm64"): + with pytest.raises(ValueError, match="expected macos-arm64, found macos-x64"): build_python_release.stage_runtime( tmp_path / "staging", "1.2.3", diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 24cd6c0383..b4b4d23720 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,8 +8,8 @@ import { spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' -import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' -import { basename, join, resolve, sep } from 'node:path' +import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' const root = resolve(import.meta.dirname, '..') @@ -55,19 +55,11 @@ type Arch = (typeof ARCHES)[number] interface RuntimeProduct { executable: string - spawnHelper: string + spawnHelper?: string } function spawnHelperBinaryTarget(path: string): string | undefined { - const header = readFileSync(path).subarray(0, 20) - if (header.length >= 20 - && header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) - && header[4] === 2 - && header[5] === 1) { - const machine = header.readUInt16LE(18) - if (machine === 62) return 'linux-x64' - if (machine === 183) return 'linux-arm64' - } + const header = readFileSync(path).subarray(0, 8) if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) { const cpuType = header.readUInt32LE(4) if (cpuType === 0x01000007) return 'macos-x64' @@ -76,6 +68,10 @@ function spawnHelperBinaryTarget(path: string): string | undefined { return undefined } +function runtimeProductFiles(product: RuntimeProduct): string[] { + return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])] +} + function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -317,7 +313,7 @@ class SingleExeBuild { */ async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) - const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` + await this.prepareNativePty(target) if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) await this.run(`pkg ${target.spec}`, pnpmBin(), [ 'dlx', @@ -332,6 +328,8 @@ class SingleExeBuild { 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}.`) } + if (target.platform !== 'macos') return { executable: product } + const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) } else { @@ -342,6 +340,38 @@ class SingleExeBuild { return { executable: product, spawnHelper } } + /** + * Put the target node-pty addon in the staged closure. Linux npm installs + * build it from source, but legacy deploy omits that side-effect directory. + * @param target - the pkg target whose native addon is being staged. + */ + private async prepareNativePty(target: Target): Promise { + const stagedRoot = join(this.staging, 'node_modules', 'node-pty') + const stagedBuild = join(stagedRoot, 'build') + if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) + else await rm(stagedBuild, { recursive: true, force: true }) + + const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' + const prebuilt = join(stagedRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'pty.node') + const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') + const destination = join(stagedBuild, 'Release', 'pty.node') + if (this.cli.dryRun) { + if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) + return + } + if (existsSync(prebuilt)) return + + const host = Target.host() + if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) { + throw new Error( + `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; ` + + `checked ${prebuilt}, ${source}. Build the Linux runtime on its target architecture.`, + ) + } + await mkdir(dirname(destination), { recursive: true }) + await copyFile(source, destination) + } + /** * Resolve the node-pty helper that matches a pkg target. * @param target - the pkg target whose helper must be shipped. @@ -349,14 +379,12 @@ class SingleExeBuild { */ private resolveSpawnHelper(target: Target): string { const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty') - const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' const candidates = [ - join(nodePtyRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'spawn-helper'), + join(nodePtyRoot, 'prebuilds', `darwin-${target.arch}`, 'spawn-helper'), ] - const hostPlatform = process.platform === 'darwin' ? 'macos' : process.platform - const hostArch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined - if (target.platform === hostPlatform && target.arch === hostArch) { - candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper')) + const host = Target.host() + if (target.platform === host.platform && target.arch === host.arch) { + candidates.push(join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'spawn-helper')) } const helper = candidates.find(candidate => existsSync(candidate)) if (helper === undefined) { @@ -387,11 +415,10 @@ class SingleExeBuild { 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.executable}`) - console.log(` ${product.spawnHelper}`) + for (const path of runtimeProductFiles(product)) console.log(` ${path}`) continue } - for (const path of [product.executable, product.spawnHelper]) { + for (const path of runtimeProductFiles(product)) { const megabytes = statSync(path).size / (1024 * 1024) console.log(` ${path} (${megabytes.toFixed(1)} MB)`) } @@ -407,7 +434,7 @@ class SingleExeBuild { const destDir = resolve(root, PYTHON_RUNTIME_DIR) if (this.cli.dryRun) { for (const product of products) { - for (const path of [product.executable, product.spawnHelper]) { + for (const path of runtimeProductFiles(product)) { console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) } } @@ -415,7 +442,7 @@ class SingleExeBuild { } mkdirSync(destDir, { recursive: true }) for (const product of products) { - for (const path of [product.executable, product.spawnHelper]) { + for (const path of runtimeProductFiles(product)) { const destination = join(destDir, basename(path)) await copyFile(path, destination) await chmod(destination, statSync(path).mode & 0o777) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 3011120da1..6bbe8bf512 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -27,26 +27,18 @@ EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} def spawn_helper_binary_target(header: bytes) -> str | None: - if ( - len(header) >= 20 - and header[:4] == b"\x7fELF" - and header[4] == 2 - and header[5] == 1 - ): - machine = int.from_bytes(header[18:20], "little") - if machine == 62: - return "linux-x64" - if machine == 183: - return "linux-arm64" if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - if int.from_bytes(header[4:8], "little") == 0x0100000C: + cpu_type = int.from_bytes(header[4:8], "little") + if cpu_type == 0x01000007: + return "macos-x64" + if cpu_type == 0x0100000C: return "macos-arm64" return None def validate_spawn_helper(path: Path, expected_target: str) -> None: with path.open("rb") as helper: - actual_target = spawn_helper_binary_target(helper.read(20)) + actual_target = spawn_helper_binary_target(helper.read(8)) if actual_target != expected_target: raise ValueError( f"runtime spawn helper binary mismatch: expected {expected_target}, " @@ -166,12 +158,14 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ 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}") + expected_target = EXECUTABLE_TARGETS[executable_name] spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") - if not spawn_helper.is_file(): - raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") - if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") - validate_spawn_helper(spawn_helper, EXECUTABLE_TARGETS[executable_name]) + if expected_target.startswith("macos-"): + if not spawn_helper.is_file(): + raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") + if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") + validate_spawn_helper(spawn_helper, expected_target) copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -179,9 +173,10 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ destination_executable = runtime_dir / executable_name shutil.copyfile(executable, destination_executable) destination_executable.chmod(executable.stat().st_mode & 0o777) - destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" - shutil.copyfile(spawn_helper, destination_helper) - destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) + if expected_target.startswith("macos-"): + destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" + shutil.copyfile(spawn_helper, destination_helper) + destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) def verify_wheel( @@ -209,20 +204,27 @@ def verify_wheel( 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}") + expected_target = EXECUTABLE_TARGETS[platform[1]] expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" - if len(helpers) != 1 or not helpers[0].endswith(f"/runtime/{expected_helper}"): - raise RuntimeError(f"{wheel} must contain exactly {expected_helper}, found {helpers}") - for executable in [executables[0], helpers[0]]: + expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] + found_helpers = [Path(helper).name for helper in helpers] + if found_helpers != expected_helpers: + expected = ", ".join(expected_helpers) or "none" + found = ", ".join(found_helpers) or "none" + raise RuntimeError( + f"{wheel} runtime helper payload mismatch: expected {expected}; found {found}" + ) + for executable in [executables[0], *helpers]: mode = archive.getinfo(executable).external_attr >> 16 if mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") - actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:20]) - expected_target = EXECUTABLE_TARGETS[platform[1]] - if actual_target != expected_target: - raise RuntimeError( - f"{wheel} spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'}" - ) + if helpers: + actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:8]) + if actual_target != expected_target: + raise RuntimeError( + f"{wheel} spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'}" + ) elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 5de69975f018a2bc1fb972bef2f6aae5141443e4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:56:14 +0800 Subject: [PATCH 16/41] test(python): stabilize executable snapshot --- ...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 | 9 +- .../advanced/result.json | 708 +++++++++++------- .../advanced/session.1.jsonl | 6 +- .../advanced/session.2.jsonl | 6 +- .../advanced/session.jsonl | 30 +- 8 files changed, 468 insertions(+), 299 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index ce64f9d035..27e43ef4b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: fac3d9527b496adaaffdbd8e78401a17c6f0cc0b -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ff6a8be16c5f8591efa9bf383cd47c8fe251fe39 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: f749d6a72b4c32a189a9f848595076457819d9b9 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2b511573bc68e5378279cec8d22ce960af0966e9 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index fac3d9527b..f749d6a72b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/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. 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`. +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 fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in 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/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index ff6a8be16c..2b511573bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/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`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个由 spawn 提供方直接启动的 subagent(子 agent)和一个会通过 spawn 启动第二个子 agent 的工作流,随后卸载该插件。比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--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 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID: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 346818e5fa..3ce800932f 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -72,6 +72,9 @@ CUSTOM_CORDIS = """\ name: '@deepseek-ai/dsh-agent-spine-demo' config: workspaceContext: false + skills: + enabled: false + toolBash: false tools: mode: both - id: sessions @@ -79,10 +82,6 @@ CUSTOM_CORDIS = """\ config: root: !!js process.env.DSH_SESSION_ROOT compression: 'none' -- id: bash - 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 @@ -874,6 +873,8 @@ def normalize_snapshot_value( normalized["createdAt"] = 0 if "seq" in normalized and "time" in normalized: normalized["time"] = 0 + if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "user"): + normalized["id"] = "{{messageId}}" scrub_snapshot_header(normalized) return normalized diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 07393e7f25..79daa0570c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -30,7 +30,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" }, @@ -70,20 +72,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -176,17 +173,22 @@ "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 output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "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 output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -221,14 +223,27 @@ "data": { "turn": 1, "step": 1, - "callId": "advanced-mount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-mount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 11 @@ -266,21 +281,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -373,17 +383,22 @@ "data": { "turn": 1, "step": 2, - "content": [ - { - "type": "tool-call", - "id": "advanced-code", - "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -451,14 +466,27 @@ "data": { "turn": 1, "step": 2, - "callId": "advanced-code", - "content": [ - { - "type": "text", - "text": "42" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-code" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 22 @@ -570,17 +598,22 @@ "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.\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "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.\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -615,14 +648,27 @@ "data": { "turn": 1, "step": 3, - "callId": "advanced-direct-child", - "content": [ - { - "type": "text", - "text": "DIRECT_CHILD_OK" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-direct-child" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 34 @@ -734,17 +780,22 @@ "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\"}}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "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\"}}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -779,14 +830,27 @@ "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 + "message": { + "source": { + "kind": "tool", + "callId": "advanced-workflow" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 44 @@ -898,17 +962,22 @@ "data": { "turn": 1, "step": 5, - "content": [ - { - "type": "tool-call", - "id": "advanced-unmount", - "name": "cordis_unmount", - "arguments": "{\"id\": \"dyn-1\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -943,14 +1012,27 @@ "data": { "turn": 1, "step": 5, - "callId": "advanced-unmount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 was unmounted and removed." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-unmount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 was unmounted and removed." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 54 @@ -988,20 +1070,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -1090,15 +1167,20 @@ "data": { "turn": 1, "step": 6, - "content": [ - { - "type": "text", - "text": "ADVANCED_EXECUTABLE_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1173,7 +1255,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" } @@ -1231,20 +1315,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -1373,17 +1452,22 @@ "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 output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "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 output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1430,14 +1514,27 @@ "data": { "turn": 1, "step": 1, - "callId": "advanced-mount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-mount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 11 @@ -1493,21 +1590,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -1636,17 +1728,22 @@ "data": { "turn": 1, "step": 2, - "content": [ - { - "type": "tool-call", - "id": "advanced-code", - "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1738,14 +1835,27 @@ "data": { "turn": 1, "step": 2, - "callId": "advanced-code", - "content": [ - { - "type": "text", - "text": "42" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-code" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 22 @@ -1905,17 +2015,22 @@ "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.\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "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.\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1995,7 +2110,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" } @@ -2053,21 +2170,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -2192,15 +2304,20 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "text", - "text": "DIRECT_CHILD_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -2278,14 +2395,27 @@ "data": { "turn": 1, "step": 3, - "callId": "advanced-direct-child", - "content": [ - { - "type": "text", - "text": "DIRECT_CHILD_OK" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-direct-child" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 34 @@ -2445,17 +2575,22 @@ "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\"}}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "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\"}}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -2535,7 +2670,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" } @@ -2593,21 +2730,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -2732,15 +2864,20 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "text", - "text": "WORKFLOW_CHILD_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "WORKFLOW_CHILD_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -2818,14 +2955,27 @@ "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 + "message": { + "source": { + "kind": "tool", + "callId": "advanced-workflow" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 44 @@ -2985,17 +3135,22 @@ "data": { "turn": 1, "step": 5, - "content": [ - { - "type": "tool-call", - "id": "advanced-unmount", - "name": "cordis_unmount", - "arguments": "{\"id\": \"dyn-1\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -3042,14 +3197,27 @@ "data": { "turn": 1, "step": 5, - "callId": "advanced-unmount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 was unmounted and removed." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-unmount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 was unmounted and removed." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 54 @@ -3105,20 +3273,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -3243,15 +3406,20 @@ "data": { "turn": 1, "step": 6, - "content": [ - { - "type": "text", - "text": "ADVANCED_EXECUTABLE_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 05998f8980..2929f8664c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,14 +1,14 @@ {"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"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":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":12,"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 index 778c200078..a5da33d006 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,14 +1,14 @@ {"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"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":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":12,"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 index 1078fe4985..1f2f890b3c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,30 +1,30 @@ {"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"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":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"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 output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} {"type":"assistant/chunk","seq":7,"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 output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"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 output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","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 output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"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 output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":17,"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 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} {"type":"assistant/chunk","seq":18,"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 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":21,"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 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} {"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} {"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} {"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} {"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -32,9 +32,9 @@ {"type":"assistant/chunk","seq":30,"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":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"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.\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","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.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} {"type":"tool/call","seq":34,"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":35,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} {"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} {"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -42,9 +42,9 @@ {"type":"assistant/chunk","seq":40,"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":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"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\"}}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","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\"}}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} {"type":"tool/call","seq":44,"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":45,"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":[44],"surfaceOp":"append"} +{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} {"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} {"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -52,17 +52,17 @@ {"type":"assistant/chunk","seq":50,"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":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} +{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} {"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} {"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} {"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} {"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} {"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} From 525b3aa6c98113420983e344a6943087d9758156 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:20:08 +0800 Subject: [PATCH 17/41] fix(python): resolve Linux runtime without helper --- python/sdk-runtime/README.i18n.yaml | 4 +-- python/sdk-runtime/README.md | 4 +-- python/sdk-runtime/README.zh.md | 4 +-- .../src/deepseek_harness_runtime/__init__.py | 27 ++++++++++--------- python/sdk/tests/test_runtime_resolution.py | 27 +++++++++++++++++++ 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index ba56be2639..129dac85d5 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 python/sdk-runtime/README.md -README.md: 29ffc1dcc3ec3b273ccaee64734739f3c4f34b9c -README.zh.md: d79c87090867c60e09c50016bad4797170b34400 +README.md: efdb5cf9f87e0831ef09a47e6ffdb99254a17f36 +README.zh.md: cafac7418608c416a9f291d62442c32b8787b1fc diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 29ffc1dcc3..efdb5cf9f8 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -15,12 +15,12 @@ Both carriers hold the same content, defined once: the [package.json](package.js A missing exe raises `FileNotFoundError` naming both 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. A missing dev-only node carrier names its sole route, the build script. 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 runtime executable and its matching native spawn helper. A missing sidecar makes the runtime installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools; old exe-only wheels are intentionally unsupported. 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 runtime files, non-executable files, 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. +Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. 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 runtime files, non-executable files, 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 - `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; it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above. +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; on macOS it validates that the required sibling `-spawn-helper` is also installed). 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. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index d79c870908..cafac74186 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -15,12 +15,12 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。缺少伴随文件意味着运行时安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此;旧的仅 exe 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` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `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 载体,并会校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会在 macOS 上校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 9228281ab2..d6f8c497b6 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -5,8 +5,8 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's - **exe (production)**: single-file Node executables named ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in - {x64, arm64}) plus a sibling ``-spawn-helper`` used by ``node-pty``; the - target machine needs no Node installation. + {x64, arm64}); macOS also uses a sibling ``-spawn-helper``. 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-demo/lib/bin.js`` on a @@ -71,11 +71,11 @@ def bundled_default_config_path() -> 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). + Raises FileNotFoundError when the platform is unsupported, the executable + has not been placed into this package, or the required macOS spawn helper is + missing; 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}" @@ -84,12 +84,13 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) - helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") - if not helper.is_file(): - raise FileNotFoundError( - f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " - + _EXE_ACQUISITION_HINT - ) + if tag.startswith("macos-"): + helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") + if not helper.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " + + _EXE_ACQUISITION_HINT + ) return path diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 400394ae4e..e0411bb1fd 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -2,6 +2,9 @@ from __future__ import annotations +from pathlib import Path + +import deepseek_harness_runtime as runtime import pytest from deepseek_harness_runtime import ( @@ -39,3 +42,27 @@ def test_explicit_mode_wins_over_env_mode(monkeypatch: pytest.MonkeyPatch) -> No except FileNotFoundError: return # explicit 'exe' was honored; only the artifact is missing assert args[0].endswith(("-x64", "-arm64")) + + +@pytest.mark.parametrize( + ("platform_tag", "requires_helper"), + [("linux-x64", False), ("macos-arm64", True)], +) +def test_runtime_requires_spawn_helper_only_on_macos( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + platform_tag: str, + requires_helper: bool, +) -> None: + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + executable = runtime_dir / f"dsh-jsonrpc-agent-pkg-{platform_tag}" + executable.touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: platform_tag) + + if requires_helper: + with pytest.raises(FileNotFoundError, match="node-pty spawn helper"): + runtime.bundled_runtime_path() + else: + assert runtime.bundled_runtime_path() == executable From fbfe520471024a2da483e3322a94f96f7838ad8e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:48:43 +0800 Subject: [PATCH 18/41] fix(editor): preserve literal replacement text --- packages/fs/tool-str-replace-editor/src/index.ts | 5 +++-- .../tool-str-replace-editor/tests/tools.spec.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 801e9200b4..071ada3ec6 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -308,7 +308,8 @@ async function replaceInFile( } const before = await ctx.fs.readText(target, exec.signal) const offsets = matchOffsets(before, oldValue) - if (offsets.length === 0) { + const offset = offsets[0] + if (offset === undefined) { throw new FsError( `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, 'FS_EDIT_NOT_FOUND', @@ -325,7 +326,7 @@ async function replaceInFile( try { outcome = await ctx.fs.writeText( target, - before.replace(oldValue, newValue), + before.slice(0, offset) + newValue + before.slice(offset + oldValue.length), intent === undefined ? { kind: 'replaceIfVersion', version: info.version } : { kind: 'replaceIfVersion', version: intent.version }, diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index f1fafcfa04..90b182a43f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -194,6 +194,21 @@ describe('tool-str-replace-editor', () => { expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n') }) + it('writes replacement text literally', async () => { + const { ctx, root, owner } = await setup() + const sample = join(root, 'literal.txt') + const replacement = "$&|$`|$'|$$" + await writeFile(sample, 'before OLD after') + + expect((await call(ctx, owner, { + command: 'str_replace', + path: sample, + old_str: 'OLD', + new_str: replacement, + })).isError).toBe(false) + expect(await readFile(sample, 'utf8')).toBe(`before ${replacement} after`) + }) + it('lists visible entries to depth two and clips at the configured view limit', async () => { const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 }) await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) From 7f5aa2d053ff7c2068dd9b7b90a2d53a8d8713b3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:50:12 +0800 Subject: [PATCH 19/41] fix(persistent-bash): wait for complete status markers --- .../pty/tool-bash-persistent/src/index.ts | 10 +++++---- .../tool-bash-persistent/tests/tools.spec.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 2ad2bd54b8..90cf1b4774 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -93,16 +93,18 @@ function stripPrompt(text: string): string { function commandOutput( snapshot: RetainedOutput, marker: CommandMarkers, -): CapturedOutput { +): CapturedOutput | undefined { const text = snapshot.text const end = text.lastIndexOf(marker.end) - const exitCode = Number.parseInt(text.slice(end + marker.end.length), 10) + if (end < 0) return undefined + const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] + if (status === undefined) return undefined const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), incomplete: startMarker < 0, - exitCode, + exitCode: Number(status), } } @@ -317,7 +319,7 @@ async function executeCommand( } if (latest.text.includes(marker.end)) { const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) - return renderCaptured(complete, config.maxOutputChars) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 1a9d15c476..f3bd7bf40b 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -84,6 +84,8 @@ type StubMode = | 'idle-then-normal' | 'large' | 'nonzero' + | 'torn-status' + | 'finish-torn-status' | 'end-only' | 'init-exit' | 'init-timeout' @@ -160,6 +162,17 @@ class StubPtySession implements PtyBackendSession { this.pendingText = '' const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0] const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0] + if (this.mode === 'torn-status') { + const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` + this.scrollback += output + this.mode = 'finish-torn-status' + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + if (this.mode === 'finish-torn-status') { + const output = `7\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } if (this.mode === 'end-only') { const output = `recovered output\n${end ?? ''}0\n${this.motd}` this.scrollback += output @@ -345,6 +358,15 @@ describe('tool-bash-persistent', () => { expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) + it('waits for status digits after a torn completion marker', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'torn-status' + stub.sessions[0]!.scrollback = '' + + expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') + }) + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') From 84394e596d684e7445715c44cc482323dccec2a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:50:52 +0800 Subject: [PATCH 20/41] cleanup(editor): remove dead path-base plumbing --- packages/fs/tool-str-replace-editor/src/index.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 071ada3ec6..c003d0915b 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -105,15 +105,13 @@ class MutationPolicy { async function resolveTarget( ctx: Context, path: string, - exec: ToolRunContext, - workspaceRoot?: string, + signal: AbortSignal, ): Promise { if (path.trim().length === 0) throw new Error('path must be a non-empty string') if (!isAbsolute(path)) { throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) } - const cwd = exec.agent?.session.header.cwd ?? workspaceRoot - return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal }) + return ctx.fs.resolve(path, { signal }) } async function statExisting( @@ -238,7 +236,7 @@ async function viewPath( maxOutputChars: number, exec: ToolRunContext, ): Promise { - const target = await resolveTarget(ctx, path, exec) + const target = await resolveTarget(ctx, path, exec.signal) const info = await statExisting(ctx, target, 'view', exec) if (info.type === 'directory') { if (viewRange !== undefined) { @@ -263,7 +261,7 @@ async function createFile( ): Promise { const content = requiredForCommand(fileText, 'file_text', 'create') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec.signal) if (await ctx.fs.stat(target, exec.signal) !== undefined) { throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) } @@ -298,7 +296,7 @@ async function replaceInFile( exec: ToolRunContext, ): Promise { const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec.signal) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) const newValue = newStr ?? '' @@ -351,7 +349,7 @@ async function insertInFile( if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec.signal) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const info = await statExisting(ctx, target, 'insert', exec) if (info.type !== 'file') { From d8b70df00c62c0aa4cf9ddd238a45d0d87a59261 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:53:59 +0800 Subject: [PATCH 21/41] test(snapshot): isolate persistent tool artifacts --- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 56 ++++++++++++------- .../notifications.expected.jsonl | 22 ++++---- .../snapshots/persistent-tools/session.jsonl | 22 ++++---- 3 files changed, 57 insertions(+), 43 deletions(-) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 26a6461586..8f8da33611 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, isAbsolute, join } from 'node:path' +import { basename, delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { @@ -55,7 +55,7 @@ interface SdkScenario { children: number /** Optional scenario-specific live and replay compositions. */ configs?: { live: string; replay: string } - /** Files whose final contents are part of the scenario contract. */ + /** Cwd-relative files whose final contents are part of the scenario contract. */ expectedFiles?: Readonly> } @@ -80,13 +80,11 @@ const SCENARIOS: SdkScenario[] = [ }, { name: 'persistent-tools', - prompt: 'Prove that bash state persists, then create and edit note.txt.', + prompt: 'Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt.', sessionId: 'persistent-tools-snapshot', children: 0, configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, - // Replay returns recorded tool arguments verbatim, so this cross-platform - // POSIX fixture uses one stable absolute path and cleans it around the run. - expectedFiles: { '/tmp/dsh-persistent-tools-snapshot-note.txt': 'beta\n' }, + expectedFiles: { 'note.txt': 'beta\n' }, }, ] @@ -96,6 +94,10 @@ interface PersistedLog { readonly header: Record } +interface MissingFile { + readonly missing: true +} + async function jsonlFiles(dir: string): Promise { const entries = await readdir(dir, { recursive: true }) return entries.filter(entry => entry.endsWith('.jsonl')).map(entry => join(dir, entry)).sort() @@ -125,6 +127,25 @@ function contextOfContents(contents: readonly string[]): NormalizeContext { } } +async function hydrateReplayFixtures(scenario: SdkScenario, cwd: string): Promise { + const root = join(cwd, '.replay-fixtures') + await mkdir(root, { recursive: true }) + return Promise.all(fixtureFiles(scenario).map(async (source) => { + const destination = join(root, basename(source)) + await writeFile(destination, (await readFile(source, 'utf8')).replaceAll('{{cwd}}', cwd)) + return destination + })) +} + +async function readExpectedFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error: unknown) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return { missing: true } + throw error + } +} + /** * Normalize the SDK-visible notification stream: embedded `session.event` * envelopes get the session-log treatment (times zeroed, headers tokenized), @@ -163,24 +184,18 @@ async function runScenario(scenario: SdkScenario): Promise<{ result: TurnResult notifications: HarnessNotification[] logs: PersistedLog[] - observedFiles: Record + observedFiles: Record cwd: string }> { const cwd = await mkdtemp(join(tmpdir(), `sdk-snapshot-${scenario.name}-`)) const sessionsRoot = join(cwd, '.sessions') - const scenarioDir = join(snapshotsDir, scenario.name) - const expectedFilePaths = Object.keys(scenario.expectedFiles ?? {}).map(path => - isAbsolute(path) ? path : join(cwd, path)) - await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) + const replayFixtures = recording ? [] : await hydrateReplayFixtures(scenario, cwd) const launch = resolveExampleLaunch({ srcBin: runtimeBin, configArgs: [], tsconfigPath: repoTsconfig, }) - const childFixtures = Array.from( - { length: scenario.children }, - (_, index) => join(scenarioDir, `session.${index + 1}.jsonl`), - ) + const [parentFixture, ...childFixtures] = replayFixtures const env: Record = { ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, @@ -191,8 +206,8 @@ async function runScenario(scenario: SdkScenario): Promise<{ DSH_CWD: cwd, DSH_SNAPSHOT: mode, NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - ...recording ? {} : { - DSH_SNAPSHOT_FILE: join(scenarioDir, 'session.jsonl'), + ...parentFixture === undefined ? {} : { + DSH_SNAPSHOT_FILE: parentFixture, ...childFixtures.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: childFixtures.join(delimiter) } : {}, }, } @@ -211,22 +226,21 @@ async function runScenario(scenario: SdkScenario): Promise<{ }) try { const notifications: HarnessNotification[] = [] - const result = await harness.run(scenario.prompt, { + const result = await harness.run(scenario.prompt.replaceAll('{{cwd}}', cwd), { sessionId: scenario.sessionId, onNotification: (notification) => { notifications.push(notification) }, }) await harness.close() const logs = await persistedLogs(sessionsRoot) const observedFiles = Object.fromEntries(await Promise.all( - Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string]> => [ + Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string | MissingFile]> => [ path, - await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8'), + await readExpectedFile(join(cwd, path)), ]), )) return { result, notifications, logs, observedFiles, cwd } } finally { await harness.close() - await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) await rm(cwd, { recursive: true, force: true }) } } diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl index 481e0a3d08..264835dd0a 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -1,5 +1,5 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} @@ -24,23 +24,23 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-{{sessionId}}-note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-{{sessionId}}-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl index d4c36a93ac..cdab76078e 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785331618313,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -25,23 +25,23 @@ {"type":"step/end","seq":23,"time":1785331618759,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":1785331618759,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":25,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}} -{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}} {"type":"assistant/chunk","seq":28,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":29,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}} -{"type":"tool/result","seq":32,"time":1785331618782,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-persistent-tools-snapshot-note.txt"}],"isError":false}],"role":"user","id":"121833da-381d-492e-9d6c-82eaa9694ef1"}},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}} +{"type":"tool/result","seq":32,"time":1785331618782,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"121833da-381d-492e-9d6c-82eaa9694ef1"}},"sourceEventSeqs":[31],"surfaceOp":"append"} {"type":"step/end","seq":33,"time":1785331618782,"data":{"turn":1,"step":3}} {"type":"step/start","seq":34,"time":1785331618782,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":35,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}} -{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}} +{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} {"type":"assistant/chunk","seq":38,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":39,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} -{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}} -{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-persistent-tools-snapshot-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}} +{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} {"type":"step/end","seq":43,"time":1785331618799,"data":{"turn":1,"step":4}} {"type":"step/start","seq":44,"time":1785331618799,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":45,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} From 38e4a7bf7fc03c0f84c51f53326099cee1506620 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:56:28 +0800 Subject: [PATCH 22/41] test(snapshot): pin persistent tool schemas --- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 8f8da33611..a716a834b8 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -57,6 +57,8 @@ interface SdkScenario { configs?: { live: string; replay: string } /** Cwd-relative files whose final contents are part of the scenario contract. */ expectedFiles?: Readonly> + /** Assembled model-facing tool names and required argument keys. */ + expectedTools?: Readonly> } const SCENARIOS: SdkScenario[] = [ @@ -85,6 +87,7 @@ const SCENARIOS: SdkScenario[] = [ children: 0, configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, expectedFiles: { 'note.txt': 'beta\n' }, + expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] }, }, ] @@ -112,6 +115,20 @@ async function persistedLogs(sessionsRoot: string): Promise { })) } +interface LoggedRequestHeader { + type?: string + data?: { header?: { tools?: Array<{ name: string; parameters: { required?: string[] } }> } } +} + +function assembledToolRequirements(log: PersistedLog): Record { + const event = log.content.trimEnd().split('\n') + .map(line => JSON.parse(line) as LoggedRequestHeader) + .find(candidate => candidate.type === 'request/header') + const tools = event?.data?.header?.tools + if (tools === undefined) throw new Error('session log has no request/header tools') + return Object.fromEntries(tools.map(tool => [tool.name, tool.parameters.required ?? []])) +} + function contextOf(logs: readonly { content: string; header: Record }[], cwd: string): NormalizeContext { return { sessionIds: logs.flatMap(log => typeof log.header.id === 'string' ? [log.header.id] : []), @@ -337,6 +354,11 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { expect(result.status).toBe('ok') expect(notifications.at(-1)?.method).toBe('session.finished') expect(observedFiles).toEqual(scenario.expectedFiles ?? {}) + if (scenario.expectedTools !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledToolRequirements(parent)).toEqual(scenario.expectedTools) + } if (scenario.children > 0) { expect(notifications.some(n => n.method === 'subagent.started')).toBe(true) expect(notifications.some(n => n.method === 'subagent.finished')).toBe(true) From 514238ee47c5cccd1dc16de6ee39d90a6b6bda22 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:05:58 +0800 Subject: [PATCH 23/41] fix(editor): preserve tabs in file views --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 4 +- .../notifications.expected.jsonl | 46 +++++++++++-------- .../snapshots/persistent-tools/session.jsonl | 46 +++++++++++-------- .../tool-str-replace-editor/README.i18n.yaml | 4 +- packages/fs/tool-str-replace-editor/README.md | 2 +- .../fs/tool-str-replace-editor/README.zh.md | 2 +- .../fs/tool-str-replace-editor/src/index.ts | 23 ++-------- .../tests/tools.spec.ts | 6 ++- 11 files changed, 73 insertions(+), 68 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 0e31711c21..59f972d856 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 6e8a1df7f04340f4a97c0b799aaace0a78526ba5 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 256ffad7945cbb4367b9cc8bb92e9b2968ab4501 +2026-07-29-persistent-bash-str-replace-editor.md: 006031248fbe74d72cbecb3dad88deb9f035e023 +2026-07-29-persistent-bash-str-replace-editor.zh.md: f134c3499648ef6c544bc251084136984c384bb2 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 6e8a1df7f0..006031248f 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -12,7 +12,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. -`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute, mutations preserve tabs outside the requested edit, and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. +`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute; file views preserve content tabs so copied text remains valid literal replacement input; mutations preserve tabs outside the requested edit; and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 256ffad794..f134c34996 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -12,7 +12,7 @@ `@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 -`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径,变更会保留请求编辑范围之外的制表符,且公开 schema 与错误只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 +`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径;文件查看会保留内容中的制表符,因此复制的文本仍可作为有效的字面量替换输入;变更会保留请求编辑范围之外的制表符;公开 schema 与错误则只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index a716a834b8..a750ddc5e4 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -82,11 +82,11 @@ const SCENARIOS: SdkScenario[] = [ }, { name: 'persistent-tools', - prompt: 'Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt.', + prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line.', sessionId: 'persistent-tools-snapshot', children: 0, configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, - expectedFiles: { 'note.txt': 'beta\n' }, + expectedFiles: { 'note.txt': 'target:\n\tnew\n' }, expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] }, }, ] diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl index 264835dd0a..b1494b95ca 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -1,6 +1,6 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} @@ -24,31 +24,41 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":51,"time":0,"data":{"turn":1,"step":5}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":52,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[51],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":61,"time":0,"data":{"turn":1,"step":6}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":62,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl index cdab76078e..b4094876ea 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785331618313,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785331618325,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -25,30 +25,40 @@ {"type":"step/end","seq":23,"time":1785331618759,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":1785331618759,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":25,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}} -{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} {"type":"assistant/chunk","seq":28,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":29,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}} +{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}} {"type":"tool/result","seq":32,"time":1785331618782,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"121833da-381d-492e-9d6c-82eaa9694ef1"}},"sourceEventSeqs":[31],"surfaceOp":"append"} {"type":"step/end","seq":33,"time":1785331618782,"data":{"turn":1,"step":3}} {"type":"step/start","seq":34,"time":1785331618782,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":35,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}} -{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}} +{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} {"type":"assistant/chunk","seq":38,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":39,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} -{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}} -{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}} +{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} {"type":"step/end","seq":43,"time":1785331618799,"data":{"turn":1,"step":4}} {"type":"step/start","seq":44,"time":1785331618799,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":45,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":46,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} -{"type":"assistant/chunk","seq":47,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":45,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} {"type":"assistant/chunk","seq":48,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":49,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":50,"time":1785331618802,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b8832049-1795-4127-b0e0-e31528da0e99"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} -{"type":"step/end","seq":51,"time":1785331618802,"data":{"turn":1,"step":5}} -{"type":"turn/end","seq":52,"time":1785331618802,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":49,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1785331618802,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b8832049-1795-4127-b0e0-e31528da0e99"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1785331618802,"data":{"turn":1,"step":5,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}} +{"type":"tool/result","seq":52,"time":1785331618803,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"ee874ae7-c4d9-4075-9b40-45e643a4b159"}},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1785331618803,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":1785331618803,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":56,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} +{"type":"assistant/chunk","seq":57,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":58,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1785331618805,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"8e39f4fe-5538-46be-b24a-84296d638c44"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1785331618805,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":62,"time":1785331618805,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 9c7a2190c4..7d08a33f9a 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-str-replace-editor/README.md -README.md: 12224537ab2ca2d2ba97e93fe8dc2192fa9ac1aa -README.zh.md: 5481723f8a3077ee329ec202b12a67b678abc691 +README.md: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f +README.zh.md: 48358eb3c9d81ddad6a83c4ff3ef0cf6542096b1 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 12224537ab..97e9e0ab9a 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -13,7 +13,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w ## Tool -The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. +The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. ## Model Experience diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index 5481723f8a..48358eb3c9 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -13,7 +13,7 @@ ## 工具 -Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 +Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 ## 模型体验 diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index c003d0915b..6d0f65c48c 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -35,23 +35,6 @@ function maybeTruncate(content: string, maxOutputChars: number): string { : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE } -function expandTabs(content: string, tabSize = 8): string { - let column = 0 - let result = '' - for (const character of content) { - if (character === '\t') { - const spaces = tabSize - (column % tabSize) - result += ' '.repeat(spaces) - column += spaces - continue - } - result += character - if (character === '\n' || character === '\r') column = 0 - else column += 1 - } - return result -} - function codepointCompare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 } @@ -192,9 +175,9 @@ function formatFileView( : allLines.slice(initialLine - 1, finalLine) prompt += ` with view_range=[${initialLine}, ${finalLine}]` } - const numbered = expandTabs(lines - .map((line, index) => `${String(initialLine + index).padStart(6, ' ')}\t${line}`) - .join('\n')) + const numbered = lines + .map((line, index) => `${String(initialLine + index).padStart(6, ' ')} ${line}`) + .join('\n') return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars) } diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 90b182a43f..9a948ed164 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -470,11 +470,13 @@ describe('tool-str-replace-editor', () => { const { ctx, root, owner } = await setup() const path = join(root, 'Makefile') await writeFile(path, 'target:\n\told\nremove\n') + expect(text(await call(ctx, owner, { command: 'view', path }))) + .toContain(' 2 \told') await call(ctx, owner, { command: 'str_replace', path, - old_str: 'old', - new_str: 'new', + old_str: '\told', + new_str: '\tnew', }) await call(ctx, owner, { command: 'str_replace', From 93ae5ea8676acbdb831891d2e343cf5cf176c26e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:08:17 +0800 Subject: [PATCH 24/41] fix(python): name unsupported release executables --- python/sdk/tests/test_release_version.py | 21 +++++++++++++++++++++ scripts/build-python-release.py | 14 ++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index fad1d01cda..ce54185ba5 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -85,6 +85,27 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: ) +def test_stage_runtime_rejects_unsupported_executable_name(tmp_path: Path) -> None: + executable = tmp_path / "custom-runtime" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + + with pytest.raises( + ValueError, + match=( + "unsupported runtime executable 'custom-runtime'; expected one of: " + "dsh-jsonrpc-agent-pkg-linux-arm64, dsh-jsonrpc-agent-pkg-linux-x64, " + "dsh-jsonrpc-agent-pkg-macos-arm64" + ), + ): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) + + @pytest.mark.parametrize("target", ["linux-x64", "linux-arm64"]) def test_stage_runtime_copies_linux_executable_without_spawn_helper( tmp_path: Path, target: str diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 6bbe8bf512..dc53853b39 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -26,6 +26,16 @@ SPAWN_HELPER_SUFFIX = "-spawn-helper" EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} +def executable_target(executable_name: str) -> str: + try: + return EXECUTABLE_TARGETS[executable_name] + except KeyError as error: + supported = ", ".join(sorted(EXECUTABLE_TARGETS)) + raise ValueError( + f"unsupported runtime executable {executable_name!r}; expected one of: {supported}" + ) from error + + def spawn_helper_binary_target(header: bytes) -> str | None: if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": cpu_type = int.from_bytes(header[4:8], "little") @@ -158,7 +168,7 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ 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}") - expected_target = EXECUTABLE_TARGETS[executable_name] + expected_target = executable_target(executable_name) spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") if expected_target.startswith("macos-"): if not spawn_helper.is_file(): @@ -204,7 +214,7 @@ def verify_wheel( 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}") - expected_target = EXECUTABLE_TARGETS[platform[1]] + expected_target = executable_target(platform[1]) expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] found_helpers = [Path(helper).name for helper in helpers] From 9b9d3838baab000a865f420ccf3798395c3739e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:10:32 +0800 Subject: [PATCH 25/41] docs(example): update persistent-tools snapshot command --- examples/jsonrpc-agent/README.i18n.yaml | 4 ++-- examples/jsonrpc-agent/README.md | 4 ++-- examples/jsonrpc-agent/README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 1ff308b60f..bc0cd4facd 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/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 examples/jsonrpc-agent/README.md -README.md: 9a4c715e8988f52b647dbbd6b14a478cc1357d92 -README.zh.md: fa792f9cf6bdad4f32a980f478a131f19de97611 +README.md: 5e7b7d79415a4af0b0d16c61b0a4590dce57e545 +README.zh.md: 8f2cca582853d420a542606c8a0f3e534e9c9bec diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 9a4c715e89..5e7b7d7941 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -33,8 +33,8 @@ Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CON - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless behavior snapshot drives the shipped JSON-RPC runtime through both tools and proves that shell cwd/environment survive across calls: +It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless SDK snapshot drives the shipped JSON-RPC runtime through both tools, proves that shell cwd/environment survive across calls, and pins the notification stream, turn result, and persisted JSONL: ```bash -pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +pnpm exec vitest run --config vitest.snapshot.config.ts -t persistent-tools ``` diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index fa792f9cf6..8f2cca5828 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -33,8 +33,8 @@ - agent 独占、状态持久的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥行为快照会通过正式 JSON-RPC runtime 驱动这两个工具,并验证 shell 的 cwd 与环境变量能跨调用保留: +它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥 SDK 快照会通过正式 JSON-RPC runtime 驱动这两个工具,验证 shell 的 cwd 与环境变量能跨调用保留,并锁定通知流、轮次结果与已持久化的 JSONL: ```bash -pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +pnpm exec vitest run --config vitest.snapshot.config.ts -t persistent-tools ``` From 9549c73f30860b0a74553d63171ec98602f2329a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:15:39 +0800 Subject: [PATCH 26/41] fix(persistent-bash): distinguish shell exit status --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +-- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 2 +- .../notifications.expected.jsonl | 26 +++++++++++++------ .../snapshots/persistent-tools/session.jsonl | 26 +++++++++++++------ .../pty/tool-bash-persistent/README.i18n.yaml | 4 +-- packages/pty/tool-bash-persistent/README.md | 6 ++--- .../pty/tool-bash-persistent/README.zh.md | 6 ++--- .../pty/tool-bash-persistent/src/index.ts | 25 +++++++++++------- .../tool-bash-persistent/tests/tools.spec.ts | 21 ++++++++++++--- 11 files changed, 82 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 59f972d856..e50f6eacd3 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 006031248fbe74d72cbecb3dad88deb9f035e023 -2026-07-29-persistent-bash-str-replace-editor.zh.md: f134c3499648ef6c544bc251084136984c384bb2 +2026-07-29-persistent-bash-str-replace-editor.md: 2babbe75b791254722b5a26f1cbf09fc33acab50 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 3e40a610dacc2ba4fb87c01df5e377df0ba6b0bf diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 006031248f..2babbe75b7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -10,7 +10,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m ## Decision -`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. +`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. A nonzero wrapped command appends `[exit code: N]`; a shell that dies before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither. `maxOutputChars` bounds retained command output, while fixed diagnostics can extend the returned string. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. `@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute; file views preserve content tabs so copied text remains valid literal replacement input; mutations preserve tabs outside the requested edit; and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index f134c34996..3e40a610da 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -10,7 +10,7 @@ ## 决策 -`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 +`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。经封装的命令以非零状态结束时,会追加 `[exit code: N]`;若 shell 在报告该状态前终止,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`。`maxOutputChars` 限制保留的命令输出,而固定诊断可能使返回字符串更长。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 `@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径;文件查看会保留内容中的制表符,因此复制的文本仍可作为有效的字面量替换输入;变更会保留请求编辑范围之外的制表符;公开 schema 与错误则只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index a750ddc5e4..11c7615c48 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -82,7 +82,7 @@ const SCENARIOS: SdkScenario[] = [ }, { name: 'persistent-tools', - prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line.', + prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.', sessionId: 'persistent-tools-snapshot', children: 0, configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl index b1494b95ca..e69b5d95ee 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -1,5 +1,5 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} @@ -53,12 +53,22 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[51],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":61,"time":0,"data":{"turn":1,"step":6}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":62,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":61,"time":0,"data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":62,"time":0,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[61],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":64,"time":0,"data":{"turn":1,"step":7}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":70,"time":0,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":71,"time":0,"data":{"turn":1,"step":7}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":72,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl index b4094876ea..8a288888d5 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785331618313,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -54,11 +54,21 @@ {"type":"tool/result","seq":52,"time":1785331618803,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"ee874ae7-c4d9-4075-9b40-45e643a4b159"}},"sourceEventSeqs":[51],"surfaceOp":"append"} {"type":"step/end","seq":53,"time":1785331618803,"data":{"turn":1,"step":5}} {"type":"step/start","seq":54,"time":1785331618803,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":55,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":56,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} -{"type":"assistant/chunk","seq":57,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":55,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":56,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}} +{"type":"assistant/chunk","seq":57,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} {"type":"assistant/chunk","seq":58,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":59,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":60,"time":1785331618805,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"8e39f4fe-5538-46be-b24a-84296d638c44"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} -{"type":"step/end","seq":61,"time":1785331618805,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":62,"time":1785331618805,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":59,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":60,"time":1785331618805,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"8e39f4fe-5538-46be-b24a-84296d638c44"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"tool/call","seq":61,"time":1785331618805,"data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}} +{"type":"tool/result","seq":62,"time":1785331618806,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"cb4bf07d-474f-46de-a945-94666c849a5f"}},"sourceEventSeqs":[61],"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":1785331618806,"data":{"turn":1,"step":6}} +{"type":"step/start","seq":64,"time":1785331618806,"data":{"turn":1,"step":7}} +{"type":"assistant/chunk","seq":65,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":66,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} +{"type":"assistant/chunk","seq":67,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":68,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":69,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":70,"time":1785331618808,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"42e7f4c0-f936-4616-8af3-4f486f27fbb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"} +{"type":"step/end","seq":71,"time":1785331618808,"data":{"turn":1,"step":7}} +{"type":"turn/end","seq":72,"time":1785331618808,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/pty/tool-bash-persistent/README.i18n.yaml b/packages/pty/tool-bash-persistent/README.i18n.yaml index 2f15d109c1..16c3e69523 100644 --- a/packages/pty/tool-bash-persistent/README.i18n.yaml +++ b/packages/pty/tool-bash-persistent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/pty/tool-bash-persistent/README.md -README.md: 04c714d5489dbae8572e9339a4387a148450a0e9 -README.zh.md: adfb38b10409174d9558b963f5a2359cf819f04b +README.md: 3f5ffbe50bed4a6ab8f7f11ebad47bf933bba1e8 +README.zh.md: 94882534b3a2f8aedf382db0376a19540e2ea0d1 diff --git a/packages/pty/tool-bash-persistent/README.md b/packages/pty/tool-bash-persistent/README.md index 04c714d548..3f5ffbe50b 100644 --- a/packages/pty/tool-bash-persistent/README.md +++ b/packages/pty/tool-bash-persistent/README.md @@ -10,7 +10,7 @@ Model-facing `bash(command)` backed by one owner-scoped `ctx.pty` shell. The pac |---|---:|---| | `backendType` | `shell` | Registered PTY backend used for each Agent shell. | | `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | -| `maxOutputChars` | `16000` | Prefix characters retained before the clipping notice. | +| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. | | `description` | Persistent-shell description | Model-facing environment contract. | ## Model Experience @@ -33,11 +33,11 @@ Prefix-stable while the configured description and schema remain unchanged. #### What the model sees -Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and tells the model that the next call starts fresh. +Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. #### Token effect -Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice. +Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result. #### KV Cache effect diff --git a/packages/pty/tool-bash-persistent/README.zh.md b/packages/pty/tool-bash-persistent/README.zh.md index adfb38b104..94882534b3 100644 --- a/packages/pty/tool-bash-persistent/README.zh.md +++ b/packages/pty/tool-bash-persistent/README.zh.md @@ -10,7 +10,7 @@ |---|---:|---| | `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY 后端。 | | `timeoutMs` | `300000` | 单条命令的墙钟时间上限;超时会关闭 shell。 | -| `maxOutputChars` | `16000` | 截断提示前保留的前缀字符数。 | +| `maxOutputChars` | `16000` | 命令输出最多保留的字符数;固定诊断会在此后追加。 | | `description` | 持久 shell 描述 | 面向模型的环境契约。 | ## 模型体验 @@ -33,11 +33,11 @@ #### 模型所见 -每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并告知模型下次调用从新 shell 开始。 +每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell,并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并报告该重置。 #### Token 影响 -随数据变化,并受 `maxOutputChars` 与固定截断提示约束。 +随数据变化。`maxOutputChars` 限制保留的命令输出;固定的截断、前缀丢失、状态、超时与重置诊断可能使结果更长。 #### KV Cache 影响 diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 90cf1b4774..f2a5e5d0d3 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -174,21 +174,28 @@ function renderCaptured(output: CapturedOutput, maxOutputChars: number): string const withPrefix = output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered - return renderExitStatus(withPrefix, output.exitCode ?? 0, null) + const marker = output.exitCode !== undefined && output.exitCode !== 0 + ? `[exit code: ${output.exitCode}]` + : undefined + return appendStatusMarker(withPrefix, marker) } -function renderExitStatus( +function appendStatusMarker(content: string, marker: string | undefined): string { + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` +} + +function renderShellExitStatus( content: string, exitCode: number | null, signal: NodeJS.Signals | null, ): string { const marker = signal !== null - ? `[killed by signal: ${signal}]` - : exitCode !== null && exitCode !== 0 - ? `[exit code: ${exitCode}]` - : undefined - if (marker === undefined) return content - return content.length === 0 ? marker : `${content}\n${marker}` + ? `[shell killed by signal: ${signal}]` + : exitCode !== null + ? `[shell exited: code ${exitCode}]` + : '[shell exited]' + return appendStatusMarker(content, marker) } function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { @@ -325,7 +332,7 @@ async function executeCommand( const snapshot = retainedScrollback(ctx, owner, id, latest) await shells.reset(owner, 'persistent bash shell exited') return [ - renderExitStatus( + renderShellExitStatus( renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), result.sessionStatus.exitCode, result.sessionStatus.signal, diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index f3bd7bf40b..4b5401a256 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -79,6 +79,7 @@ type StubMode = | 'stalled-read' | 'exit' | 'signal-exit' + | 'unknown-exit' | 'wait-for-abort' | 'end-on-abort' | 'idle-then-normal' @@ -184,12 +185,14 @@ class StubPtySession implements PtyBackendSession { const exitCode = this.mode === 'nonzero' ? 7 : 0 const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` this.scrollback += output - if (this.mode === 'exit' || this.mode === 'signal-exit') { + if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') { const exitedOutput = `${start ?? ''}\nhello from stub\n` this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput this.statusValue = this.mode === 'signal-exit' ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } - : { kind: 'exited', exitCode: 9, signal: null } + : this.mode === 'exit' + ? { kind: 'exited', exitCode: 9, signal: null } + : { kind: 'exited', exitCode: null, signal: null } return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) } return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) @@ -339,7 +342,8 @@ describe('tool-bash-persistent', () => { session.mode = 'exit' const exited = text(await call(ctx, owner, 'exit')) expect(exited).toContain('hello from') - expect(exited).toContain('[exit code: 9]') + expect(exited).toContain('[shell exited: code 9]') + expect(exited).not.toContain('[exit code: 9]') expect(exited).toContain('next bash call starts from the workspace') expect(session.closed).toContain('persistent bash shell exited') @@ -347,7 +351,8 @@ describe('tool-bash-persistent', () => { expect(stub.sessions).toHaveLength(2) const replacement = stub.sessions[1]! replacement.mode = 'signal-exit' - expect(text(await call(ctx, owner, 'kill shell'))).toContain('[killed by signal: SIGTERM]') + expect(text(await call(ctx, owner, 'kill shell'))) + .toContain('[shell killed by signal: SIGTERM]') await call(ctx, owner, 'another shell') expect(stub.sessions).toHaveLength(3) @@ -367,6 +372,14 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') }) + it('reports a shell exit when the backend has no code or signal', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'unknown-exit' + + expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]') + }) + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') From a3caf4b2117225262c793b19bf12d470769d2ef3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:16:26 +0800 Subject: [PATCH 27/41] cleanup(persistent-bash): drop impossible marker branch --- packages/pty/tool-bash-persistent/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index f2a5e5d0d3..24cc998cf2 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -96,7 +96,6 @@ function commandOutput( ): CapturedOutput | undefined { const text = snapshot.text const end = text.lastIndexOf(marker.end) - if (end < 0) return undefined const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] if (status === undefined) return undefined const startMarker = text.lastIndexOf(marker.start, end) From 92052c1220390b6f78fa19ae809a9c7595d5c7b9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:18:55 +0800 Subject: [PATCH 28/41] docs(persistent-bash): define cancellation reset --- .../2026-07-29-persistent-bash-str-replace-editor.i18n.yaml | 4 ++-- .../feature/2026-07-29-persistent-bash-str-replace-editor.md | 2 +- .../2026-07-29-persistent-bash-str-replace-editor.zh.md | 2 +- packages/pty/tool-bash-persistent/README.i18n.yaml | 4 ++-- packages/pty/tool-bash-persistent/README.md | 2 +- packages/pty/tool-bash-persistent/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index e50f6eacd3..70909fe8f4 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 2babbe75b791254722b5a26f1cbf09fc33acab50 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 3e40a610dacc2ba4fb87c01df5e377df0ba6b0bf +2026-07-29-persistent-bash-str-replace-editor.md: ed9e772259b12f8d94abddd56cb36140099c1f9e +2026-07-29-persistent-bash-str-replace-editor.zh.md: 4a0348ef0ec9120e37799b69a39d49c4f8adce57 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 2babbe75b7..ed9e772259 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -10,7 +10,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m ## Decision -`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. A nonzero wrapped command appends `[exit code: N]`; a shell that dies before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither. `maxOutputChars` bounds retained command output, while fixed diagnostics can extend the returned string. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. +`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. A nonzero wrapped command appends `[exit code: N]`; a shell that dies before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither. `maxOutputChars` bounds retained command output, while fixed diagnostics can extend the returned string. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. Cancellation always resets and discards the result, even when a complete status marker is already observable, so state changes the model never saw cannot survive. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. `@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute; file views preserve content tabs so copied text remains valid literal replacement input; mutations preserve tabs outside the requested edit; and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 3e40a610da..4a0348ef0e 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -10,7 +10,7 @@ ## 决策 -`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。经封装的命令以非零状态结束时,会追加 `[exit code: N]`;若 shell 在报告该状态前终止,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`。`maxOutputChars` 限制保留的命令输出,而固定诊断可能使返回字符串更长。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 +`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。经封装的命令以非零状态结束时,会追加 `[exit code: N]`;若 shell 在报告该状态前终止,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`。`maxOutputChars` 限制保留的命令输出,而固定诊断可能使返回字符串更长。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。取消始终会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此,从而不会让模型未曾看到的状态变更得以保留。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 `@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径;文件查看会保留内容中的制表符,因此复制的文本仍可作为有效的字面量替换输入;变更会保留请求编辑范围之外的制表符;公开 schema 与错误则只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 diff --git a/packages/pty/tool-bash-persistent/README.i18n.yaml b/packages/pty/tool-bash-persistent/README.i18n.yaml index 16c3e69523..aec30c2b4a 100644 --- a/packages/pty/tool-bash-persistent/README.i18n.yaml +++ b/packages/pty/tool-bash-persistent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/pty/tool-bash-persistent/README.md -README.md: 3f5ffbe50bed4a6ab8f7f11ebad47bf933bba1e8 -README.zh.md: 94882534b3a2f8aedf382db0376a19540e2ea0d1 +README.md: cfb3acf41803f5b06ecb3f56ee8ffc29ce9147b1 +README.zh.md: a525485933344a8f9bc218456c099efdef572902 diff --git a/packages/pty/tool-bash-persistent/README.md b/packages/pty/tool-bash-persistent/README.md index 3f5ffbe50b..cfb3acf418 100644 --- a/packages/pty/tool-bash-persistent/README.md +++ b/packages/pty/tool-bash-persistent/README.md @@ -46,5 +46,5 @@ Append-only tool results follow the reusable request prefix. ## Known Limitations and Deferred Work - The tool requires an owning Agent and a real PTY backend. -- Explicit `exit`, timeout, or cancellation discards shell state; the next call starts a fresh shell. +- Explicit `exit` and timeout discard shell state. Cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell. - Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default. diff --git a/packages/pty/tool-bash-persistent/README.zh.md b/packages/pty/tool-bash-persistent/README.zh.md index 94882534b3..a525485933 100644 --- a/packages/pty/tool-bash-persistent/README.zh.md +++ b/packages/pty/tool-bash-persistent/README.zh.md @@ -46,5 +46,5 @@ ## 已知限制与延后工作 - 工具需要拥有它的 Agent 和真实 PTY 后端。 -- 显式 `exit`、超时或取消会丢弃 shell 状态;下次调用创建新 shell。 +- 显式 `exit` 与超时会丢弃 shell 状态。取消同样会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此;下次调用创建新 shell。 - 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。 From 91a104acef134ff944f40bfaee82baf41c9363dd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:20:44 +0800 Subject: [PATCH 29/41] docs(pty): explain spawn-helper override precedence --- .../2026-07-29-persistent-bash-str-replace-editor.i18n.yaml | 4 ++-- .../feature/2026-07-29-persistent-bash-str-replace-editor.md | 2 +- .../2026-07-29-persistent-bash-str-replace-editor.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 70909fe8f4..93776643da 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: ed9e772259b12f8d94abddd56cb36140099c1f9e -2026-07-29-persistent-bash-str-replace-editor.zh.md: 4a0348ef0ec9120e37799b69a39d49c4f8adce57 +2026-07-29-persistent-bash-str-replace-editor.md: aee566f2adee94cea88b2ab802d95e405ee8b26c +2026-07-29-persistent-bash-str-replace-editor.zh.md: 86a4b11ff18ec8c4ef276565f242f60270c4bfb0 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index ed9e772259..aee566f2ad 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch resolves the sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 4a0348ef0e..86a4b11ff1 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 ## 考虑过的替代方案 From 758dc73ed99d2611072202346f3f2c2db5ddcf2c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:24:59 +0800 Subject: [PATCH 30/41] docs: refresh generated config anchors --- docs/config-catalog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fcb2bdd2bd..4aca3810c8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:397`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:405`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1767,7 +1767,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:514`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:496`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` From 0e53b7a8aabc2565238a76c52570a7399739bc98 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:07:47 +0800 Subject: [PATCH 31/41] cleanup(build): drop helper architecture parsing --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 23 --------- python/sdk/tests/test_release_version.py | 51 +------------------ scripts/build-exe-for-python-sdk.ts | 24 +-------- scripts/build-python-release.py | 28 ---------- 10 files changed, 12 insertions(+), 130 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 93776643da..a16f07a63c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: aee566f2adee94cea88b2ab802d95e405ee8b26c -2026-07-29-persistent-bash-str-replace-editor.zh.md: 86a4b11ff18ec8c4ef276565f242f60270c4bfb0 +2026-07-29-persistent-bash-str-replace-editor.md: a97af750bdd80ddf38dc2d126e70c245ed035f35 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 0c2ab26693d90c91d5c41a128ebb77a3c6cc2e7f diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index aee566f2ad..a97af750bd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 86a4b11ff1..0c2ab26693 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 ## 考虑过的替代方案 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 129dac85d5..f39bfa8f13 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 python/sdk-runtime/README.md -README.md: efdb5cf9f87e0831ef09a47e6ffdb99254a17f36 -README.zh.md: cafac7418608c416a9f291d62442c32b8787b1fc +README.md: 07bb3c574b3cd49f1dc74f0e9d9bd1bb7ca9b216 +README.zh.md: 9eb03352505f7cad3e6bf6f253ed6564fdb06ca0 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index efdb5cf9f8..07bb3c574b 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ 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)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there, and its thin Mach-O header must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is 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-demo/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. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index cafac74186..9eb0335250 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件,其 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 9b54e0c5ed..cf56dd4138 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -16,26 +16,6 @@ _PLATFORMS = { _SPAWN_HELPER_SUFFIX = "-spawn-helper" -def _spawn_helper_binary_target(header: bytes) -> str | None: - if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - cpu_type = int.from_bytes(header[4:8], "little") - if cpu_type == 0x01000007: - return "macos-x64" - if cpu_type == 0x0100000C: - return "macos-arm64" - return None - - -def _validate_spawn_helper(path: Path, expected_target: str) -> None: - with path.open("rb") as helper: - actual_target = _spawn_helper_binary_target(helper.read(8)) - if actual_target != expected_target: - raise RuntimeError( - f"runtime spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'} at {path}" - ) - - 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 @@ -86,9 +66,6 @@ class RuntimeBuildHook(BuildHookInterface): for executable in [executables[0], *helpers]: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") - if helpers: - _validate_spawn_helper(helpers[0], expected_target) - build_data["pure_python"] = False build_data["infer_tag"] = False build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index ce54185ba5..b8fa5484c2 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -16,14 +16,6 @@ SCRIPT = ROOT / "scripts" / "build-python-release.py" build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) -def helper_header(target: str) -> bytes: - header = bytearray(8) - header[:4] = b"\xcf\xfa\xed\xfe" - cpu_type = 0x01000007 if target == "macos-x64" else 0x0100000C - header[4:8] = cpu_type.to_bytes(4, "little") - return bytes(header) - - def test_repository_version_matches_root_package_json() -> None: expected = json.loads((ROOT / "package.json").read_text())["version"] @@ -53,7 +45,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non executable.write_bytes(b"runtime") executable.chmod(0o755) spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(helper_header("macos-arm64")) + spawn_helper.write_bytes(b"helper") spawn_helper.chmod(0o751) destination = tmp_path / "staging" @@ -67,7 +59,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" assert (runtime_dir / executable.name).read_bytes() == b"runtime" copied_helper = runtime_dir / spawn_helper.name - assert copied_helper.read_bytes() == helper_header("macos-arm64") + assert copied_helper.read_bytes() == b"helper" assert copied_helper.stat().st_mode & stat.S_IXUSR @@ -120,42 +112,3 @@ def test_stage_runtime_copies_linux_executable_without_spawn_helper( runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = [path.name for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")] assert runtime_files == [executable.name] - - -@pytest.mark.parametrize("target", ["macos-x64", "macos-arm64"]) -def test_spawn_helper_binary_target(target: str) -> None: - assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target - - -def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(helper_header("macos-x64")) - spawn_helper.chmod(0o755) - - with pytest.raises(ValueError, match="expected macos-arm64, found macos-x64"): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) - - -def test_stage_runtime_rejects_non_binary_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(b"helper") - spawn_helper.chmod(0o755) - - with pytest.raises(ValueError, match="unsupported format or architecture"): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index b4b4d23720..e9ae59edbc 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -7,7 +7,7 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, statSync } from 'node:fs' import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -58,16 +58,6 @@ interface RuntimeProduct { spawnHelper?: string } -function spawnHelperBinaryTarget(path: string): string | undefined { - const header = readFileSync(path).subarray(0, 8) - if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) { - const cpuType = header.readUInt32LE(4) - if (cpuType === 0x01000007) return 'macos-x64' - if (cpuType === 0x0100000c) return 'macos-arm64' - } - return undefined -} - function runtimeProductFiles(product: RuntimeProduct): string[] { return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])] } @@ -393,17 +383,7 @@ class SingleExeBuild { + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, ) } - if (statSync(helper).mode & 0o111) { - const expected = `${target.platform}-${target.arch}` - const actual = spawnHelperBinaryTarget(helper) - if (actual !== expected) { - throw new Error( - `build-exe-for-python-sdk: node-pty spawn-helper binary mismatch: expected ${expected}, ` - + `found ${actual ?? 'unsupported format or architecture'} at ${helper}`, - ) - } - return helper - } + if (statSync(helper).mode & 0o111) return helper throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index dc53853b39..be5125cbcd 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -36,26 +36,6 @@ def executable_target(executable_name: str) -> str: ) from error -def spawn_helper_binary_target(header: bytes) -> str | None: - if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - cpu_type = int.from_bytes(header[4:8], "little") - if cpu_type == 0x01000007: - return "macos-x64" - if cpu_type == 0x0100000C: - return "macos-arm64" - return None - - -def validate_spawn_helper(path: Path, expected_target: str) -> None: - with path.open("rb") as helper: - actual_target = spawn_helper_binary_target(helper.read(8)) - if actual_target != expected_target: - raise ValueError( - f"runtime spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'} at {path}" - ) - - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--package", choices=("sdk", "runtime"), required=True) @@ -175,7 +155,6 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") - validate_spawn_helper(spawn_helper, expected_target) copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -228,13 +207,6 @@ def verify_wheel( mode = archive.getinfo(executable).external_attr >> 16 if mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") - if helpers: - actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:8]) - if actual_target != expected_target: - raise RuntimeError( - f"{wheel} spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'}" - ) elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 7118b4cfe136d3a92427ddb47315de332cae3456 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:09:54 +0800 Subject: [PATCH 32/41] cleanup(build): collapse native runtime payloads --- python/sdk-runtime/hatch_build.py | 25 +++----- python/sdk/tests/test_release_version.py | 30 +--------- scripts/build-exe-for-python-sdk.ts | 75 ++++++++---------------- scripts/build-python-release.py | 69 ++++++++-------------- 4 files changed, 60 insertions(+), 139 deletions(-) diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index cf56dd4138..19ec962257 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -39,31 +39,24 @@ class RuntimeBuildHook(BuildHookInterface): ) platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag() - matches = [(key, value) for key, value in _PLATFORMS.items() if value[0] == 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_target, (_, expected_executable) = matches[0] + expected_executable = matches[0][1] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) - executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] - helpers = [path for path in runtime_files if path.name.endswith(_SPAWN_HELPER_SUFFIX)] - if [path.name for path in executables] != [expected_executable]: - found = ", ".join(path.name for path in executables) or "none" + expected_files = [expected_executable] + if "-macos-" in expected_executable: + expected_files.append(f"{expected_executable}{_SPAWN_HELPER_SUFFIX}") + found_files = [path.name for path in runtime_files] + if found_files != expected_files: raise RuntimeError( - f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" + f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}" ) - expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}" - expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] - if [path.name for path in helpers] != expected_helpers: - expected = ", ".join(expected_helpers) or "none" - found = ", ".join(path.name for path in helpers) or "none" - raise RuntimeError( - f"runtime wheel {platform_tag} helper payload mismatch: expected {expected}; found {found}" - ) - for executable in [executables[0], *helpers]: + for executable in runtime_files: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index b8fa5484c2..68a9aac993 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -68,7 +68,7 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: executable.write_bytes(b"runtime") executable.chmod(0o755) - with pytest.raises(FileNotFoundError, match="spawn helper"): + with pytest.raises(FileNotFoundError, match="spawn-helper"): build_python_release.stage_runtime( tmp_path / "staging", "1.2.3", @@ -77,32 +77,8 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: ) -def test_stage_runtime_rejects_unsupported_executable_name(tmp_path: Path) -> None: - executable = tmp_path / "custom-runtime" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - - with pytest.raises( - ValueError, - match=( - "unsupported runtime executable 'custom-runtime'; expected one of: " - "dsh-jsonrpc-agent-pkg-linux-arm64, dsh-jsonrpc-agent-pkg-linux-x64, " - "dsh-jsonrpc-agent-pkg-macos-arm64" - ), - ): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) - - -@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64"]) -def test_stage_runtime_copies_linux_executable_without_spawn_helper( - tmp_path: Path, target: str -) -> None: - executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" +def test_stage_runtime_copies_linux_executable_without_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" executable.write_bytes(b"runtime") executable.chmod(0o755) destination = tmp_path / "staging" diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index e9ae59edbc..bbfa2402d0 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -53,15 +53,6 @@ const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] -interface RuntimeProduct { - executable: string - spawnHelper?: string -} - -function runtimeProductFiles(product: RuntimeProduct): string[] { - return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])] -} - function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -299,9 +290,9 @@ class SingleExeBuild { /** * Package one target; SEA mode accepts one target per invocation. * @param target - the pkg target triple to build. - * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. + * @returns the executable path and, on macOS, its helper path. */ - async pack(target: Target): Promise { + async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) await this.prepareNativePty(target) if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) @@ -318,7 +309,7 @@ class SingleExeBuild { 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}.`) } - if (target.platform !== 'macos') return { executable: product } + if (target.platform !== 'macos') return [product] const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) @@ -327,7 +318,7 @@ class SingleExeBuild { await copyFile(source, spawnHelper) await chmod(spawnHelper, statSync(source).mode & 0o777) } - return { executable: product, spawnHelper } + return [product, spawnHelper] } /** @@ -341,21 +332,19 @@ class SingleExeBuild { if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) - const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' - const prebuilt = join(stagedRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'pty.node') const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') const destination = join(stagedBuild, 'Release', 'pty.node') if (this.cli.dryRun) { if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return } - if (existsSync(prebuilt)) return + if (target.platform === 'macos') return const host = Target.host() if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) { throw new Error( `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; ` - + `checked ${prebuilt}, ${source}. Build the Linux runtime on its target architecture.`, + + `checked ${source}. Build the Linux runtime on its target architecture.`, ) } await mkdir(dirname(destination), { recursive: true }) @@ -368,19 +357,11 @@ class SingleExeBuild { * @returns a physical executable outside pkg's virtual snapshot. */ private resolveSpawnHelper(target: Target): string { - const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty') - const candidates = [ - join(nodePtyRoot, 'prebuilds', `darwin-${target.arch}`, 'spawn-helper'), - ] - const host = Target.host() - if (target.platform === host.platform && target.arch === host.arch) { - candidates.push(join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'spawn-helper')) - } - const helper = candidates.find(candidate => existsSync(candidate)) - if (helper === undefined) { + const helper = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') + if (!existsSync(helper)) { throw new Error( `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; ` - + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, + + `checked ${helper}. Build each runtime on its target platform and architecture.`, ) } if (statSync(helper).mode & 0o111) return helper @@ -391,43 +372,37 @@ class SingleExeBuild { * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. */ - printProducts(products: RuntimeProduct[]): void { + 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) { + for (const path of products) { if (this.cli.dryRun) { - for (const path of runtimeProductFiles(product)) console.log(` ${path}`) + console.log(` ${path}`) continue } - for (const path of runtimeProductFiles(product)) { - const megabytes = statSync(path).size / (1024 * 1024) - console.log(` ${path} (${megabytes.toFixed(1)} MB)`) - } + const megabytes = statSync(path).size / (1024 * 1024) + console.log(` ${path} (${megabytes.toFixed(1)} MB)`) } } /** - * Copy each executable into the Python runtime package. The deployed node + * Copy each product into the Python runtime package. The deployed node * carrier is already in place, and `dist-exe/` retains upload copies. * @param products - the product paths returned by {@link pack}. */ - async syncToPythonRuntime(products: RuntimeProduct[]): Promise { + async syncToPythonRuntime(products: string[]): Promise { const destDir = resolve(root, PYTHON_RUNTIME_DIR) if (this.cli.dryRun) { - for (const product of products) { - for (const path of runtimeProductFiles(product)) { - console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) - } + for (const path of products) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) } return } mkdirSync(destDir, { recursive: true }) - for (const product of products) { - for (const path of runtimeProductFiles(product)) { - const destination = join(destDir, basename(path)) - await copyFile(path, destination) - await chmod(destination, statSync(path).mode & 0o777) - console.log(`build-exe-for-python-sdk: synced ${destination}`) - } + for (const path of products) { + const destination = join(destDir, basename(path)) + await copyFile(path, destination) + await chmod(destination, statSync(path).mode & 0o777) + console.log(`build-exe-for-python-sdk: synced ${destination}`) } } @@ -476,8 +451,8 @@ async function main(): Promise { await pipeline.build() await pipeline.deployStaging() await pipeline.injectPkgConfig() - const products: RuntimeProduct[] = [] - for (const target of cli.targets) products.push(await pipeline.pack(target)) + const products: string[] = [] + for (const target of cli.targets) products.push(...await pipeline.pack(target)) pipeline.printProducts(products) await pipeline.syncToPythonRuntime(products) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index be5125cbcd..4fe7d62980 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -23,17 +23,6 @@ PLATFORMS = { "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } SPAWN_HELPER_SUFFIX = "-spawn-helper" -EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} - - -def executable_target(executable_name: str) -> str: - try: - return EXECUTABLE_TARGETS[executable_name] - except KeyError as error: - supported = ", ".join(sorted(EXECUTABLE_TARGETS)) - raise ValueError( - f"unsupported runtime executable {executable_name!r}; expected one of: {supported}" - ) from error def main() -> None: @@ -144,28 +133,24 @@ def stage_sdk(destination: Path, version: str) -> None: 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}") - expected_target = executable_target(executable_name) - spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") - if expected_target.startswith("macos-"): - if not spawn_helper.is_file(): - raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") - if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") + payload = [(executable, executable_name)] + if "-macos-" in executable_name: + payload.append( + (Path(f"{executable}{SPAWN_HELPER_SUFFIX}"), f"{executable_name}{SPAWN_HELPER_SUFFIX}") + ) + for source, _ in payload: + if not source.is_file(): + raise FileNotFoundError(f"runtime file does not exist: {source}") + if source.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime file is not executable: {source}") 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) - if expected_target.startswith("macos-"): - destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" - shutil.copyfile(spawn_helper, destination_helper) - destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) + for source, name in payload: + target = runtime_dir / name + shutil.copyfile(source, target) + target.chmod(source.stat().st_mode & 0o777) def verify_wheel( @@ -187,26 +172,18 @@ def verify_wheel( runtime_files = [ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name ] - helpers = [name for name in runtime_files if name.endswith(SPAWN_HELPER_SUFFIX)] - executables = [name for name in runtime_files if not name.endswith(SPAWN_HELPER_SUFFIX)] 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}") - expected_target = executable_target(platform[1]) - expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" - expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] - found_helpers = [Path(helper).name for helper in helpers] - if found_helpers != expected_helpers: - expected = ", ".join(expected_helpers) or "none" - found = ", ".join(found_helpers) or "none" - raise RuntimeError( - f"{wheel} runtime helper payload mismatch: expected {expected}; found {found}" - ) - for executable in [executables[0], *helpers]: - mode = archive.getinfo(executable).external_attr >> 16 + expected_files = [platform[1]] + if "-macos-" in platform[1]: + expected_files.append(f"{platform[1]}{SPAWN_HELPER_SUFFIX}") + found_files = sorted(Path(name).name for name in runtime_files) + if found_files != expected_files: + raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") + for runtime_file in runtime_files: + mode = archive.getinfo(runtime_file).external_attr >> 16 if mode & stat.S_IXUSR == 0: - raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") + raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}") elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 8a9d882a11930a1348f039a5e8f783eecb6616fa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:29:18 +0800 Subject: [PATCH 33/41] cleanup(build): minimize native payload handling --- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 3 +- .../src/deepseek_harness_runtime/__init__.py | 4 +- python/sdk/tests/test_release_version.py | 54 +++++-------------- python/sdk/tests/test_runtime_resolution.py | 25 ++++----- scripts/build-exe-for-python-sdk.ts | 47 +++++----------- scripts/build-python-release.py | 25 +++------ 9 files changed, 47 insertions(+), 119 deletions(-) diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index f0d6c67967..59f3e836cf 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 python/README.md -README.md: aee682e25fc33287c49131d0f5b92b136ed16bae -README.zh.md: 4404114fcdab78468991769a4657370f85997a88 +README.md: dfd9d909122f9245a19fe91d8a394156795b13ae +README.zh.md: 151b2cdab4f28384a20d16c86d398f669fbdc818 diff --git a/python/README.md b/python/README.md index aee682e25f..dfd9d90912 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/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` plus the matching `-spawn-helper` required by `node-pty` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform 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). +Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`); macOS builds also sync the matching `-spawn-helper` required by `node-pty`. After a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform 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 4404114fcd..151b2cdab4 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/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` 及 `node-pty` 所需的同名 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 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`);macOS 构建还会同步 `node-pty` 所需的同名 `-spawn-helper` 伴随文件。本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 ## 用可执行文件验证 SDK diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 19ec962257..b0f9a79550 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -13,7 +13,6 @@ _PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } -_SPAWN_HELPER_SUFFIX = "-spawn-helper" def _host_platform_tag() -> str: @@ -50,7 +49,7 @@ class RuntimeBuildHook(BuildHookInterface): runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) expected_files = [expected_executable] if "-macos-" in expected_executable: - expected_files.append(f"{expected_executable}{_SPAWN_HELPER_SUFFIX}") + expected_files.append(f"{expected_executable}-spawn-helper") found_files = [path.name for path in runtime_files] if found_files != expected_files: raise RuntimeError( diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index d6f8c497b6..a3aa53ae80 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -28,7 +28,6 @@ import sys from pathlib import Path PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" -SPAWN_HELPER_SUFFIX = "-spawn-helper" RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" @@ -85,7 +84,7 @@ def bundled_runtime_path() -> Path: + _EXE_ACQUISITION_HINT ) if tag.startswith("macos-"): - helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") + helper = Path(f"{path}-spawn-helper") if not helper.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " @@ -153,7 +152,6 @@ def _node_launch_args() -> tuple[str, str]: __all__ = [ "PACKAGE_METADATA_FILENAME", "RUNTIME_MODE_ENV_VAR", - "SPAWN_HELPER_SUFFIX", "bundled_default_config_path", "bundled_package_dir", "bundled_runtime_path", diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 68a9aac993..7e5f660070 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -4,7 +4,6 @@ from __future__ import annotations import json import runpy -import stat from pathlib import Path from types import SimpleNamespace @@ -40,51 +39,22 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: build_python_release.repository_version(tmp_path) -def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(b"helper") - spawn_helper.chmod(0o751) - destination = tmp_path / "staging" - - build_python_release.stage_runtime( - destination, - "1.2.3", - executable, - executable.name, - ) - - runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" - assert (runtime_dir / executable.name).read_bytes() == b"runtime" - copied_helper = runtime_dir / spawn_helper.name - assert copied_helper.read_bytes() == b"helper" - assert copied_helper.stat().st_mode & stat.S_IXUSR - - -def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - - with pytest.raises(FileNotFoundError, match="spawn-helper"): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) - - -def test_stage_runtime_copies_linux_executable_without_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" +@pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) +def test_stage_runtime_copies_platform_payload( + tmp_path: Path, target: str, with_helper: bool +) -> None: + executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" executable.write_bytes(b"runtime") executable.chmod(0o755) + expected = {executable.name: b"runtime"} + if with_helper: + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(b"helper") + spawn_helper.chmod(0o755) + expected[spawn_helper.name] = b"helper" destination = tmp_path / "staging" build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" - runtime_files = [path.name for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")] - assert runtime_files == [executable.name] + assert {path.name: path.read_bytes() for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")} == expected diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index e0411bb1fd..778203f4d1 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -44,25 +44,18 @@ def test_explicit_mode_wins_over_env_mode(monkeypatch: pytest.MonkeyPatch) -> No assert args[0].endswith(("-x64", "-arm64")) -@pytest.mark.parametrize( - ("platform_tag", "requires_helper"), - [("linux-x64", False), ("macos-arm64", True)], -) def test_runtime_requires_spawn_helper_only_on_macos( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - platform_tag: str, - requires_helper: bool, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: runtime_dir = tmp_path / "runtime" runtime_dir.mkdir() - executable = runtime_dir / f"dsh-jsonrpc-agent-pkg-{platform_tag}" - executable.touch() + linux = runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64" + linux.touch() + (runtime_dir / "dsh-jsonrpc-agent-pkg-macos-arm64").touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) - monkeypatch.setattr(runtime, "_current_platform_tag", lambda: platform_tag) - if requires_helper: - with pytest.raises(FileNotFoundError, match="node-pty spawn helper"): - runtime.bundled_runtime_path() - else: - assert runtime.bundled_runtime_path() == executable + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "macos-arm64") + with pytest.raises(FileNotFoundError, match="node-pty spawn helper"): + runtime.bundled_runtime_path() + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") + assert runtime.bundled_runtime_path() == linux diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index bbfa2402d0..d2cdbeec4d 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -7,7 +7,7 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, statSync } from 'node:fs' +import { existsSync, statSync } from 'node:fs' import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -19,7 +19,6 @@ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' /** The app entry inside the deployed closure. */ const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' -const SPAWN_HELPER_SUFFIX = '-spawn-helper' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' /** Pinned for reproducible builds. */ @@ -295,7 +294,7 @@ class SingleExeBuild { async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) await this.prepareNativePty(target) - if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) + if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true }) await this.run(`pkg ${target.spec}`, pnpmBin(), [ 'dlx', PKG_SPEC, @@ -310,13 +309,13 @@ class SingleExeBuild { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } if (target.platform !== 'macos') return [product] - const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` + const spawnHelper = `${product}-spawn-helper` + const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') if (this.cli.dryRun) { - console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) + console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${spawnHelper}`) } else { - const source = this.resolveSpawnHelper(target) await copyFile(source, spawnHelper) - await chmod(spawnHelper, statSync(source).mode & 0o777) + await chmod(spawnHelper, 0o755) } return [product, spawnHelper] } @@ -327,47 +326,27 @@ class SingleExeBuild { * @param target - the pkg target whose native addon is being staged. */ private async prepareNativePty(target: Target): Promise { - const stagedRoot = join(this.staging, 'node_modules', 'node-pty') - const stagedBuild = join(stagedRoot, 'build') + const stagedBuild = join(this.staging, 'node_modules', 'node-pty', 'build') if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) - + if (target.platform !== 'linux') return const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') const destination = join(stagedBuild, 'Release', 'pty.node') if (this.cli.dryRun) { - if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) + console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return } - if (target.platform === 'macos') return - const host = Target.host() - if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) { + if (target.platform !== host.platform || target.arch !== host.arch) { throw new Error( - `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; ` - + `checked ${source}. Build the Linux runtime on its target architecture.`, + 'build-exe-for-python-sdk: build the Linux runtime on its target architecture; ' + + `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`, ) } await mkdir(dirname(destination), { recursive: true }) await copyFile(source, destination) } - /** - * Resolve the node-pty helper that matches a pkg target. - * @param target - the pkg target whose helper must be shipped. - * @returns a physical executable outside pkg's virtual snapshot. - */ - private resolveSpawnHelper(target: Target): string { - const helper = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') - if (!existsSync(helper)) { - throw new Error( - `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; ` - + `checked ${helper}. Build each runtime on its target platform and architecture.`, - ) - } - if (statSync(helper).mode & 0o111) return helper - throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) - } - /** * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. @@ -397,7 +376,7 @@ class SingleExeBuild { } return } - mkdirSync(destDir, { recursive: true }) + await mkdir(destDir, { recursive: true }) for (const path of products) { const destination = join(destDir, basename(path)) await copyFile(path, destination) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 4fe7d62980..ec049cdd4f 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -22,7 +22,10 @@ PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } -SPAWN_HELPER_SUFFIX = "-spawn-helper" + + +def runtime_suffixes(executable_name: str) -> tuple[str, ...]: + return ("", "-spawn-helper") if "-macos-" in executable_name else ("",) def main() -> None: @@ -133,24 +136,12 @@ def stage_sdk(destination: Path, version: str) -> None: def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: - payload = [(executable, executable_name)] - if "-macos-" in executable_name: - payload.append( - (Path(f"{executable}{SPAWN_HELPER_SUFFIX}"), f"{executable_name}{SPAWN_HELPER_SUFFIX}") - ) - for source, _ in payload: - if not source.is_file(): - raise FileNotFoundError(f"runtime file does not exist: {source}") - if source.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime file is not executable: {source}") 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) - for source, name in payload: - target = runtime_dir / name - shutil.copyfile(source, target) - target.chmod(source.stat().st_mode & 0o777) + for suffix in runtime_suffixes(executable_name): + shutil.copy2(Path(f"{executable}{suffix}"), runtime_dir / f"{executable_name}{suffix}") def verify_wheel( @@ -174,9 +165,7 @@ def verify_wheel( ] if package == "runtime": assert platform is not None - expected_files = [platform[1]] - if "-macos-" in platform[1]: - expected_files.append(f"{platform[1]}{SPAWN_HELPER_SUFFIX}") + expected_files = [f"{platform[1]}{suffix}" for suffix in runtime_suffixes(platform[1])] found_files = sorted(Path(name).name for name in runtime_files) if found_files != expected_files: raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") From b3411556524aadb23391f174e228f86606503652 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 11:38:51 +0800 Subject: [PATCH 34/41] feat(session): project the inherited-history boundary into the log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin owning a standalone open/close bracket cannot tell a dead marker from a live one: an unmatched `compact/start` reads identically whether the previous writer died mid-compaction or a compaction is running now. `Session.firstLiveSeq` already holds that answer exactly, but only in memory. Append the log-only `session/inherited` event at that seq from the seeded constructor — the single waist all six seeded-start paths pass through (resume, configured startup on a persisted id, `sessions.fork()`, a subagent fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it through the new `isInheritedSeq(events, seq)`. The constructor placement means persistence needs no changes: the marker is already in `events` when a backend captures the creation seed, so it rides the ordinary seed path with no load-time write. It also covers fork, where the inherited bracket's owner may still be running — the case a persistence-layer boundary could not reach. Activity ordering excludes the boundary through `lastActivityTime()`, since lazy resume makes browsing a pickup and the three call sites would otherwise float every opened session to the top of a picker or list. --- ...0-session-inherited-log-boundary.i18n.yaml | 6 ++ ...26-07-30-session-inherited-log-boundary.md | 53 +++++++++ ...07-30-session-inherited-log-boundary.zh.md | 53 +++++++++ ...7-29-durable-last-activity-index.i18n.yaml | 6 ++ .../2026-07-29-durable-last-activity-index.md | 66 ++++++++++++ ...26-07-29-durable-last-activity-index.zh.md | 66 ++++++++++++ ...-29-session-resumed-log-boundary.i18n.yaml | 6 ++ ...2026-07-29-session-resumed-log-boundary.md | 51 +++++++++ ...6-07-29-session-resumed-log-boundary.zh.md | 51 +++++++++ docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 36 ++++++- docs/core-data-structures/session.zh.md | 36 ++++++- docs/persistence-catalog.md | 29 ++++- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../snapshots/subagent-fork/session.1.jsonl | 35 +++--- .../snapshots/subagent-mixed/session.2.jsonl | 35 +++--- .../session.expected.jsonl | 25 ++--- .../parent-override/parent.expected.jsonl | 47 ++++---- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 5 +- packages/core/agent-loop/tests/resume.spec.ts | 8 +- packages/core/session/src/index.ts | 17 ++- packages/core/session/src/repair.ts | 47 +++++++- packages/core/session/src/types.ts | 20 ++++ packages/core/session/tests/fork.spec.ts | 18 +++- .../core/session/tests/properties.spec.ts | 14 ++- packages/core/session/tests/repair.spec.ts | 101 +++++++++++++++++- packages/core/session/tests/session.spec.ts | 19 ++-- packages/host/apiproxy/src/api-proxy.ts | 5 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 37 +++++++ .../llm/token-meter/tests/token-meter.spec.ts | 11 +- .../tests/jsonl.spec.ts | 4 +- .../tests/coordinator-contract.ts | 23 ++-- .../session-query-sqlite/tests/sqlite.spec.ts | 3 +- .../tests/service-contracts.spec.ts | 2 + .../tests/inheritance.spec.ts | 3 +- .../session-telemetry/tests/telemetry.spec.ts | 12 ++- packages/ui/tui/src/components/dialogs.ts | 4 +- packages/ui/tui/src/index.ts | 3 +- packages/ui/tui/tests/tui.snapshot.ts | 3 + 41 files changed, 850 insertions(+), 122 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md create mode 100644 .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md create mode 100644 .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md create mode 100644 .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md create mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml create mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md create mode 100644 .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml new file mode 100644 index 0000000000..99695985b8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md +2026-07-30-session-inherited-log-boundary.md: 027281a7555214122b4e2d1f8457d3554a751921 +2026-07-30-session-inherited-log-boundary.zh.md: 3c169e16f0b409f125ccc29226412f239a58c6aa diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md new file mode 100644 index 0000000000..027281a755 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md @@ -0,0 +1,53 @@ +# Agent Note: the inherited-history log boundary + +Status: implemented + +English | [中文](2026-07-30-session-inherited-log-boundary.zh.md) + +## Problem + +A plugin that owns a standalone open/close bracket in the session log cannot tell a dead marker from a live one. `compact/start` … `compact/end` is the shipped case: on picking up a log whose last compaction event is an unmatched `compact/start`, "the previous writer died mid-compaction" and "a compaction is running right now" are byte-identical stored history. The owner must either refuse to compact a log that is actually free (wedging the session) or proceed over one that is genuinely busy. + +Nothing in the log marked where inherited history ended. `session/created`, `session/disposed`, and `session/flush` are cordis runtime signals, not log events; `agent/session-start` is emit-only. `Session.firstLiveSeq` already held the answer exactly — the seq of this lifecycle's first own write — but only in memory, so a consumer reading stored bytes could not see it. + +Crash repair does not close the gap and must not: `interruptedTurnClosers` synthesizes turn, step, and tool boundaries because core owns that vocabulary, and `compact/*` belongs to the compaction seam. A core repair pass that closed plugin brackets would put every plugin's bracket semantics in core. + +## Decision + +`Session`'s constructor appends the log-only `session/inherited` event as the seeded session's first live write, at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, the event answers it for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history. + +`isInheritedSeq(events, seq)` (exported from `dsh-session`) is the predicate a bracket owner calls on an unmatched opening marker. True means the marker belongs to a lifecycle that has ended and cannot still be running. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin. + +The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case the predicate must classify. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating. + +Two guards keep the marker from becoming noise. An empty seed writes nothing: a boundary with nothing below it marks nothing. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit. + +## Persistence needs no changes + +The marker is in `session.events` before the coordinator captures its creation seed, so it persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. No load-path write, no revision bump at load, no durable mark on a rejected `append`, and a read-only store still serves loads. + +Being a live event, it reaches disk through the write-behind drain (`session/event` → `live.pending` → `scheduleDrain`) rather than a synchronous commit, so a crash can lose it. That costs nothing: `pending` drains in order, so a lost boundary means every live event above it is lost too, and the next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write. + +## Scope of the guarantee + +The predicate holds for a bracket *this* session inherited, not as a liveness signal about other writers. A concurrently live session may hold an open bracket over the same stored history while its own boundary sits elsewhere. A consumer that must tolerate concurrent writers needs a liveness signal beyond the log and cannot omit it on the strength of this event. + +## Alternatives considered + +**A boundary written by the persistence coordinator's cold-load path.** Built first, as the [`session/resumed` boundary](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md), and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store. + +**A boundary appended at loop start.** The loop is one call above `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary. + +**Reusing `header.seedLength`.** It is the durable *fork-lineage* boundary and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both. + +**Crash repair closing `compact/*` alongside turn boundaries.** Rejected: it moves every plugin's bracket semantics into core's repair pass, and core cannot know what closing another package's bracket should record. + +## Consequences + +Bought: one predicate, in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary. + +Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property. + +`session/inherited` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly reports nothing as inherited. + +Not built here: no plugin consumes `isInheritedSeq` yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary, and it belongs with that seam's own tests. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md new file mode 100644 index 0000000000..3c169e16f0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 继承历史日志边界 + +Status: implemented + +[English](2026-07-30-session-inherited-log-boundary.md) | 中文 + +## Problem + +拥有独立开/闭括号的插件无法区分一个已死的标记和一个存活的标记。`compact/start` … `compact/end` 就是已发布的实例:当接手一份日志、而它最后的压缩事件是一个未配对的 `compact/start` 时,"上一个写入方在压缩中途死掉了"与"此刻正有一次压缩在运行"在存储历史中是逐字节相同的。所有方只能二选一:拒绝压缩一份其实空闲的日志(把会话卡死),或者在一份确实繁忙的日志上继续压缩。 + +日志中没有任何东西标出继承历史在哪里结束。`session/created`、`session/disposed` 与 `session/flush` 是 cordis 运行时信号,不是日志事件;`agent/session-start` 只发射不落盘。`Session.firstLiveSeq` 本来就精确地持有这个答案——本生命周期第一次自有写入的 seq——但只存在于内存中,因此读取存储字节的消费方看不到它。 + +崩溃修复既没有填上这个缺口,也不应该去填:`interruptedTurnClosers` 合成轮次、步骤与工具边界,是因为核心拥有那套词汇表,而 `compact/*` 属于压缩 seam。一个会关闭插件括号的核心修复流程,等于把每个插件的括号语义都搬进核心。 + +## Decision + +`Session` 的构造函数把仅日志事件 `session/inherited` 作为带种子会话的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",该事件则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。 + +`isInheritedSeq(events, seq)`(由 `dsh-session` 导出)是括号所有方在一个未配对开启标记上调用的谓词。为真意味着该标记属于一个已结束的生命周期,不可能仍在运行。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件。 + +选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是该谓词必须判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。 + +两条守卫让这个标记不至于变成噪声。空种子不写入任何内容:下方什么都没有的边界标记不了任何东西。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。 + +## 持久化无需任何改动 + +协调器捕获创建种子时该标记已在 `session.events` 中,因此它通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。没有加载路径写入、加载时没有 revision 递增、被拒绝的 `append` 不留下持久标记,只读存储依然可以服务加载。 + +作为实时事件,它经由后写式 drain(`session/event` → `live.pending` → `scheduleDrain`)而不是同步提交到达磁盘,因此崩溃可能丢掉它。这没有代价:`pending` 按序 drain,所以丢掉一个边界意味着它上面的每个实时事件也一起丢掉,而下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。 + +## 保证的适用范围 + +该谓词对*本*会话继承的括号成立,而不是关于其他写入方的存活信号。一个并发存活的会话可能在同一段存储历史上持有开放括号,而它自己的边界在别处。必须容忍并发写入方的消费方需要日志之外的存活信号,不能仅凭这个事件就省掉它。 + +## Alternatives considered + +**由持久化协调器的冷加载路径写入边界。** 最先实现的方案,即 [`session/resumed` 边界](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md),在合并前被放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。 + +**在 loop 启动时追加边界。** loop 位于 `resumeWith` 上一层,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。 + +**复用 `header.seedLength`。** 它是持久的 *fork 血缘*边界,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。 + +**让崩溃修复连同轮次边界一起关闭 `compact/*`。** 否决:这会把每个插件的括号语义搬进核心的修复流程,而核心无法知道关闭另一个包的括号应该记录什么。 + +## Consequences + +买到的:一个谓词,位于一处,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。 + +代价:带种子会话的日志长了一个事件,这在九个包(session、agent-loop、持久化契约、jsonl、session-query、session-title、subagent-inprocess、telemetry、token-meter)里挪动了 seq 期望。其中两处更新是承重的而非机械的:telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;而属性测试套件的重放不变式被重述为"种子逐字节复现,外加一个仅日志边界",并把幂等性补成一条独立属性。 + +`session/inherited` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地报告没有任何内容被继承。 + +此处未做:还没有任何插件消费 `isInheritedSeq`。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作,应当与那个 seam 自己的测试一起完成。 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml new file mode 100644 index 0000000000..643238c384 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md +2026-07-29-durable-last-activity-index.md: 7d8a83e96d9339054b0d15b2331284c8aecfe65d +2026-07-29-durable-last-activity-index.zh.md: b081aa91cfa51ef8bb87e93f4091431f2d6ddce1 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md new file mode 100644 index 0000000000..7d8a83e96d --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md @@ -0,0 +1,66 @@ +# Agent Note: Record last activity in the session index + +Status: proposed + +English | [中文](2026-07-29-durable-last-activity-index.zh.md) + +## Problem + +A cold (persisted, unattached) session has no stored answer to "when was this last worked in". `dsh-host-apiproxy`'s `summarizeCold()` therefore approximates it with the log file's mtime where one exists — `locate()` resolves a per-session artifact for JSONL and `undefined` for SQLite, whose cold sessions fall back to `createdAt` — and the web client sorts its session tree by the resulting `updatedAt`. The two backends are wrong in opposite directions: JSONL reads too new, SQLite too old. + +mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/inherited` boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough. + +The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size. + +The boundary change raised the frequency of this defect and documented it as a known limitation on `dsh-host-apiproxy`. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there. + +## Proposal + +Store last-activity time where a listing already reads — the session index — so `summarizeCold()` can serve it without opening the log. The coordinator computes the value, because it sees every append and already owns per-id state; backends persist it. That makes it a new `PersistenceBackend` contract element rather than backend-local bookkeeping, and keeps one definition of "activity" shared with the in-log `lastActivityTime()`. + +The two shipped backends have opposite constraints, and the proposal is deliberately asymmetric about them: + +- **SQLite** gets a column on `sessions`, written in the same transaction as `appendBatch`, at the cost of a monotonic `SCHEMA_VERSION` bump. +- **JSONL cannot host a mutable header field.** The header is line 1, written once during materialization, and the log is opened for append forever after; `jsonl.spec.ts` pins that committed bytes are never rewritten. A per-append header field would violate an asserted durability invariant, not merely complicate the writer. A per-session sidecar file is the shape to compare against leaving JSONL approximate. + +Three questions must be answered before implementation, and none of them is settled here: + +**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/inherited`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session. + +**How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at today's accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree. + +**Is a sidecar acceptable for JSONL?** It reintroduces a second file per session that can disagree with the log, which the single-artifact design avoided. + +## Alternatives considered + +**Read the log on the cold path.** Correct by construction and needs no format change, but it defeats the header-only listing: `list()` would scale with total log size, and the web session tree fans out over every session in the store. This is the option the mtime approximation exists to avoid. + +**Keep mtime and exclude boundary writes from it.** Rejected as impossible rather than undesirable: mtime is the filesystem's, not the backend's. Nothing short of restoring the timestamp after every boundary write would preserve it, and that races any concurrent reader and lies about the artifact. + +**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction. + +**Derive activity from a projection cache.** `session-projection-cache` already folds tails past a watermark, so a last-activity unit would ride existing machinery. Rejected as the primary shape because the cache is an optional composition entry; a listing served only when a cache plugin is mounted makes ordering depend on composition. + +## Acceptance criteria + +- `SessionSummary.updatedAt` for a cold session equals the same value the attached projection reports for that session, verified by resuming, quitting without a turn, and asserting the order is unchanged across both paths. +- A resumed-then-abandoned session does not sort above a session worked in afterwards, in the web session tree and the TUI resume picker, pinned by an assembled snapshot rather than unit tests alone. +- The activity rule has one definition: a test proves the stored field and `lastActivityTime()` agree over a log containing boundaries, closers, and a plain turn. +- Pre-field artifacts load and list without error under the chosen fallback, with the fallback's ordering consequence asserted. +- SQLite's `SCHEMA_VERSION` bump rejects the old on-disk version per the repo's no-migration stance. + +## Risks + +**Two definitions of activity drift.** The stored field is computed per batch, the projection over a whole log. A new event type classified one way at write time and the other at read time yields a session whose cold and attached orderings disagree — a bug that only appears after a restart, which is where it is hardest to notice. + +**A JSONL sidecar can disagree with its log.** A crash between the log append and the sidecar write leaves a stale value with no torn-tail marker to repair it. Every consumer would need to treat the sidecar as a hint, which is close to what mtime already is. + +**The fallback reorders existing sessions.** Whichever fallback is chosen, users with existing logs see their picker and tree reorder once on upgrade. `createdAt` makes that reordering large. + +**Cost may exceed the defect.** The defect is a misordering of abandoned sessions. If the honest answer for JSONL is "keep the approximation", this note's outcome may be documenting that decision rather than implementing a field — and that is an acceptable outcome. + +## Related + +- [The inherited-history log boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with. +- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field. +- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into. diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md new file mode 100644 index 0000000000..b081aa91cf --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md @@ -0,0 +1,66 @@ +# Agent Note: 在会话索引中记录最后活动 + +Status: proposed + +[English](2026-07-29-durable-last-activity-index.md) | 中文 + +## 问题 + +一个冷会话(已持久化、未附加)对「上次是什么时候在这里面工作过」没有任何已存储的答案。因此 `dsh-host-apiproxy` 的 `summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物,为 SQLite 解析出 `undefined`,而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反:JSONL 读出来偏新,SQLite 偏旧。 + +mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/inherited` 边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy` 的 `agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了。 + +已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。 + +边界那次变更提高了这个缺陷的出现频率,并把它作为一项已知限制记录在 `dsh-host-apiproxy` 上。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。 + +## 提案 + +把最后活动时间存到列举本就会读取的地方,也就是会话索引,这样 `summarizeCold()` 无需打开日志就能给出答案。该值由协调器计算,因为它看得到每一次追加,而且本就拥有每 id 状态;由后端负责持久化。这样它就成为 `PersistenceBackend` 契约中新增的一个要素,而不是各后端本地的账目,同时让「活动」只保留一个定义,与日志内的 `lastActivityTime()` 共用。 + +两个已交付的后端受到的约束正好相反,本提案对它们有意采取不对称的处理: + +- **SQLite** 在 `sessions` 表上得到一列,与 `appendBatch` 在同一个事务中写入,代价是一次单调的 `SCHEMA_VERSION` 递增。 +- **JSONL 无法承载一个可变的 header 字段。** header 就是第 1 行,在物化时一次写就,此后这份日志永远以追加方式打开;`jsonl.spec.ts` 钉住了「已提交的字节绝不重写」。一个每次追加都要改的 header 字段,违反的是一条被断言的持久性不变式,而不只是让写入方变复杂。要与「让 JSONL 保持近似」相比较的形态,是每会话一个伴随文件。 + +实现之前必须回答三个问题,本文对它们都没有定论: + +**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/inherited` 回答了这个问题。一个已存储字段是在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。 + +**该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。 + +**对 JSONL 来说伴随文件可以接受吗?** 它重新引入了每会话第二个文件,而该文件可能与日志不一致,这正是单产物设计所避开的。 + +## 考虑过的替代方案 + +**在冷路径上读取日志。** 它按构造就是正确的,也不需要改动格式,但会让只读 header 的列举失去意义:`list()` 的开销将随日志总体量增长,而 web 会话树会扇出到存储中的每一个会话。mtime 近似的存在,正是为了避开这个选项。 + +**保留 mtime,但把边界的写入排除在它之外。** 否决的理由是做不到,而不是不合意:mtime 属于文件系统,不属于后端。除了在每次边界写入之后把时间戳复原,没有别的办法能保住它,而那样做会与任何并发读取方产生竞态,也会对这份产物撒谎。 + +**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。 + +**从投影缓存派生活动时间。** `session-projection-cache` 本就会折叠水位线之后的尾部,因此一个最后活动单元可以搭乘既有机制。它作为主形态被否决,因为该缓存是一个可选的组合项;只有挂载了缓存插件才提供的列举,会让排序取决于如何组合。 + +## 验收标准 + +- 冷会话的 `SessionSummary.updatedAt` 等于已附加会话的投影为同一个会话报告的那个值;验证方式是恢复、不跑轮次就退出,并断言两条路径上的顺序都没有变化。 +- 在 web 会话树和 TUI 恢复选择器中,一个恢复后即被弃置的会话不会排到此后工作过的会话之前;由一份组装后的快照钉住,而不是只靠单元测试。 +- 活动规则只有一个定义:一个测试证明,在一份同时包含边界、closer 和一个普通轮次的日志上,已存储字段与 `lastActivityTime()` 的结果一致。 +- 在选定的回退方案下,该字段引入之前的产物能够无错误地加载和列举,并且该回退在排序上的后果有断言覆盖。 +- 按本仓库不做迁移的立场,SQLite 的 `SCHEMA_VERSION` 递增会拒绝旧的磁盘版本。 + +## 风险 + +**「活动」的两个定义发生漂移。** 已存储字段按批次计算,而投影在整份日志上计算。一种新事件类型若在写入时按一种方式归类、在读取时按另一种方式归类,就会产生一个冷排序与已附加排序彼此矛盾的会话;这个缺陷只在重启之后才显现,而那正是最难被注意到的地方。 + +**JSONL 的伴随文件可能与它的日志不一致。** 在日志追加与伴随文件写入之间发生崩溃,会留下一个陈旧的值,而且没有撕裂尾部标记可用来修复它。每个消费方都得把伴随文件当作一条提示来对待,而这与 mtime 今天的地位已经很接近了。 + +**回退方案会让既有会话重新排序。** 无论选定哪种回退,持有既有日志的用户都会在升级时看到自己的选择器和会话树重新排一次序。选 `createdAt` 会让这次重排的幅度很大。 + +**代价可能超过这个缺陷本身。** 该缺陷是被弃置会话的排序出错。如果对 JSONL 来说诚实的答案是「保留这套近似」,那么本文的结局可能是记录下这个决定,而不是实现一个字段,而这也是一个可以接受的结局。 + +## 相关 + +- [继承历史日志边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影。 +- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。 +- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。 diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml new file mode 100644 index 0000000000..bcfbebf293 --- /dev/null +++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md +2026-07-29-session-resumed-log-boundary.md: 6dbe44cbd8354369e8ed56d8cc838e9771dbaa52 +2026-07-29-session-resumed-log-boundary.zh.md: f035773b896131ca87a70b58b19cfafb280601e4 diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md new file mode 100644 index 0000000000..6dbe44cbd8 --- /dev/null +++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md @@ -0,0 +1,51 @@ +# Agent Note: Record the resume process boundary in the session log + +Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the inherited-history boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) + +English | [中文](2026-07-29-session-resumed-log-boundary.zh.md) + +## Problem + +A session's durable log gave no evidence that it had changed processes. `session/created`, `session/disposed`, and `session/flush` are cordis runtime signals rather than `SessionEventMap` members, and `agent/session-start` carries a `SessionStartSource` but is emit-only and never logged. Reading a stored log therefore gave no hint that anything had been resumed. + +That gap makes one class of question unanswerable. A plugin that owns a standalone open/close pair in the log — compaction's `compact/start` … `compact/end` is the only one today — must distinguish an unmatched opening marker left by a process that died mid-operation from one an operation is holding right now. Those two states are **byte-identical in stored history**. Without a boundary the owner has to choose between refusing forever (an unmatched marker wedges the operation permanently, and because automatic compaction failure is warn-and-continue the user-visible result is that compaction silently stops working until the context window overflows) and proceeding always (which defeats the point of holding a lock). + +The pressure to fix this is immediate: moving `compact/start` to its real time point, before summarization, widens the crash window from a few microseconds of synchronous appends to the length of a whole model call, so orphaned brackets go from rare to routine. + +## Proposal + +`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event below the boundary was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one. + +The predicate a bracket owner evaluates is purely a function of the log: an unmatched opening marker with a `session/resumed` after it is stale, and one with no `session/resumed` after it is live. + +`time` is `Date.now()` floored at the log's greatest `time`, deliberately unlike the synthetic closers, which reuse the last real event's timestamp so repair output stays a deterministic function of stored history. The wall clock is not monotonic — an NTP step, a VM restore, or a log copied from a machine that was ahead can put it behind events already stored — so the floor keeps every cross-boundary duration non-negative. The floor is durable, because the clamped boundary is stored and joins the log's maximum: one future-dated event pins every later boundary in that log to the same instant until wall time passes it. + +**The predicate distinguishes process succession, not concurrent writers.** `load()`'s liveness guard is `ctx.sessions.get(id)`, which only sees sessions live in *this* runtime, and no backend takes a cross-process per-session lock. So process B cold-loading a session A currently owns writes a boundary after A's still-open bracket. A consumer that must tolerate concurrent writers still needs a liveness signal beyond the log. + +## Why this was rejected + +Two reasons, found while reviewing where the marker belonged. + +**It covers no fork.** `sessions.fork()` and a subagent fork child construct a seeded session without touching persistence, so neither gets a boundary. A forked child inherits its parent's prefix verbatim — including an open `compact/start` the parent is still holding — which is the one case where the inherited bracket's owner is demonstrably alive. The predicate was unavailable exactly where it was most needed. + +**Minting the marker at load made a read path a durable write.** Every consequence the review surfaced traced to that: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, the durable time floor above, a load that fails against a read-only store, and a marked log after a resume the caller then cancelled. None of these are wrong given the placement; they are the placement's cost. + +The successor keeps the problem statement and the concurrent-writer scope limit unchanged, and moves the write to `Session`'s constructor — the single waist all six seeded-start paths pass through, fork included. Because the marker then rides the ordinary seed-persistence path, the whole durable-write surface above disappears. + +## Alternatives considered + +**Use `Session.firstLiveSeq` as the staleness predicate.** Dismissed here on the grounds that it is documented as deliberately not persisted, so the same stored log yields different answers in different processes and a read-only reader cannot evaluate it at all. That reasoning was sound about the field and wrong about the conclusion: the fix is to persist a projection of it rather than to compute the boundary somewhere else. This is the alternative that became the successor. + +**Declare the event in core (`dsh-session`).** Rejected here because "the constructor cannot distinguish resume from fork or replay." That is true and turned out not to matter — the distinction is not needed, since inherited history is dead history in all three cases. + +**Teach `interruptedTurnClosers` to close `compact/*`.** Rejected: `compact/*` is plugin-owned vocabulary and core must not know it. Core closes turn, step, and tool boundaries — the relations it owns. The successor keeps this rejection. + +**Lazy self-repair: the owner appends a synthetic closing marker when it finds an orphan.** A write inside a read-shaped check, and it needs an invariant exception for a numbered owner whose turn has already closed. + +**A merge-extensible repair-contributor registry in core.** The right shape once a second consumer exists; with one consumer today, `packages/AGENTS.md` says not to split a seam preemptively. + +**Write the boundary only when repair actually occurred.** Rejected: the predicate must hold for an orderly restart too, where there is nothing to repair. The successor keeps this rejection. + +## Related + +The cold-session `updatedAt` skew this proposal documented is scoped in [the last-activity-index Agent Note](../../proposed/architecture/2026-07-29-durable-last-activity-index.md). That defect predates this proposal and survives its rejection: it is caused by mtime counting every durable write, not by any one boundary. diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md new file mode 100644 index 0000000000..f035773b89 --- /dev/null +++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 在会话日志中记录恢复的进程边界 + +Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[继承历史边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)取代 + +[English](2026-07-29-session-resumed-log-boundary.md) | 中文 + +## Problem + +会话的持久日志此前无法证明它换过进程。`session/created`、`session/disposed` 和 `session/flush` 是 cordis 运行时信号,而不是 `SessionEventMap` 成员;`agent/session-start` 虽然携带 `SessionStartSource`,却只用于 emit,从不记录。因此,读取一份已存储日志得不到任何关于「曾经发生过恢复」的线索。 + +这一空缺让一类问题无法回答。在日志中拥有独立开始/结束事件对的插件必须区分两种未匹配的起始标记:一种由某个在操作中途死亡的进程留下,另一种正被当前某项操作持有;今天符合这一形态的只有压缩的 `compact/start` … `compact/end`。这两种状态**在已存储历史中逐字节相同**。没有边界,所有方只能在两种做法之间选择:永远拒绝(一个未匹配的标记会永久卡住该操作,而自动压缩失败采取警告并继续的策略,因此用户可见的结果是压缩静默停止工作,直到上下文窗口溢出),或者始终继续(这让持有锁失去了意义)。 + +修复它的压力是即刻的:把 `compact/start` 移到摘要生成之前这个真实的时间点,会把崩溃窗口从几微秒的同步追加扩大为一整次模型调用的时长,孤儿括号也就从罕见变为常态。 + +## Proposal + +`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之下的每个事件都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()` 与 `adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()`/`readFrom()` 读取也从不写入。 + +括号所有方求值的谓词纯粹是日志的函数:未匹配的起始标记之后有 `session/resumed` 的就是陈旧的,之后没有的就是存活的。 + +`time` 取 `Date.now()` 并以日志的最大 `time` 为下限,刻意区别于合成 closers——后者复用最后一个真实事件的时间戳,以便修复输出始终是已存储历史的确定性函数。挂钟并非单调:一次 NTP 跳变、一次虚拟机恢复,或一份从走快的机器上拷来的日志,都可能让它落在已存储事件之后,因此这个下限让跨边界的时长都非负。该下限是持久的,因为被钳制的边界本身会被存储并加入日志的最大值:一个未来时间的事件会把该日志中之后的每个边界都钉在同一时刻,直到挂钟时间越过它。 + +**该谓词区分的是进程接替,不是并发写入方。** `load()` 的存活性守卫是 `ctx.sessions.get(id)`,它只看到*本*运行时中存活的会话,而且没有任何后端会取跨进程的按会话锁。因此,进程 B 冷加载一个 A 当前拥有的会话时,会在 A 仍然开放的括号之后写入一个边界。必须容忍并发写入方的消费方仍然需要日志之外的存活信号。 + +## 为什么被否决 + +两个原因,都是在复审标记应当落在何处时发现的。 + +**它完全覆盖不到 fork。** `sessions.fork()` 与子代理 fork 子会话在不触及持久化的情况下构造带种子会话,因此两者都拿不到边界。fork 子会话会逐字节继承父会话的前缀——包括父会话仍然持有的开放 `compact/start`——而这恰恰是继承括号的所有方明显还活着的唯一情形。谓词偏偏在最需要它的地方不可用。 + +**在加载时铸造标记,把读取路径变成了持久写入。** 复审暴露出的每一项后果都源于此:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、上文那个持久时间下限、加载在只读存储上会失败,以及调用方随后取消的恢复也已留下标记。这些在该放置方式下都不算错,它们就是该放置方式的成本。 + +取代方案保留问题陈述与并发写入方的适用范围限制不变,并把写入移到 `Session` 的构造函数——全部六条带种子启动路径(含 fork)必经的唯一收窄处。由于标记随后走普通的种子持久化路径,上述整个持久写入面就消失了。 + +## Alternatives considered + +**用 `Session.firstLiveSeq` 作为陈旧性谓词。** 此处以「文档明确它有意不做持久化,因此同一份已存储日志在不同进程中会给出不同答案,而只读读取方根本无法对它求值」为理由否决。这个推理对字段本身是成立的,但结论错了:正确的修法是持久化它的一个投影,而不是把边界挪到别处去算。这条替代方案正是后来的取代方案。 + +**在核心(`dsh-session`)中声明该事件。** 此处以「构造函数无法把恢复与 fork 或回放区分开」为理由否决。这句话是对的,但事实证明它无关紧要——并不需要这种区分,因为在这三种情形下继承历史都是死历史。 + +**教 `interruptedTurnClosers` 关闭 `compact/*`。** 否决:`compact/*` 是插件所属词汇,核心不得知道它。核心只关闭轮次、步骤和工具边界,也就是它自己拥有的关系。取代方案保留这条否决。 + +**惰性自修复:所有方发现孤儿时自行追加一条合成的关闭标记。** 这是在一次形似读取的检查中执行写入,而且需要为一个带轮次编号、其轮次却已经关闭的所有方开一个不变式例外。 + +**在核心中建一个可合并扩展的修复贡献方注册表。** 一旦出现第二个消费方,这就是正确的形状;今天只有一个消费方,而 `packages/AGENTS.md` 要求不要预先拆分 seam。 + +**仅在确实发生了修复时才写入边界。** 否决:该谓词对有序重启同样必须成立,而那时没有任何东西需要修复。取代方案保留这条否决。 + +## 相关 + +本提案记录过的冷会话 `updatedAt` 偏斜,范围界定在[最后活动索引 Agent Note](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)。该缺陷早于本提案存在,并且在本提案被否决后依然存在:它的成因是 mtime 会计入每一次持久写入,而不是某一个边界。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 93e7821075..316c5a9830 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:695`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:706`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -2197,7 +2197,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:247`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:248`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 3e35c3a051..257e879eee 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: fd8285eebd76e8bd7723ee86ae15427f4923f4d6 -session.zh.md: 1033bfda117b5693421f0bdf4ec3fc136039f223 +session.md: 20942722c2c1f86bf9a2b6007ad42c9e97d43b91 +session.zh.md: 87ca7dc44ad4ac19456679ecbffd08cfd3023296 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index fd8285eebd..20942722c2 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -91,6 +91,26 @@ interface SessionEventMap { * It is log-only; the latest snapshot reconstructs the request header. */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } + /** + * The log-only durable projection of {@link Session.firstLiveSeq}: everything + * BELOW it was inherited through a constructor seed — resume, fork, or replay + * — and no writer in this session's lifecycle produced it. Appended as the + * first live event of every seeded session. + * + * A plugin owning a standalone open/close bracket (`compact/start` … + * `compact/end`) needs it because inherited history and live work are + * otherwise byte-identical: an unmatched opening marker below this boundary + * belongs to an ended lifecycle, so it is dead whether the writer crashed, + * the process succeeded it, or the events were forked out of a parent that is + * still running. Read it through `isInheritedSeq`. + * + * NOT a liveness signal about other writers: a concurrently live session may + * hold an open bracket over the same stored history with its own boundary + * elsewhere, so tolerating concurrent writers needs a signal beyond the log. + * + * The payload is empty by design — position and `time` carry the meaning. + */ + 'session/inherited': Record } ``` @@ -326,8 +346,12 @@ declare class Session { * log as a publication substitute (telemetry adoption) start here. Distinct * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed * session's constructor seed is its full stored log, while its header keeps - * the original fork value — this field is the in-process construction fact - * and is deliberately not persisted. + * the original fork value — this field is the in-process construction fact. + * + * Not persisted itself: a nonzero value is projected into the log as the + * `session/inherited` event at this seq, which is what a consumer reading + * STORED history reads. Prefer this field in-process — it is exact before + * the marker's write reaches storage. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); @@ -507,6 +531,14 @@ A turn encloses one model-loop execution, not the whole session log. Idle inject The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md). +## The inherited-history boundary: `session/inherited` + +A seeded session — resume, fork, or replay — appends this log-only event as its first live write, at the seq its `firstLiveSeq` names. It is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. An empty seed writes nothing, and a seed already ending in one is not re-marked, so reopening an untouched session does not grow its log per open. + +It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. `isInheritedSeq(events, seq)` is the predicate a bracket owner calls — true means the opening marker belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent). It classifies only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`. + +Activity ordering excludes the boundary through `lastActivityTime(events)`: picking a session up is not work, and lazy resume means browsing writes one, so a resume picker or session list ordering by log tail would float every opened session to the top. + ## Plugin-contributed log-only events A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 1033bfda11..87ca7dc44a 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -91,6 +91,26 @@ interface SessionEventMap { * It is log-only; the latest snapshot reconstructs the request header. */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } + /** + * The log-only durable projection of {@link Session.firstLiveSeq}: everything + * BELOW it was inherited through a constructor seed — resume, fork, or replay + * — and no writer in this session's lifecycle produced it. Appended as the + * first live event of every seeded session. + * + * A plugin owning a standalone open/close bracket (`compact/start` … + * `compact/end`) needs it because inherited history and live work are + * otherwise byte-identical: an unmatched opening marker below this boundary + * belongs to an ended lifecycle, so it is dead whether the writer crashed, + * the process succeeded it, or the events were forked out of a parent that is + * still running. Read it through `isInheritedSeq`. + * + * NOT a liveness signal about other writers: a concurrently live session may + * hold an open bracket over the same stored history with its own boundary + * elsewhere, so tolerating concurrent writers needs a signal beyond the log. + * + * The payload is empty by design — position and `time` carry the meaning. + */ + 'session/inherited': Record } ``` @@ -328,8 +348,12 @@ declare class Session { * log as a publication substitute (telemetry adoption) start here. Distinct * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed * session's constructor seed is its full stored log, while its header keeps - * the original fork value — this field is the in-process construction fact - * and is deliberately not persisted. + * the original fork value — this field is the in-process construction fact. + * + * Not persisted itself: a nonzero value is projected into the log as the + * `session/inherited` event at this seq, which is what a consumer reading + * STORED history reads. Prefer this field in-process — it is exact before + * the marker's write reaches storage. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); @@ -511,6 +535,14 @@ interface TurnEndReasonMap { 可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。 +## 继承历史边界:`session/inherited` + +带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。它是该字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。空种子不写入任何内容;种子本身已以该事件结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次打开都增长日志。 + +它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。`isInheritedSeq(events, seq)` 就是括号所有方调用的谓词——为真意味着该开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来)。它只判定*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。 + +活动排序通过 `lastActivityTime(events)` 排除该边界:接手会话不算工作,而惰性恢复意味着浏览就会写入一个,因此按日志尾部排序的恢复选择器或会话列表会把每个打开过的会话顶到最前。 + ## 插件贡献的仅日志事件 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index dea3c4816b..5e1c6526ce 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) ## Events @@ -404,6 +404,33 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s ### `session/*` +#### `session/inherited` — log-only + +```ts persistence-catalog +/** + * The log-only durable projection of {@link Session.firstLiveSeq}: everything + * BELOW it was inherited through a constructor seed — resume, fork, or replay + * — and no writer in this session's lifecycle produced it. Appended as the + * first live event of every seeded session. + * + * A plugin owning a standalone open/close bracket (`compact/start` … + * `compact/end`) needs it because inherited history and live work are + * otherwise byte-identical: an unmatched opening marker below this boundary + * belongs to an ended lifecycle, so it is dead whether the writer crashed, + * the process succeeded it, or the events were forked out of a parent that is + * still running. Read it through `isInheritedSeq`. + * + * NOT a liveness signal about other writers: a concurrently live session may + * hold an open bracket over the same stored history with its own boundary + * elsewhere, so tolerating concurrent writers needs a signal beyond the log. + * + * The payload is empty by design — position and `time` carry the meaning. + */ +'session/inherited': Record +``` + +Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts) + #### `session/title` — log-only ```ts persistence-catalog diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 042753f1a7..c019a1da1d 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/inherited': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index 6d5b0d9163..2dbb507f36 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{cwd}}","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"917c2f1a-be80-4f54-86e8-c94fe6859bdd"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"4fbb7fab-f45a-4e3c-95e0-454a638cf4b3"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783352134840,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1783352134840,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -12,21 +12,22 @@ {"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} {"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} {"type":"assistant/chunk","seq":34,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5927ef74-0269-4474-a6c0-45c09c1adac5"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"4dd423ef-963c-409e-8238-551c4b10273b"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} {"type":"step/end","seq":36,"time":1783352135773,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":37,"time":1783352135773,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":38,"time":1783352137162,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":39,"time":1783352137163,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"840f1fca-2577-47c1-acee-c47125098882"},"surfaceOp":"append"} -{"type":"step/start","seq":40,"time":1783352137163,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":41,"time":1785142305260,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":42,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":43,"time0":1783352137783,"data":{"turn":2,"step":1,"index":0,"dt":[178,28,31,26,0,0,0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} -{"type":"assistant/chunk","seq":77,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":78,"time0":1783352138275,"data":{"turn":2,"step":1,"index":1,"dt":[0,0,30],"texts":["M","ARM","AL","ADE"]}} -{"type":"assistant/chunk","seq":82,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} -{"type":"assistant/chunk","seq":83,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} -{"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":85,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":86,"time":1785142305270,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0376771a-3af4-41ec-9ee6-750ba6d65b25"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} -{"type":"step/end","seq":87,"time":1785142305270,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":88,"time":1785142305270,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session/inherited","seq":38,"time":1785381572223,"data":{}} +{"type":"turn/start","seq":39,"time":1785381572224,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":40,"time":1785381572224,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8cfd615d-f500-4de4-b751-d19e27d23016"},"surfaceOp":"append"} +{"type":"step/start","seq":41,"time":1785381572240,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":42,"time":1785381572241,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":43,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":44,"time0":1783352137961,"data":{"turn":2,"step":1,"index":0,"dt":[28,31,26,0,0,0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} +{"type":"assistant/chunk","seq":78,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":79,"time0":1783352138275,"data":{"turn":2,"step":1,"index":1,"dt":[0,30,2],"texts":["M","ARM","AL","ADE"]}} +{"type":"assistant/chunk","seq":83,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} +{"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} +{"type":"assistant/chunk","seq":85,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":86,"time":1785381572250,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":87,"time":1785381572250,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3dc1dc0d-40e0-4cd1-a111-1bcf40a1001e"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86],"surfaceOp":"append"} +{"type":"step/end","seq":88,"time":1785381572250,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":89,"time":1785381572251,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index 1b9127889d..9277f70139 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"867b46b8-e2fa-4257-a2b1-a8fa12abe782"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5c2a4880-9a66-4e38-b8d2-a2f891ff283f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1783352142836,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -12,21 +12,22 @@ {"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} {"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} {"type":"assistant/chunk","seq":28,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5dc3014f-f57f-4686-bbe9-8b89079c0b18"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"132af698-2789-4aaa-aca0-cffd1c58720a"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} {"type":"step/end","seq":30,"time":1783352143771,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":31,"time":1783352143771,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":32,"time":1783352147508,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":33,"time":1783352147509,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"9809d0e2-3997-4c6c-83ea-f28538b83ad9"},"surfaceOp":"append"} -{"type":"step/start","seq":34,"time":1783352147509,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":35,"time":1785142306299,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":36,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":37,"time0":1783352147925,"data":{"turn":2,"step":1,"index":0,"dt":[94,29,1,0,27,0,1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} -{"type":"assistant/chunk","seq":68,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":69,"time0":1783352148313,"data":{"turn":2,"step":1,"index":1,"dt":[0,31],"texts":["SA","FF","RON"]}} -{"type":"assistant/chunk","seq":72,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} -{"type":"assistant/chunk","seq":73,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} -{"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":75,"time":1785142306309,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":76,"time":1785142306309,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7c0c4a97-79fe-4429-a963-8e24633e6335"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} -{"type":"step/end","seq":77,"time":1785142306309,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":78,"time":1785142306309,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session/inherited","seq":32,"time":1785381573525,"data":{}} +{"type":"turn/start","seq":33,"time":1785381573526,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":34,"time":1785381573526,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f85229b8-dcf8-4d8c-9d61-8ef879ed5974"},"surfaceOp":"append"} +{"type":"step/start","seq":35,"time":1785381573543,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":36,"time":1785381573543,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":37,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":38,"time0":1783352148019,"data":{"turn":2,"step":1,"index":0,"dt":[29,1,0,27,0,1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} +{"type":"assistant/chunk","seq":69,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":70,"time0":1783352148313,"data":{"turn":2,"step":1,"index":1,"dt":[31,1],"texts":["SA","FF","RON"]}} +{"type":"assistant/chunk","seq":73,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} +{"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} +{"type":"assistant/chunk","seq":75,"time":1785142306309,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":76,"time":1785381573552,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":77,"time":1785381573552,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5d0e4a32-f5ee-4992-8223-51cdb14d1db3"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} +{"type":"step/end","seq":78,"time":1785381573553,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":79,"time":1785381573553,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl b/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl index 04c81635bd..ea07941fd5 100644 --- a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl +++ b/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl @@ -7,15 +7,16 @@ {"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-5","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]} {"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}} -{"type":"turn/start","seq":8,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":10,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":11,"time":0,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":17,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[13,14,15,16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":0,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":19,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session/inherited","seq":8,"time":0,"data":{}} +{"type":"turn/start","seq":9,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":11,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":12,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":13,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":18,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[14,15,16,17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":0,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":20,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl index d5bfb405de..7123ff0eb4 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl @@ -3,26 +3,27 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"sandbox/mode","seq":2,"time":0,"data":{"mode":"read-only"}} {"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":4,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":7,"time":0,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"tool/call","seq":15,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} -{"type":"tool/result","seq":16,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":18,"time":0,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The delegated child was denied by the sandbox. PARENT_DONE"}}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":24,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"step/end","seq":25,"time":0,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":26,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session/inherited","seq":4,"time":0,"data":{}} +{"type":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":8,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":9,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} +{"type":"tool/result","seq":17,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":0,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":19,"time":0,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The delegated child was denied by the sandbox. PARENT_DONE"}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":27,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9f7d6dc28d..b476158040 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2158,7 +2158,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/inherited\': Record;\n}', }, { name: 'SessionEventMetadataFilter', diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index e051642dfd..3e9895ec4c 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1207,8 +1207,9 @@ describe('agent loop', () => { const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] }) expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages()) - // event-by-event identity of types - expect(replayed.events.map(e => e.type)).toEqual( + // event-by-event identity of types over the inherited prefix + expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual( agent.session.events.map(e => e.type)) + expect(replayed.events.at(-1)?.type).toBe('session/inherited') }) }) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index f133f1979c..969c7b9f5e 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -283,7 +283,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', agentOptions: { provider: 'mock', model: 'mock' }, setup: async (agentCtx) => { expect(agentCtx.agent?.id).toBe(sessionId) - expect(agentCtx.agent?.session.events).toHaveLength(2) + // The two persisted events plus the inherited-history boundary. + expect(agentCtx.agent?.session.events).toHaveLength(3) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') @@ -585,7 +586,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') - expect(a2.session.events.length).toBe(events1.length) + // …below one boundary marking all of it inherited. + expect(a2.session.events.length).toBe(events1.length + 1) + expect(a2.session.firstLiveSeq).toBe(events1.length) + expect(a2.session.events.at(-1)?.type).toBe('session/inherited') const replay = new Session(SessionId('replay'), events1) expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages()) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 52c37dc15b..13d30dcd66 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -23,7 +23,7 @@ export * from './types.ts' export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm' export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' -export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts' +export { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts' export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts' export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' @@ -388,8 +388,12 @@ export class Session { * log as a publication substitute (telemetry adoption) start here. Distinct * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed * session's constructor seed is its full stored log, while its header keeps - * the original fork value — this field is the in-process construction fact - * and is deliberately not persisted. + * the original fork value — this field is the in-process construction fact. + * + * Not persisted itself: a nonzero value is projected into the log as the + * `session/inherited` event at this seq, which is what a consumer reading + * STORED history reads. Prefer this field in-process — it is exact before + * the marker's write reaches storage. */ readonly firstLiveSeq: number @@ -427,6 +431,13 @@ export class Session { } this.firstLiveSeq = this.log.length this.header = snapshotSessionHeader(id, header) + // Appended here so the marker is already in `events` when a backend + // captures the creation seed: no load-time write. Re-marking is skipped + // because a cold session is resumed on first touch, so repeatedly opening + // one must not grow its log per open. + if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/inherited') { + this.append('session/inherited', {}) + } } /** Cached immutable public snapshot of the private append-only log. */ diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index c5d0a74a7c..5d841f9005 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -1,7 +1,8 @@ /** * Crash-recovery repair for an interrupted session log. It preserves a fully * written final turn and supplies the missing tool, step, and turn boundaries - * needed to resume with a provider-valid transcript. + * needed to resume with a provider-valid transcript, plus the inherited-history + * boundary a plugin-owned bracket reads to tell dead history from live work. * @module @deepseek-ai/dsh-session/repair */ @@ -9,6 +10,50 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm' import type { ToolResultMessage } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from './types.ts' +/** + * Whether the event at `seq` was inherited rather than written by the lifecycle + * that owns `events` — the stored-history reading of `Session.firstLiveSeq`. + * + * An owner of a standalone open/close bracket calls this on an unmatched + * opening marker: `true` means the operation cannot still be running, because + * the lifecycle that opened it has ended (a crashed writer, a succeeding + * process, or a parent the events were forked out of). `false` means it belongs + * to the current lifecycle and must be treated as live. + * + * Reads the log rather than a `Session`, so it serves a consumer holding only + * loaded events; in-process, compare against `session.firstLiveSeq` instead. + * @param events - the log to scan, contiguous from seq 0. + * @param seq - the event seq to classify. + * @returns true when a `session/inherited` boundary sits at or above `seq`. + */ +export function isInheritedSeq(events: readonly SessionEvent[], seq: number): boolean { + // Tail-first: an unmarked log costs no full scan, and bracket queries are + // usually about recent events. + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] + /* v8 ignore next -- a contiguous log has no holes; the guard is for the index type */ + if (event === undefined) continue + if (event.seq < seq) return false + if (event.type === 'session/inherited') return true + } + return false +} + +/** + * The `time` of the log's last event that represents actual work, skipping the + * `session/inherited` boundary. + * + * Picking a session up is not activity, and lazy resume means browsing writes a + * boundary, so activity ordering (a resume picker, a session list) must skip it + * or every opened session sorts as freshly worked in. + * @param events - the log to scan, in seq order. + * @returns the latest non-boundary event's `time`, or undefined when the log has + * no such event (empty, or nothing but boundaries). + */ +export function lastActivityTime(events: readonly SessionEvent[]): number | undefined { + return events.findLast(event => event.type !== 'session/inherited')?.time +} + /** Recovery code for an assistant tool request that never reached a recorded call start. */ export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED' diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 7f3a12b54e..46b3ad0ed2 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -250,6 +250,26 @@ export interface SessionEventMap { * It is log-only; the latest snapshot reconstructs the request header. */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } + /** + * The log-only durable projection of {@link Session.firstLiveSeq}: everything + * BELOW it was inherited through a constructor seed — resume, fork, or replay + * — and no writer in this session's lifecycle produced it. Appended as the + * first live event of every seeded session. + * + * A plugin owning a standalone open/close bracket (`compact/start` … + * `compact/end`) needs it because inherited history and live work are + * otherwise byte-identical: an unmatched opening marker below this boundary + * belongs to an ended lifecycle, so it is dead whether the writer crashed, + * the process succeeded it, or the events were forked out of a parent that is + * still running. Read it through `isInheritedSeq`. + * + * NOT a liveness signal about other writers: a concurrently live session may + * hold an open bracket over the same stored history with its own boundary + * elsewhere, so tolerating concurrent writers needs a signal beyond the log. + * + * The payload is empty by design — position and `time` carry the meaning. + */ + 'session/inherited': Record } /** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index a317ec565b..fcfbd229cf 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -50,6 +50,14 @@ function lastSeq(session: Session): number { return event.seq } +/** A seeded child's inherited prefix: its log minus the constructor's boundary. */ +function inherited(session: Session): readonly SessionEvent[] { + const events = session.events + const last = events.at(-1) + if (last?.type !== 'session/inherited') throw new Error('seeded child is missing its inherited boundary') + return events.slice(0, -1) +} + describe('SessionStore.fork', () => { it('forks an empty live session as an empty child with lineage metadata', async () => { const { ctx, sessions } = await setup() @@ -73,7 +81,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(SessionId('parent'), undefined, SessionId('child')) - expect(child.events).toEqual(source.events) + expect(inherited(child)).toEqual(source.events) expect(child.events).not.toBe(source.events) expect(child.events[1]).not.toBe(source.events[1]) expect(() => { @@ -97,8 +105,8 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('log-only-child')) - expect(child.events).toEqual(source.events) - expect(child.events.at(-1)).toMatchObject({ + expect(inherited(child)).toEqual(source.events) + expect(inherited(child).at(-1)).toMatchObject({ type: 'test/log-only', data: { value: 'after execution' }, }) @@ -114,7 +122,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, firstBoundary, SessionId('child-from-first')) - expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1)) + expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1)) expect(child.header.seedLength).toBe(firstBoundary + 1) expect(child.deriveMessages()).toEqual([{ id: expect.any(String) as unknown, @@ -141,7 +149,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`)) - expect(child.events.at(-1)?.type).toBe('turn/end') + expect(inherited(child).at(-1)?.type).toBe('turn/end') expect(child.header.seedLength).toBe(source.events.length) } }) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 1449f3684e..c4372fe377 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -112,7 +112,19 @@ describe('Session properties', () => { const original = build(events) const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) - expect(replayed.seq).toBe(original.seq) + // A non-empty replay grows by exactly one log-only boundary. + expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.seq).toBe(original.seq === 0 ? 0 : original.seq + 1) + })) + }) + + it('replaying an already-inherited log adds no further boundary', () => { + fc.assert(fc.property(logArb, (events) => { + const original = build(events) + const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events]) + const twice = new Session(SessionId(`idem-b-${counter++}`), [...once.events]) + // Lazy resume makes browsing a pickup, so this must not grow per open. + expect(twice.events).toEqual(once.events) })) }) diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index 645d7d6921..b01f73a220 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' -import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts' +import { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts' import type { SessionEvent, SurfaceEvent } from '../src/index.ts' /** @@ -273,3 +273,102 @@ describe('interruptedTurnClosers', () => { expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end']) }) }) + +/** + * The stored-history reading of the inherited boundary. A bracket owner calls + * this on an unmatched opening marker to decide whether the operation can still + * be running, so the classification of the marker's own seq — and of the + * boundary seq itself — is the contract. + */ +describe('isInheritedSeq', () => { + const inheritedAt = (seq: number): SessionEvent => + ({ type: 'session/inherited', seq, time: seq, data: {} }) + + it('classifies nothing as inherited in a log without a boundary', () => { + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + expect(isInheritedSeq(events, 0)).toBe(false) + expect(isInheritedSeq(events, 1)).toBe(false) + }) + + it('treats an empty log as owning nothing', () => { + expect(isInheritedSeq([], 0)).toBe(false) + }) + + it('splits the log at the boundary', () => { + // seqs 0-1 inherited; the boundary at 2; seq 3 written by this lifecycle. + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + inheritedAt(2), + userTurnStart(2, 3), + ] + expect(isInheritedSeq(events, 0)).toBe(true) + expect(isInheritedSeq(events, 1)).toBe(true) + // The boundary's own seq counts as inherited: it belongs to the pickup. + expect(isInheritedSeq(events, 2)).toBe(true) + expect(isInheritedSeq(events, 3)).toBe(false) + }) + + it('reports inherited for an event below a later boundary', () => { + // Two pickups in turn: the tail scan must not stop at the nearer boundary. + const events: SessionEvent[] = [ + userTurnStart(1, 0), + inheritedAt(1), + userTurnStart(2, 2), + inheritedAt(3), + userTurnStart(3, 4), + ] + expect(isInheritedSeq(events, 0)).toBe(true) + expect(isInheritedSeq(events, 2)).toBe(true) + expect(isInheritedSeq(events, 4)).toBe(false) + }) +}) + +/** + * Activity ordering excludes the pickup boundary. A resume picker or session + * list sorting by log tail would otherwise promote every session the user + * merely opened above the ones they actually worked in. + */ +describe('lastActivityTime', () => { + const inheritedAt = (seq: number, time: number): SessionEvent => + ({ type: 'session/inherited', seq, time, data: {} }) + + it('has no answer for an empty log', () => { + expect(lastActivityTime([])).toBeUndefined() + }) + + it('reports the log tail when no boundary is present', () => { + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + expect(lastActivityTime(events)).toBe(500) + }) + + it('skips a trailing boundary in favour of the last real work', () => { + const events: SessionEvent[] = [ + userTurnStart(1, 0), + { type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } }, + inheritedAt(2, 9_000), + ] + // Resumed long after the work, but never worked in again. + expect(lastActivityTime(events)).toBe(500) + }) + + it('reports work done above a boundary', () => { + const events: SessionEvent[] = [ + userTurnStart(1, 0), + inheritedAt(1, 9_000), + { type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + expect(lastActivityTime(events)).toBe(9_500) + }) + + it('has no answer for a log of nothing but boundaries', () => { + // Unreachable via the constructor, but the projection is a pure function. + expect(lastActivityTime([inheritedAt(0, 1), inheritedAt(1, 2)])).toBeUndefined() + }) +}) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 7152dd5d41..4c551bede0 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -112,7 +112,7 @@ describe('Session', () => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events)) - expect(replayed.events).toEqual(session.events) + expect(replayed.events.slice(0, -1)).toEqual(session.events) const turnEnd = replayed.events.findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) }) @@ -188,7 +188,10 @@ describe('Session', () => { const replayed = new Session(SessionId('s3-replay'), [...original.events]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) - expect(replayed.seq).toBe(original.seq) + // The seed verbatim, plus the boundary the constructor appends over it. + expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.seq).toBe(original.seq + 1) + expect(replayed.firstLiveSeq).toBe(original.seq) }) it('rejects pre-provider request headers and assistant messages on seed/load', () => { @@ -217,7 +220,7 @@ describe('Session', () => { const unrelatedPrimitiveData = { type: 'plugin/event', seq: 0, time: 1, data: null, } as unknown as SessionEvent - expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events) + expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1)) .toEqual([unrelatedPrimitiveData]) }) @@ -522,7 +525,8 @@ describe('Session', () => { { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, ] as SessionEvent[] const session = new Session(SessionId('seed-ok'), goodSeed) - expect(session.events).toHaveLength(3) + expect(session.events.slice(0, 3)).toEqual(goodSeed) + expect(session.firstLiveSeq).toBe(3) }) it('reads each seed array entry once so validation and storage use the same event', () => { @@ -546,7 +550,7 @@ describe('Session', () => { const session = new Session(SessionId('seed-entry-snapshot'), seed) expect(reads).toBe(1) - expect(session.events).toEqual([accepted]) + expect(session.events.slice(0, 1)).toEqual([accepted]) }) it('reads a nested seed-data getter once and stores its first JSON value', () => { @@ -624,7 +628,7 @@ describe('Session', () => { const session = new Session(SessionId('seed-null-prototype'), [event]) - expect(session.events).toEqual([{ ...event }]) + expect(session.events.slice(0, 1)).toEqual([{ ...event }]) }) it('reads a nested seed-metadata getter once and stores its first JSON value', () => { @@ -1647,6 +1651,7 @@ describe('todo/write event', () => { const replayed = new Session(SessionId('t4-replay'), [...original.events]) expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos) .toEqual([{ content: 'only', status: 'completed' }]) - expect(replayed.seq).toBe(original.seq) + expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.firstLiveSeq).toBe(original.seq) }) }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 81d16c8919..078d448dd7 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -14,6 +14,7 @@ import type { import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm' +import { lastActivityTime } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' @@ -164,7 +165,9 @@ function sessionBlank(session: Session): boolean { function summarize(session: Session, running: boolean): SessionSummary { return { sessionId: session.id, - updatedAt: session.events.at(-1)?.time ?? session.header.createdAt, + // Excludes the inherited-history boundary: a resumed-but-untouched session + // must not sort as freshly worked in. + updatedAt: lastActivityTime(session.events) ?? session.header.createdAt, running, blank: sessionBlank(session), ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 3e7f09a8df..d994444df1 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -78,6 +78,43 @@ describe('sessions.list cold merge', () => { }) }) +describe('attached updatedAt excludes the inherited-history boundary', () => { + it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + // Old work, resumed just now: the boundary's time is far above the work's, + // so reading the log tail would report the pickup as activity. + const worked = 1_000_000 + const resumed = ctx.sessions.create(sid('resumed-untouched'), { + seed: [ + { type: 'turn/start', seq: 0, time: worked, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } }, + ], + meta: { cwd: '/proj', createdAt: 500 }, + }) + ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent) + const boundary = resumed.events.at(-1) + expect(boundary?.type).toBe('session/inherited') + expect(boundary?.time).toBeGreaterThan(worked) + + const listed = await api.sessions.list(request({})) + if (!listed.result.ok) throw new Error('list failed') + const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched') + expect(summary?.updatedAt).toBe(worked) + + // Real work above the boundary does move it. + resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + const after = await api.sessions.list(request({})) + if (!after.result.ok) throw new Error('list failed') + const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched') + expect(moved?.updatedAt).toBeGreaterThan(worked) + }) +}) + describe('degenerate composition (no persistence, no factory)', () => { it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => { const ctx = new Context() diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 30342e81ec..3d63ad19db 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -417,7 +417,8 @@ describe('replay anchors and surface folds', () => { expect(after.surfaceDeltaTokens).toBeLessThan(0) expectSurfaceTotal(after) expect(before.nodes).toHaveLength(2) - expect(before.logRevision).toBe(original.events.length) + // The earlier snapshot still reports the log it measured: seed + boundary. + expect(before.logRevision).toBe(original.events.length + 1) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) }) @@ -677,13 +678,15 @@ describe('malformed replay and listener lifecycle', () => { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - expect(revisions).toEqual([2]) - expect(activeMeter.measure(session).logRevision).toBe(2) + // Seed, constructor boundary, then the append above. Only the last + // published: the boundary predates store attachment, like the seed. + expect(revisions).toEqual([3]) + expect(activeMeter.measure(session).logRevision).toBe(3) await firstFiber.dispose() const secondFiber = await ctx.plugin(TokenMeterService) activeMeter = ctx.tokenMeter - expect(activeMeter.measure(session).logRevision).toBe(2) + expect(activeMeter.measure(session).logRevision).toBe(3) await secondFiber.dispose() }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 5171bb8585..15e1ccc319 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -378,7 +378,9 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await ctx.sessions.flush(child) const loaded = await ctx.sessionPersistence.load(child.id) - expect(loaded.events).toEqual(source.events) + // The inherited prefix reaches disk verbatim, then the child's boundary. + expect(loaded.events.slice(0, source.events.length)).toEqual(source.events) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: source.events.length }) expect(loaded.meta).toMatchObject({ id: SessionId('persist-child'), cwd: '/workspace', diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 1de20528b2..e34a37745b 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -218,7 +218,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< live.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(live) const loaded = await ctx.sessionPersistence.load(id) - expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + // The seeded constructor's boundary persisted between the stored + // turn/start and the turn/end appended live. + expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited', 'turn/end']) expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -477,11 +479,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } }) await ctx.sessions.flush(forked) // onCreated persisted the seed const loaded = await ctx.sessionPersistence.load(SessionId('forked')) - expect(loaded.events).toEqual(seed) + // Fork is where the marker earns its keep: the inherited prefix may + // carry a bracket the still-running parent owns. + expect(loaded.events.slice(0, seed.length)).toEqual(seed) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: seed.length }) // A flush with no NEW events must not double-write. await ctx.sessions.flush(forked) const reloaded = await ctx.sessionPersistence.load(SessionId('forked')) - expect(reloaded.events).toEqual(seed) + expect(reloaded.events).toEqual(loaded.events) } finally { await fiber.dispose() await fix.cleanup() @@ -510,7 +515,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await second.ctx.sessions.flush(s2) const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) - expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + // 0-5 the resumed seed, 6 the boundary, 7-8 the new turn. + expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) + expect(reloaded.events[6]).toMatchObject({ type: 'session/inherited' }) } finally { await second.fiber.dispose() await fix.cleanup() @@ -791,7 +798,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) await expect(ctx.sessions.flush(live)).resolves.toBeUndefined() const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) + // Seeded 0-5 plus the constructor's boundary at 6. + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6]) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' }) } finally { await fiber.dispose() await fix.cleanup() @@ -844,7 +853,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< }, { inject: ['sessions'] })) await ctx.sessions.flush(cont) const loaded = await ctx.sessionPersistence.load(SessionId('claim')) - expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + // 6-7 the claimed suffix; 8 the boundary over the whole seed. + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' }) expect(loaded.meta).toEqual(durableMeta) expect(loaded.meta.createdAt).toBe(1000) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 689bbad618..cab9271614 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1077,8 +1077,9 @@ describe('SQLite reconciliation and source lifecycle', () => { await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) db.exec('PRAGMA query_only = OFF') + // seq 2: one-event seed, its boundary, then the message appended above. await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' })) - .resolves.toMatchObject({ items: [{ seq: 1 }] }) + .resolves.toMatchObject({ items: [{ seq: 2 }] }) }) }) diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index f481d8b02e..254f76ef21 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -185,6 +185,8 @@ describe('SessionTitleService configuration and refresh boundaries', () => { 'turn/start', 'user/message', 'turn/end', + // The seeded constructor's inherited-history boundary. + 'session/inherited', 'session/title', ]) expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq]) diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index fb6ba97b32..90ae134918 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -126,9 +126,10 @@ describe('in-process policy inheritance', () => { expect(child.session.header.seedLength).toBe(1) expect(child.session.firstLiveSeq).toBe(seed.length) + // seq 1 is the inherited-history boundary. expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ { seq: 0, data: { mode: 'workspace-write' } }, - { seq: 1, data: { mode: 'read-only', source: 'delegation' } }, + { seq: 2, data: { mode: 'read-only', source: 'delegation' } }, ]) await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index a449a4053d..a429efb789 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -186,7 +186,9 @@ describe('TelemetryCoordinator adoption', () => { const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]) expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]])) - expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2]]) + // 2 the boundary, 3 the turn/end: both this lifecycle's own writes, while + // inherited 0-1 stay with the parent stream. + expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]]) }) it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => { @@ -205,14 +207,16 @@ describe('TelemetryCoordinator adoption', () => { const ofResumed = () => backend.ledger() .filter(r => r.attributes['session.id'] === 'resumed') .map(r => r.attributes['event.seq']) - expect(ofResumed()).toEqual([]) + // Nothing inherited is re-exported; seq 2 is this session's own first + // write — the boundary its constructor appended over the seed. + expect(ofResumed()).toEqual([2]) // The seed fed the projection: the (turn 1, step 1) first chunk already // shipped from the original process, so its continuation is re-dropped… resumed.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'continuation' } }) - expect(ofResumed()).toEqual([]) + expect(ofResumed()).toEqual([2]) // …while a new step's first chunk exports normally. resumed.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'next step' } }) - expect(ofResumed()).toEqual([3]) + expect(ofResumed()).toEqual([2, 4]) }) it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => { diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 5e9237574a..7456f3772b 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -23,6 +23,7 @@ import { type AgentLlmTarget, } from '@deepseek-ai/dsh-agent' import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { lastActivityTime } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' @@ -512,7 +513,8 @@ export function summarizeResumeCandidate( return { record, title, - lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, + // Excludes a prior pickup's boundary, or every browsed session floats up. + lastActivityAt: lastActivityTime(snapshot.events) ?? snapshot.session.createdAt, lastTurn: resumeTurnLabel(snapshot), currentWorkspace: record.header.cwd === cwd, workspaceLabel: formatWorkspace(record.header.cwd), diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 078332d617..de86b29833 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -35,6 +35,7 @@ import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { + lastActivityTime, SessionId, type SessionEvent, type UserMessage, @@ -985,7 +986,7 @@ export function createTuiChat( const systemPrompt = displayText(renderPrompt(assembly)) || '(empty)' const registeredTools = assembly.tools.map(tool => displayText(tool.name)).join(', ') || '(none)' const events = agent.session.events - const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt + const latestActivity = lastActivityTime(events) ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown` const contextWindow = modelController.contextWindow() diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index adf1c071bd..bb870ec004 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -837,6 +837,9 @@ describe('TUI terminal-state snapshots', () => { { type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } }, { type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } }, { type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, + // A prior pickup, dated well after the work: the picker must still + // show the work's date, not the pickup's. + { type: 'session/inherited', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} }, ], }) const harness = await setupSnapshot({ From a00786362fa415ca5778374f5e6c935985f71910 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 12:01:45 +0800 Subject: [PATCH 35/41] refactor(session): drop the unused predicate helper and tighten the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isInheritedSeq` had no production caller — only its own tests — so it was a public core export shaped by nothing. A bracket owner reads the boundary positionally; the helper belongs with the compaction seam, where a real consumer decides its signature. Also condense the `session/inherited` and `lastActivityTime` docs. --- ...0-session-inherited-log-boundary.i18n.yaml | 4 +- ...26-07-30-session-inherited-log-boundary.md | 8 +-- ...07-30-session-inherited-log-boundary.zh.md | 8 +-- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 26 ++++---- docs/core-data-structures/session.zh.md | 26 ++++---- docs/persistence-catalog.md | 28 ++++----- packages/core/session/src/index.ts | 2 +- packages/core/session/src/repair.ts | 41 ++----------- packages/core/session/src/types.ts | 24 +++----- packages/core/session/tests/repair.spec.ts | 60 +------------------ 11 files changed, 58 insertions(+), 173 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml index 99695985b8..77e99f140f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md -2026-07-30-session-inherited-log-boundary.md: 027281a7555214122b4e2d1f8457d3554a751921 -2026-07-30-session-inherited-log-boundary.zh.md: 3c169e16f0b409f125ccc29226412f239a58c6aa +2026-07-30-session-inherited-log-boundary.md: 3b9cb09d1d1a16d30fd212b439a97e3d9296bd6d +2026-07-30-session-inherited-log-boundary.zh.md: de048bc994ca97f937f74783b9055fbeb171314a diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md index 027281a755..3b9cb09d1d 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md @@ -16,9 +16,9 @@ Crash repair does not close the gap and must not: `interruptedTurnClosers` synth `Session`'s constructor appends the log-only `session/inherited` event as the seeded session's first live write, at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, the event answers it for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history. -`isInheritedSeq(events, seq)` (exported from `dsh-session`) is the predicate a bracket owner calls on an unmatched opening marker. True means the marker belongs to a lifecycle that has ended and cannot still be running. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin. +A bracket owner reads it positionally: an unmatched opening marker below the boundary belongs to a lifecycle that has ended and cannot still be running. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it. -The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case the predicate must classify. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating. +The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating. Two guards keep the marker from becoming noise. An empty seed writes nothing: a boundary with nothing below it marks nothing. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit. @@ -44,10 +44,10 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si ## Consequences -Bought: one predicate, in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary. +Bought: one boundary, written in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary. Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property. `session/inherited` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly reports nothing as inherited. -Not built here: no plugin consumes `isInheritedSeq` yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary, and it belongs with that seam's own tests. +Not built here: no plugin reads the boundary yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary; the predicate helper belongs with that seam, where a real consumer decides its shape, rather than shipping into core untested against one. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md index 3c169e16f0..de048bc994 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md @@ -16,9 +16,9 @@ Status: implemented `Session` 的构造函数把仅日志事件 `session/inherited` 作为带种子会话的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",该事件则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。 -`isInheritedSeq(events, seq)`(由 `dsh-session` 导出)是括号所有方在一个未配对开启标记上调用的谓词。为真意味着该标记属于一个已结束的生命周期,不可能仍在运行。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件。 +括号所有方按位置读取它:位于边界之下的未配对开启标记属于一个已结束的生命周期,不可能仍在运行。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。 -选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是该谓词必须判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。 +选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。 两条守卫让这个标记不至于变成噪声。空种子不写入任何内容:下方什么都没有的边界标记不了任何东西。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。 @@ -44,10 +44,10 @@ Status: implemented ## Consequences -买到的:一个谓词,位于一处,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。 +买到的:一条边界,在一处写入,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。 代价:带种子会话的日志长了一个事件,这在九个包(session、agent-loop、持久化契约、jsonl、session-query、session-title、subagent-inprocess、telemetry、token-meter)里挪动了 seq 期望。其中两处更新是承重的而非机械的:telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;而属性测试套件的重放不变式被重述为"种子逐字节复现,外加一个仅日志边界",并把幂等性补成一条独立属性。 `session/inherited` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地报告没有任何内容被继承。 -此处未做:还没有任何插件消费 `isInheritedSeq`。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作,应当与那个 seam 自己的测试一起完成。 +此处未做:还没有任何插件读取该边界。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作;谓词辅助函数应当归属那个 seam——在那里由真实消费方决定它的形状——而不是未经真实消费方检验就先落进核心。 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 257e879eee..77d44ee3ac 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 20942722c2c1f86bf9a2b6007ad42c9e97d43b91 -session.zh.md: 87ca7dc44ad4ac19456679ecbffd08cfd3023296 +session.md: 25fb5471e600541a3a2d5d2b67083f5b443dcd52 +session.zh.md: 9cf8c61d3c2ca27c4cdc6adf5804e59dd73b5f5e diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 20942722c2..25fb5471e6 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -92,23 +92,17 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * The log-only durable projection of {@link Session.firstLiveSeq}: everything - * BELOW it was inherited through a constructor seed — resume, fork, or replay - * — and no writer in this session's lifecycle produced it. Appended as the - * first live event of every seeded session. + * Log-only durable projection of {@link Session.firstLiveSeq}: everything + * below it was inherited through a constructor seed (resume, fork, or replay) + * and no writer in this lifecycle produced it. Payload is empty — position + * and `time` carry the meaning. * - * A plugin owning a standalone open/close bracket (`compact/start` … - * `compact/end`) needs it because inherited history and live work are - * otherwise byte-identical: an unmatched opening marker below this boundary - * belongs to an ended lifecycle, so it is dead whether the writer crashed, - * the process succeeded it, or the events were forked out of a parent that is - * still running. Read it through `isInheritedSeq`. - * - * NOT a liveness signal about other writers: a concurrently live session may - * hold an open bracket over the same stored history with its own boundary + * An owner of a standalone open/close bracket (`compact/start` … + * `compact/end`) reads it because inherited history and live work are + * otherwise byte-identical: an unmatched opening marker below the boundary + * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal + * about other writers — a concurrently live session holds its own boundary * elsewhere, so tolerating concurrent writers needs a signal beyond the log. - * - * The payload is empty by design — position and `time` carry the meaning. */ 'session/inherited': Record } @@ -535,7 +529,7 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c A seeded session — resume, fork, or replay — appends this log-only event as its first live write, at the seq its `firstLiveSeq` names. It is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. An empty seed writes nothing, and a seed already ending in one is not re-marked, so reopening an untouched session does not grow its log per open. -It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. `isInheritedSeq(events, seq)` is the predicate a bracket owner calls — true means the opening marker belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent). It classifies only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`. +It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker below the boundary belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`. Activity ordering excludes the boundary through `lastActivityTime(events)`: picking a session up is not work, and lazy resume means browsing writes one, so a resume picker or session list ordering by log tail would float every opened session to the top. diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 87ca7dc44a..9cf8c61d3c 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -92,23 +92,17 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * The log-only durable projection of {@link Session.firstLiveSeq}: everything - * BELOW it was inherited through a constructor seed — resume, fork, or replay - * — and no writer in this session's lifecycle produced it. Appended as the - * first live event of every seeded session. + * Log-only durable projection of {@link Session.firstLiveSeq}: everything + * below it was inherited through a constructor seed (resume, fork, or replay) + * and no writer in this lifecycle produced it. Payload is empty — position + * and `time` carry the meaning. * - * A plugin owning a standalone open/close bracket (`compact/start` … - * `compact/end`) needs it because inherited history and live work are - * otherwise byte-identical: an unmatched opening marker below this boundary - * belongs to an ended lifecycle, so it is dead whether the writer crashed, - * the process succeeded it, or the events were forked out of a parent that is - * still running. Read it through `isInheritedSeq`. - * - * NOT a liveness signal about other writers: a concurrently live session may - * hold an open bracket over the same stored history with its own boundary + * An owner of a standalone open/close bracket (`compact/start` … + * `compact/end`) reads it because inherited history and live work are + * otherwise byte-identical: an unmatched opening marker below the boundary + * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal + * about other writers — a concurrently live session holds its own boundary * elsewhere, so tolerating concurrent writers needs a signal beyond the log. - * - * The payload is empty by design — position and `time` carry the meaning. */ 'session/inherited': Record } @@ -539,7 +533,7 @@ interface TurnEndReasonMap { 带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。它是该字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。空种子不写入任何内容;种子本身已以该事件结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次打开都增长日志。 -它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。`isInheritedSeq(events, seq)` 就是括号所有方调用的谓词——为真意味着该开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来)。它只判定*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。 +它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。边界之下的开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。 活动排序通过 `lastActivityTime(events)` 排除该边界:接手会话不算工作,而惰性恢复意味着浏览就会写入一个,因此按日志尾部排序的恢复选择器或会话列表会把每个打开过的会话顶到最前。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 5e1c6526ce..3718a5770e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) ## Events @@ -408,28 +408,22 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s ```ts persistence-catalog /** - * The log-only durable projection of {@link Session.firstLiveSeq}: everything - * BELOW it was inherited through a constructor seed — resume, fork, or replay - * — and no writer in this session's lifecycle produced it. Appended as the - * first live event of every seeded session. + * Log-only durable projection of {@link Session.firstLiveSeq}: everything + * below it was inherited through a constructor seed (resume, fork, or replay) + * and no writer in this lifecycle produced it. Payload is empty — position + * and `time` carry the meaning. * - * A plugin owning a standalone open/close bracket (`compact/start` … - * `compact/end`) needs it because inherited history and live work are - * otherwise byte-identical: an unmatched opening marker below this boundary - * belongs to an ended lifecycle, so it is dead whether the writer crashed, - * the process succeeded it, or the events were forked out of a parent that is - * still running. Read it through `isInheritedSeq`. - * - * NOT a liveness signal about other writers: a concurrently live session may - * hold an open bracket over the same stored history with its own boundary + * An owner of a standalone open/close bracket (`compact/start` … + * `compact/end`) reads it because inherited history and live work are + * otherwise byte-identical: an unmatched opening marker below the boundary + * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal + * about other writers — a concurrently live session holds its own boundary * elsewhere, so tolerating concurrent writers needs a signal beyond the log. - * - * The payload is empty by design — position and `time` carry the meaning. */ 'session/inherited': Record ``` -Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) #### `session/title` — log-only diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 13d30dcd66..ba27261902 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -23,7 +23,7 @@ export * from './types.ts' export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm' export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' -export { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts' +export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts' export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts' export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index 5d841f9005..d8cf2db3c2 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -11,44 +11,11 @@ import type { ToolResultMessage } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from './types.ts' /** - * Whether the event at `seq` was inherited rather than written by the lifecycle - * that owns `events` — the stored-history reading of `Session.firstLiveSeq`. - * - * An owner of a standalone open/close bracket calls this on an unmatched - * opening marker: `true` means the operation cannot still be running, because - * the lifecycle that opened it has ended (a crashed writer, a succeeding - * process, or a parent the events were forked out of). `false` means it belongs - * to the current lifecycle and must be treated as live. - * - * Reads the log rather than a `Session`, so it serves a consumer holding only - * loaded events; in-process, compare against `session.firstLiveSeq` instead. - * @param events - the log to scan, contiguous from seq 0. - * @param seq - the event seq to classify. - * @returns true when a `session/inherited` boundary sits at or above `seq`. - */ -export function isInheritedSeq(events: readonly SessionEvent[], seq: number): boolean { - // Tail-first: an unmarked log costs no full scan, and bracket queries are - // usually about recent events. - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] - /* v8 ignore next -- a contiguous log has no holes; the guard is for the index type */ - if (event === undefined) continue - if (event.seq < seq) return false - if (event.type === 'session/inherited') return true - } - return false -} - -/** - * The `time` of the log's last event that represents actual work, skipping the - * `session/inherited` boundary. - * - * Picking a session up is not activity, and lazy resume means browsing writes a - * boundary, so activity ordering (a resume picker, a session list) must skip it - * or every opened session sorts as freshly worked in. + * The `time` of the log's last event representing actual work, skipping the + * `session/inherited` boundary — picking a session up is not activity, so + * activity ordering must exclude it. * @param events - the log to scan, in seq order. - * @returns the latest non-boundary event's `time`, or undefined when the log has - * no such event (empty, or nothing but boundaries). + * @returns the latest non-boundary event's `time`, or undefined when there is none. */ export function lastActivityTime(events: readonly SessionEvent[]): number | undefined { return events.findLast(event => event.type !== 'session/inherited')?.time diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 46b3ad0ed2..149af02468 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -251,23 +251,17 @@ export interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * The log-only durable projection of {@link Session.firstLiveSeq}: everything - * BELOW it was inherited through a constructor seed — resume, fork, or replay - * — and no writer in this session's lifecycle produced it. Appended as the - * first live event of every seeded session. + * Log-only durable projection of {@link Session.firstLiveSeq}: everything + * below it was inherited through a constructor seed (resume, fork, or replay) + * and no writer in this lifecycle produced it. Payload is empty — position + * and `time` carry the meaning. * - * A plugin owning a standalone open/close bracket (`compact/start` … - * `compact/end`) needs it because inherited history and live work are - * otherwise byte-identical: an unmatched opening marker below this boundary - * belongs to an ended lifecycle, so it is dead whether the writer crashed, - * the process succeeded it, or the events were forked out of a parent that is - * still running. Read it through `isInheritedSeq`. - * - * NOT a liveness signal about other writers: a concurrently live session may - * hold an open bracket over the same stored history with its own boundary + * An owner of a standalone open/close bracket (`compact/start` … + * `compact/end`) reads it because inherited history and live work are + * otherwise byte-identical: an unmatched opening marker below the boundary + * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal + * about other writers — a concurrently live session holds its own boundary * elsewhere, so tolerating concurrent writers needs a signal beyond the log. - * - * The payload is empty by design — position and `time` carry the meaning. */ 'session/inherited': Record } diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index b01f73a220..cb2a0d7b50 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' -import { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts' +import { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts' import type { SessionEvent, SurfaceEvent } from '../src/index.ts' /** @@ -274,64 +274,6 @@ describe('interruptedTurnClosers', () => { }) }) -/** - * The stored-history reading of the inherited boundary. A bracket owner calls - * this on an unmatched opening marker to decide whether the operation can still - * be running, so the classification of the marker's own seq — and of the - * boundary seq itself — is the contract. - */ -describe('isInheritedSeq', () => { - const inheritedAt = (seq: number): SessionEvent => - ({ type: 'session/inherited', seq, time: seq, data: {} }) - - it('classifies nothing as inherited in a log without a boundary', () => { - const events: SessionEvent[] = [ - userTurnStart(1, 0), - { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, - ] - expect(isInheritedSeq(events, 0)).toBe(false) - expect(isInheritedSeq(events, 1)).toBe(false) - }) - - it('treats an empty log as owning nothing', () => { - expect(isInheritedSeq([], 0)).toBe(false) - }) - - it('splits the log at the boundary', () => { - // seqs 0-1 inherited; the boundary at 2; seq 3 written by this lifecycle. - const events: SessionEvent[] = [ - userTurnStart(1, 0), - { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, - inheritedAt(2), - userTurnStart(2, 3), - ] - expect(isInheritedSeq(events, 0)).toBe(true) - expect(isInheritedSeq(events, 1)).toBe(true) - // The boundary's own seq counts as inherited: it belongs to the pickup. - expect(isInheritedSeq(events, 2)).toBe(true) - expect(isInheritedSeq(events, 3)).toBe(false) - }) - - it('reports inherited for an event below a later boundary', () => { - // Two pickups in turn: the tail scan must not stop at the nearer boundary. - const events: SessionEvent[] = [ - userTurnStart(1, 0), - inheritedAt(1), - userTurnStart(2, 2), - inheritedAt(3), - userTurnStart(3, 4), - ] - expect(isInheritedSeq(events, 0)).toBe(true) - expect(isInheritedSeq(events, 2)).toBe(true) - expect(isInheritedSeq(events, 4)).toBe(false) - }) -}) - -/** - * Activity ordering excludes the pickup boundary. A resume picker or session - * list sorting by log tail would otherwise promote every session the user - * merely opened above the ones they actually worked in. - */ describe('lastActivityTime', () => { const inheritedAt = (seq: number, time: number): SessionEvent => ({ type: 'session/inherited', seq, time, data: {} }) From 39ebd8f7457851848ade8dbc6b0b181fe0d957a4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 13:59:08 +0800 Subject: [PATCH 36/41] fix(session): close the review gaps the boundary opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SessionSummary.updatedAt`'s wire doc still said "Persisted file mtime", which stopped being true for attached sessions. - The core invariant let `session/inherited` fall through the merge-extensible default. It is core-owned, so it gets an explicit case; an unbalanced seed legally places it inside an open turn, which the relation permits. - The Agent Note claimed the boundary reaches disk via `live.pending`/ `scheduleDrain`. Verified false: the constructor append precedes `enter()`, so it never publishes on `session/event` and rides the creation seed instead. Attaching is therefore a write where none happened before — recorded, since only `load()` stays a pure read. - The deferred-index proposal asserted this change documented the cold-mtime skew on `dsh-host-apiproxy`. It did not; the README entry now exists. - `firstLiveSeq`'s firehose gap runs through its own seq, not below it. - The boundary is not always at `firstLiveSeq` (the idempotence guard), so consumers scan for the last one. - `lastActivityTime` excludes by type, so a pickup time still leaks onto a synthetic closer when a boundary ends an open turn. Documented. - Pin the fork claim end-to-end: a child inherits a still-running parent's open bracket below its own boundary, while the parent has none. Fails if the write moves back to the load path. - Fix the telemetry title that contradicted its own assertions. The `/status` call site cannot be pinned the way the other two are: the command appends its own `command/run` before rendering, so the boundary is never the log tail there. Its fixture now at least renders over a boundary-bearing log. --- ...0-session-inherited-log-boundary.i18n.yaml | 4 ++-- ...26-07-30-session-inherited-log-boundary.md | 6 +++-- ...07-30-session-inherited-log-boundary.zh.md | 6 +++-- ...7-29-durable-last-activity-index.i18n.yaml | 4 ++-- .../2026-07-29-durable-last-activity-index.md | 2 +- ...26-07-29-durable-last-activity-index.zh.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.i18n.yaml | 4 ++-- docs/core-data-structures/session.md | 8 +++++++ docs/core-data-structures/session.zh.md | 8 +++++++ docs/persistence-catalog.md | 8 +++++-- packages/core/session/src/index.ts | 4 ++++ packages/core/session/src/invariant.ts | 3 +++ packages/core/session/src/repair.ts | 5 ++++ packages/core/session/src/types.ts | 4 ++++ packages/core/session/tests/fork.spec.ts | 23 +++++++++++++++++++ packages/core/session/tests/invariant.spec.ts | 18 +++++++++++++++ packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 1 + packages/host/apiproxy/README.zh.md | 1 + packages/host/apiproxy/src/api/sessions.ts | 6 ++++- .../apiproxy/tests/api-proxy-cold.spec.ts | 3 +-- .../session-telemetry/tests/telemetry.spec.ts | 2 +- .../status-diagnostics-narrow.expected.txt | 2 +- .../snapshots/status-diagnostics.expected.txt | 2 +- packages/ui/tui/tests/tui.snapshot.ts | 6 +++++ 26 files changed, 115 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml index 77e99f140f..7ead9ed6b4 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md -2026-07-30-session-inherited-log-boundary.md: 3b9cb09d1d1a16d30fd212b439a97e3d9296bd6d -2026-07-30-session-inherited-log-boundary.zh.md: de048bc994ca97f937f74783b9055fbeb171314a +2026-07-30-session-inherited-log-boundary.md: b5ed9598078fae6fedd4c92613d57a7632eee323 +2026-07-30-session-inherited-log-boundary.zh.md: 83d3ce9ae87728228bcdfe73db1b2049bd55cc61 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md index 3b9cb09d1d..b5ed959807 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md @@ -24,9 +24,11 @@ Two guards keep the marker from becoming noise. An empty seed writes nothing: a ## Persistence needs no changes -The marker is in `session.events` before the coordinator captures its creation seed, so it persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. No load-path write, no revision bump at load, no durable mark on a rejected `append`, and a read-only store still serves loads. +The constructor append happens before `enter()`, so the session has no store attachment: the marker never publishes on `session/event`, exactly like the seed events below it. It is instead part of the log `initFor` captures as the creation seed, and persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log. -Being a live event, it reaches disk through the write-behind drain (`session/event` → `live.pending` → `scheduleDrain`) rather than a synchronous commit, so a crash can lose it. That costs nothing: `pending` drains in order, so a lost boundary means every live event above it is lost too, and the next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write. +Consequences for the seam: `load()` stays a pure read, with no revision bump, no `commitRepair` on a balanced log, and no durable mark left by a rejected `append`. **Attaching is not a pure read**, though — a pickup now writes where nothing was written before, so a read-only or full disk fails at `session/created` rather than at the first real turn. That is the one cost this placement adds, and it is narrower than the load-path version's (which failed the load itself). + +A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event above it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write. ## Scope of the guarantee diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md index de048bc994..83d3ce9ae8 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md @@ -24,9 +24,11 @@ Status: implemented ## 持久化无需任何改动 -协调器捕获创建种子时该标记已在 `session.events` 中,因此它通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。没有加载路径写入、加载时没有 revision 递增、被拒绝的 `append` 不留下持久标记,只读存储依然可以服务加载。 +构造函数中的 append 发生在 `enter()` 之前,因此会话尚无 store attachment:该标记不会在 `session/event` 上发布,与它下方的种子事件完全一样。它属于 `initFor` 捕获的那份创建种子,并通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。 -作为实时事件,它经由后写式 drain(`session/event` → `live.pending` → `scheduleDrain`)而不是同步提交到达磁盘,因此崩溃可能丢掉它。这没有代价:`pending` 按序 drain,所以丢掉一个边界意味着它上面的每个实时事件也一起丢掉,而下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。 +对 seam 的影响:`load()` 仍是纯读取,没有 revision 递增,对平衡日志不走 `commitRepair`,被拒绝的 `append` 也不留下持久标记。但**接手不是纯读取**——如今一次拾起会在此前完全无写入的路径上产生写入,因此只读存储或磁盘写满会在 `session/created` 处报错,而不是在第一个真实轮次处。这是本放置方式新增的唯一成本,并且比加载路径方案的成本更窄(后者会让加载本身失败)。 + +若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它上面的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。 ## 保证的适用范围 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml index 643238c384..fb497a66e5 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md -2026-07-29-durable-last-activity-index.md: 7d8a83e96d9339054b0d15b2331284c8aecfe65d -2026-07-29-durable-last-activity-index.zh.md: b081aa91cfa51ef8bb87e93f4091431f2d6ddce1 +2026-07-29-durable-last-activity-index.md: 2569ce8dcdef4ce85713e4c3905c3a170be00a98 +2026-07-29-durable-last-activity-index.zh.md: 64078982bb09b0f94c41d5424d195d371c1a263f diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md index 7d8a83e96d..2569ce8dcd 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md @@ -12,7 +12,7 @@ mtime answers a different question: when the artifact was last written. Every du The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size. -The boundary change raised the frequency of this defect and documented it as a known limitation on `dsh-host-apiproxy`. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there. +The [boundary change](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there. ## Proposal diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md index b081aa91cf..64078982bb 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md @@ -12,7 +12,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入 已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。 -边界那次变更提高了这个缺陷的出现频率,并把它作为一项已知限制记录在 `dsh-host-apiproxy` 上。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。 +[边界那次变更](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。 ## 提案 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 316c5a9830..1ddfba85ec 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:706`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:710`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 77d44ee3ac..9ffcfd8772 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 25fb5471e600541a3a2d5d2b67083f5b443dcd52 -session.zh.md: 9cf8c61d3c2ca27c4cdc6adf5804e59dd73b5f5e +session.md: 03a1a3ee3350009e2f26cc52ff013643a04b5bd9 +session.zh.md: d1c676f1c9297582d9e80ed5d15af78c7ef68e52 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 25fb5471e6..03a1a3ee33 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -97,6 +97,10 @@ interface SessionEventMap { * and no writer in this lifecycle produced it. Payload is empty — position * and `time` carry the meaning. * + * Locate the LAST one rather than reading `firstLiveSeq`: a seed already + * ending in a boundary is not re-marked, so reopening an untouched session + * does not grow its log per pickup. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary @@ -346,6 +350,10 @@ declare class Session { * `session/inherited` event at this seq, which is what a consumer reading * STORED history reads. Prefer this field in-process — it is exact before * the marker's write reaches storage. + * + * The marker is appended before the store attaches, so when one exists the + * event AT this seq did not publish either: the firehose gap runs through + * `firstLiveSeq`, not just below it. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 9cf8c61d3c..d1c676f1c9 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -97,6 +97,10 @@ interface SessionEventMap { * and no writer in this lifecycle produced it. Payload is empty — position * and `time` carry the meaning. * + * Locate the LAST one rather than reading `firstLiveSeq`: a seed already + * ending in a boundary is not re-marked, so reopening an untouched session + * does not grow its log per pickup. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary @@ -348,6 +352,10 @@ declare class Session { * `session/inherited` event at this seq, which is what a consumer reading * STORED history reads. Prefer this field in-process — it is exact before * the marker's write reaches storage. + * + * The marker is appended before the store attaches, so when one exists the + * event AT this seq did not publish either: the firehose gap runs through + * `firstLiveSeq`, not just below it. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3718a5770e..7498adce79 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) ## Events @@ -413,6 +413,10 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s * and no writer in this lifecycle produced it. Payload is empty — position * and `time` carry the meaning. * + * Locate the LAST one rather than reading `firstLiveSeq`: a seed already + * ending in a boundary is not re-marked, so reopening an untouched session + * does not grow its log per pickup. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary @@ -423,7 +427,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/inherited': Record ``` -Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) #### `session/title` — log-only diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index ba27261902..318224ff30 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -394,6 +394,10 @@ export class Session { * `session/inherited` event at this seq, which is what a consumer reading * STORED history reads. Prefer this field in-process — it is exact before * the marker's write reaches storage. + * + * The marker is appended before the store attaches, so when one exists the + * event AT this seq did not publish either: the firehose gap runs through + * `firstLiveSeq`, not just below it. */ readonly firstLiveSeq: number diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index e57db4fe1a..5a119f3297 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -144,6 +144,9 @@ function validateEvent( } case 'user/message': break + case 'session/inherited': + // Unconstrained: an unbalanced seed legally puts it inside an open turn. + break case 'steering/message': case 'todo/write': case 'request/header': { diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index d8cf2db3c2..1a76b2d4a8 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -14,6 +14,11 @@ import type { SessionEvent } from './types.ts' * The `time` of the log's last event representing actual work, skipping the * `session/inherited` boundary — picking a session up is not activity, so * activity ordering must exclude it. + * + * Excluded by type, so a pickup time still leaks when a boundary is the last + * event of an open turn: {@link interruptedTurnClosers} copies it onto the + * synthetic `turn/end`, which this counts as work. Reachable only by seeding an + * unbalanced log directly — `load()` balances first. * @param events - the log to scan, in seq order. * @returns the latest non-boundary event's `time`, or undefined when there is none. */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 149af02468..35bd493e1f 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -256,6 +256,10 @@ export interface SessionEventMap { * and no writer in this lifecycle produced it. Payload is empty — position * and `time` carry the meaning. * + * Locate the LAST one rather than reading `firstLiveSeq`: a seed already + * ending in a boundary is not re-marked, so reopening an untouched session + * does not grow its log per pickup. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index fcfbd229cf..e32191a36b 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -7,6 +7,8 @@ import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { 'test/log-only': { value: string } + /** Stands in for a plugin's open/close bracket (`compact/start`). */ + 'test/bracket-open': { id: string } } } @@ -154,6 +156,27 @@ describe('SessionStore.fork', () => { } }) + it('marks a bracket the child inherited from a still-running parent', async () => { + // The constructor placement's central claim, unreachable from the + // persistence load path. + const { ctx, sessions } = await setup() + const parent = ctx.sessions.create(SessionId('bracket-parent'), { meta: { cwd: '/workspace' } }) + appendClosedTurn(parent, 1, 'work') + const open = parent.append('test/bracket-open', { id: 'op-1' }) + + const child = sessions.fork(parent, undefined, SessionId('bracket-child')) + + // Parent: nothing above the bracket, so its owner must treat it as live. + expect(parent.events.at(-1)).toBe(open) + expect(parent.events.some(event => event.type === 'session/inherited')).toBe(false) + // Child: the same bracket sits below its boundary, so it is dead history. + const boundary = child.events.at(-1) + expect(boundary).toMatchObject({ type: 'session/inherited' }) + expect(boundary!.seq).toBeGreaterThan(open.seq) + expect(child.firstLiveSeq).toBe(open.seq + 1) + expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } }) + }) + it('rejects invalid boundaries before creating a child', async () => { const { ctx, sessions } = await setup() const empty = ctx.sessions.create(SessionId('empty')) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index aa0cd5bd64..281ba50f4c 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -382,6 +382,24 @@ describe('session-log invariants', () => { .toThrow(/turn 1 is still open/) }) + it('accepts the inherited boundary whether or not a turn is open', async () => { + const { ctx } = await setup() + // Balanced seed: between turns. + expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] })).not.toThrow() + // Unbalanced seed: inside the open turn, which the relation permits. + const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ] }) + expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited']) + // Still open afterwards: the boundary moves no cursor. + expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) + .toThrow(/turn 1 is still open/) + expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow() + }) + it('removes all listeners when the companion is disposed', async () => { const { ctx, fiber } = await setup() const session = ctx.sessions.create() diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 18202ed036..95438c076e 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 -README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 +README.md: 3123f64d19a77e0082b8d0ed6f93d279da0eb065 +README.zh.md: 0bc2886aae3c3492d8361acabfec163a10eb4b30 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1f0daedc54..3123f64d19 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -42,3 +42,4 @@ None; this package neither assembles nor sends a provider request. - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). +- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/inherited` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index abf5417cdb..0bc2886aae 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -42,3 +42,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。 +- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/inherited` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 3a824c8261..bdb3598d1d 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -127,7 +127,11 @@ export interface SessionModels { /** Session list entry (v1 builds no index: list does readdir+stat). */ export interface SessionSummary { sessionId: SessionId - /** Persisted file mtime. */ + /** + * Last activity. Attached: the last non-`session/inherited` event, since a + * pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend + * with no per-session file (README Known Limitations covers the skew). + */ updatedAt: number /** Status of the attached agent; always false for cold (unattached) sessions. */ running: boolean diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index d994444df1..2b37bc3cb9 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -86,8 +86,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => { await ctx.plugin(AgentRegistry) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) - // Old work, resumed just now: the boundary's time is far above the work's, - // so reading the log tail would report the pickup as activity. + // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 const resumed = ctx.sessions.create(sid('resumed-untouched'), { seed: [ diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index a429efb789..58228179d5 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -191,7 +191,7 @@ describe('TelemetryCoordinator adoption', () => { expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]]) }) - it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => { + it('resume shape: a full-log seed exports only its own boundary and rebuilds the chunk projection', async () => { const backend = new FakeBackend() const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt index 58cc124b75..b35a53ed0d 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt @@ -48,7 +48,7 @@ buffer 17| "│ │" style 0-0 dim style 55-55 dim -18| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 │" +18| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 │" style 0-0 dim style 3-12 dim style 55-55 dim diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt index b5f776918f..70ec6a3a04 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -45,7 +45,7 @@ buffer 16| "│ │" style 0-0 dim style 81-81 dim -17| "│ Agent: idle · 7 events · 1 turn · 1 step · 1 tool call │" +17| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 tool call │" style 0-0 dim style 3-12 dim style 81-81 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index bb870ec004..912febae70 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -909,6 +909,12 @@ describe('TUI terminal-state snapshots', () => { messageSeqs: [1], source: { kind: 'fallback' }, }) + // Renders over a boundary-bearing log. It cannot pin the exclusion: + // `/status` appends its own `command/run` first, so the boundary is + // never the tail here. The other two call sites pin it. + dateNow.mockReturnValue(Date.parse('2026-07-22T10:10:11.000Z')) + session.append('session/inherited', {}) + dateNow.mockReturnValue(Date.parse('2026-07-22T09:10:11.000Z')) }, }, { columns: 92, rows: 32 }) await renderAfter(harness, () => { From 3e57bd3d0beb0392c800c08d13f0350ecf58aef6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 14:21:04 +0800 Subject: [PATCH 37/41] chore(session): re-record the pairing and catalog fixture after the master merge --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index ea8dad9a96..972b9d7e67 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/inherited': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 4664ba28df..d48fe81928 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 7129842a0cc89f5fa10c0bceec7cf0997ac71a99 -README.zh.md: 8765627236ea7af9e5cd3b7029133181ef499905 +README.md: c745dd6fb142b5b5a50b4593e3ce0223e2be6728 +README.zh.md: 7ddd7a37f7dd45f683a93601637c75b3f1bd8fd2 From b257ed5e8a8e8b64d2bdadef9a8e426aac35e6fa Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 14:46:38 +0800 Subject: [PATCH 38/41] docs(session): make the boundary's position and ownership conditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation only; no behavior change. `firstLiveSeq`'s JSDoc still stated the boundary sits at that seq unconditionally. Verified reachable on a plain production path: pick up a log, do no work, pick it up again — the seed already ends in a boundary, so it is not re-marked and `events[firstLiveSeq]` is undefined. Both that claim and the firehose-gap sentence are now conditional, with the locate rule ("scan for the last boundary") matching what types.ts already said. `repair.ts`'s header claimed this module supplies the boundary. It does not — the constructor does; this module supplies the activity read that must skip it. Reworded, and it now names the closer timestamp leak, which is the one real coupling that justifies `lastActivityTime` living beside the repair synthesis. Recorded that `Session`'s constructor is the boundary's only legitimate writer, since the invariant companion constrains nothing and a plugin-side append would silently turn live brackets below it into dead history. --- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.i18n.yaml | 4 ++-- docs/core-data-structures/session.md | 25 ++++++++++++++------- docs/core-data-structures/session.zh.md | 25 ++++++++++++++------- docs/persistence-catalog.md | 8 +++++-- packages/core/session/src/index.ts | 17 ++++++++------ packages/core/session/src/repair.ts | 6 +++-- packages/core/session/src/types.ts | 4 ++++ 8 files changed, 61 insertions(+), 30 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2df12320f2..8970d7ae1c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:710`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:713`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 9ffcfd8772..95b2754409 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 03a1a3ee3350009e2f26cc52ff013643a04b5bd9 -session.zh.md: d1c676f1c9297582d9e80ed5d15af78c7ef68e52 +session.md: fd9dcf6c6c6127a026286f11b6c5dcf16f505abb +session.zh.md: 23202e2b4c4109c4ea4abd79c6409aaf3b593679 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 03a1a3ee33..fd9dcf6c6c 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -101,6 +101,10 @@ interface SessionEventMap { * ending in a boundary is not re-marked, so reopening an untouched session * does not grow its log per pickup. * + * `Session`'s constructor is the only legitimate writer. The invariant + * companion deliberately constrains nothing here, so a plugin appending one + * would silently turn every live bracket below it into dead history. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary @@ -346,14 +350,17 @@ declare class Session { * session's constructor seed is its full stored log, while its header keeps * the original fork value — this field is the in-process construction fact. * - * Not persisted itself: a nonzero value is projected into the log as the - * `session/inherited` event at this seq, which is what a consumer reading - * STORED history reads. Prefer this field in-process — it is exact before - * the marker's write reaches storage. + * Not persisted itself: a seeded session projects it into the log as the + * `session/inherited` event, which is what a consumer reading STORED history + * reads. Locate that event as the log's LAST boundary, not at this seq — a + * seed already ending in one is not re-marked, so reopening an untouched + * session leaves the boundary below `firstLiveSeq`. Prefer this field + * in-process: it is exact before the marker's write reaches storage. * - * The marker is appended before the store attaches, so when one exists the - * event AT this seq did not publish either: the firehose gap runs through - * `firstLiveSeq`, not just below it. + * When this lifecycle did append a boundary it sits at this seq, appended + * before the store attached, so that event did not publish either — the + * firehose gap then runs through `firstLiveSeq` rather than stopping below + * it. Otherwise this seq holds an ordinary published write. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); @@ -535,7 +542,9 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c ## The inherited-history boundary: `session/inherited` -A seeded session — resume, fork, or replay — appends this log-only event as its first live write, at the seq its `firstLiveSeq` names. It is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. An empty seed writes nothing, and a seed already ending in one is not re-marked, so reopening an untouched session does not grow its log per open. +A seeded session — resume, fork, or replay — appends this log-only event as its first live write. It is the durable projection of `firstLiveSeq`: that field answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer. + +An empty seed writes nothing, and a seed already ending in a boundary is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the boundary as the log's LAST one rather than at `firstLiveSeq`: after a pickup with no work, the next one leaves it below that seq. It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker below the boundary belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`. diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index d1c676f1c9..23202e2b4c 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -101,6 +101,10 @@ interface SessionEventMap { * ending in a boundary is not re-marked, so reopening an untouched session * does not grow its log per pickup. * + * `Session`'s constructor is the only legitimate writer. The invariant + * companion deliberately constrains nothing here, so a plugin appending one + * would silently turn every live bracket below it into dead history. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary @@ -348,14 +352,17 @@ declare class Session { * session's constructor seed is its full stored log, while its header keeps * the original fork value — this field is the in-process construction fact. * - * Not persisted itself: a nonzero value is projected into the log as the - * `session/inherited` event at this seq, which is what a consumer reading - * STORED history reads. Prefer this field in-process — it is exact before - * the marker's write reaches storage. + * Not persisted itself: a seeded session projects it into the log as the + * `session/inherited` event, which is what a consumer reading STORED history + * reads. Locate that event as the log's LAST boundary, not at this seq — a + * seed already ending in one is not re-marked, so reopening an untouched + * session leaves the boundary below `firstLiveSeq`. Prefer this field + * in-process: it is exact before the marker's write reaches storage. * - * The marker is appended before the store attaches, so when one exists the - * event AT this seq did not publish either: the firehose gap runs through - * `firstLiveSeq`, not just below it. + * When this lifecycle did append a boundary it sits at this seq, appended + * before the store attached, so that event did not publish either — the + * firehose gap then runs through `firstLiveSeq` rather than stopping below + * it. Otherwise this seq holds an ordinary published write. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); @@ -539,7 +546,9 @@ interface TurnEndReasonMap { ## 继承历史边界:`session/inherited` -带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。它是该字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。空种子不写入任何内容;种子本身已以该事件结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次打开都增长日志。 +带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。 + +空种子不写入任何内容;种子本身已以该边界结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。定位边界应取日志中的**最后一条**,而不是读 `firstLiveSeq`:在一次没有产生工作的拾起之后,下一次拾起会让边界落在该 seq 之下。 它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。边界之下的开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7498adce79..6acc650789 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:346`](../packages/core/session/src/types.ts) ## Events @@ -417,6 +417,10 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s * ending in a boundary is not re-marked, so reopening an untouched session * does not grow its log per pickup. * + * `Session`'s constructor is the only legitimate writer. The invariant + * companion deliberately constrains nothing here, so a plugin appending one + * would silently turn every live bracket below it into dead history. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary @@ -427,7 +431,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/inherited': Record ``` -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) #### `session/title` — log-only diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 318224ff30..e4786e7222 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -390,14 +390,17 @@ export class Session { * session's constructor seed is its full stored log, while its header keeps * the original fork value — this field is the in-process construction fact. * - * Not persisted itself: a nonzero value is projected into the log as the - * `session/inherited` event at this seq, which is what a consumer reading - * STORED history reads. Prefer this field in-process — it is exact before - * the marker's write reaches storage. + * Not persisted itself: a seeded session projects it into the log as the + * `session/inherited` event, which is what a consumer reading STORED history + * reads. Locate that event as the log's LAST boundary, not at this seq — a + * seed already ending in one is not re-marked, so reopening an untouched + * session leaves the boundary below `firstLiveSeq`. Prefer this field + * in-process: it is exact before the marker's write reaches storage. * - * The marker is appended before the store attaches, so when one exists the - * event AT this seq did not publish either: the firehose gap runs through - * `firstLiveSeq`, not just below it. + * When this lifecycle did append a boundary it sits at this seq, appended + * before the store attached, so that event did not publish either — the + * firehose gap then runs through `firstLiveSeq` rather than stopping below + * it. Otherwise this seq holds an ordinary published write. */ readonly firstLiveSeq: number diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index 1a76b2d4a8..a4e82dabfb 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -1,8 +1,10 @@ /** * Crash-recovery repair for an interrupted session log. It preserves a fully * written final turn and supplies the missing tool, step, and turn boundaries - * needed to resume with a provider-valid transcript, plus the inherited-history - * boundary a plugin-owned bracket reads to tell dead history from live work. + * needed to resume with a provider-valid transcript, plus the activity-time + * read that must skip the inherited-history boundary — which this module does + * not write (`Session`'s constructor does) but whose synthetic closers can + * inherit that boundary's timestamp, the one real coupling between the two. * @module @deepseek-ai/dsh-session/repair */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 35bd493e1f..aca980ccfe 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -260,6 +260,10 @@ export interface SessionEventMap { * ending in a boundary is not re-marked, so reopening an untouched session * does not grow its log per pickup. * + * `Session`'s constructor is the only legitimate writer. The invariant + * companion deliberately constrains nothing here, so a plugin appending one + * would silently turn every live bracket below it into dead history. + * * An owner of a standalone open/close bracket (`compact/start` … * `compact/end`) reads it because inherited history and live work are * otherwise byte-identical: an unmatched opening marker below the boundary From b21f13baa14673e7587dfe237e2f2a854168ff56 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:21:42 +0800 Subject: [PATCH 39/41] test: update tool agent fixtures for inbox API --- packages/fs/tool-str-replace-editor/tests/tools.spec.ts | 1 + .../pty/tool-bash-persistent/tests/loader-composition.spec.ts | 1 + packages/pty/tool-bash-persistent/tests/tools.spec.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 9a948ed164..0a3b65ba5e 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -39,6 +39,7 @@ function agent(ctx: Context, cwd: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index b8586161f2..9e18402477 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -49,6 +49,7 @@ function agent(ctx: Context, cwd: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 4b5401a256..9949879292 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -45,6 +45,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } From 8527137230564fb78c2a3f0eb6c2f728ac92e23f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 15:32:06 +0800 Subject: [PATCH 40/41] refactor(session): rename the seed boundary to end-seed --- ...0-session-end-seed-log-boundary.i18n.yaml} | 6 +- ...26-07-30-session-end-seed-log-boundary.md} | 18 ++--- ...07-30-session-end-seed-log-boundary.zh.md} | 18 ++--- ...7-29-durable-last-activity-index.i18n.yaml | 4 +- .../2026-07-29-durable-last-activity-index.md | 10 +-- ...26-07-29-durable-last-activity-index.zh.md | 10 +-- ...-29-session-resumed-log-boundary.i18n.yaml | 4 +- ...2026-07-29-session-resumed-log-boundary.md | 4 +- ...6-07-29-session-resumed-log-boundary.zh.md | 4 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 67 ++++++++++--------- docs/core-data-structures/session.zh.md | 67 ++++++++++--------- docs/persistence-catalog.md | 35 +++++----- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../snapshots/subagent-fork/session.1.jsonl | 10 +-- .../snapshots/subagent-mixed/session.2.jsonl | 10 +-- .../session.expected.jsonl | 2 +- .../parent-override/parent.expected.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 6 +- packages/core/session/src/index.ts | 34 +++++----- packages/core/session/src/invariant.ts | 2 +- packages/core/session/src/repair.ts | 6 +- packages/core/session/src/types.ts | 29 ++++---- packages/core/session/tests/fork.spec.ts | 12 ++-- packages/core/session/tests/invariant.spec.ts | 4 +- .../core/session/tests/properties.spec.ts | 2 +- packages/core/session/tests/repair.spec.ts | 12 ++-- packages/core/session/tests/session.spec.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 2 +- packages/host/apiproxy/src/api/sessions.ts | 2 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 6 +- .../llm/token-meter/tests/token-meter.spec.ts | 4 +- .../tests/jsonl.spec.ts | 4 +- .../tests/coordinator-contract.ts | 18 ++--- .../session-query-sqlite/tests/sqlite.spec.ts | 2 +- .../tests/service-contracts.spec.ts | 4 +- .../tests/inheritance.spec.ts | 2 +- .../session-telemetry/tests/telemetry.spec.ts | 6 +- packages/ui/tui/tests/tui.snapshot.ts | 4 +- 44 files changed, 228 insertions(+), 224 deletions(-) rename .agents/notes/implemented/architecture/{2026-07-30-session-inherited-log-boundary.i18n.yaml => 2026-07-30-session-end-seed-log-boundary.i18n.yaml} (55%) rename .agents/notes/implemented/architecture/{2026-07-30-session-inherited-log-boundary.md => 2026-07-30-session-end-seed-log-boundary.md} (70%) rename .agents/notes/implemented/architecture/{2026-07-30-session-inherited-log-boundary.zh.md => 2026-07-30-session-end-seed-log-boundary.zh.md} (73%) diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml similarity index 55% rename from .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index 7ead9ed6b4..d0d8d7b82f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md -2026-07-30-session-inherited-log-boundary.md: b5ed9598078fae6fedd4c92613d57a7632eee323 -2026-07-30-session-inherited-log-boundary.zh.md: 83d3ce9ae87728228bcdfe73db1b2049bd55cc61 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +2026-07-30-session-end-seed-log-boundary.md: 837531ba0bd3ecf404eb47ee933438546c682a54 +2026-07-30-session-end-seed-log-boundary.zh.md: 33680c1845364de62e5b53ead13de418a389f908 diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md similarity index 70% rename from .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md rename to .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index b5ed959807..837531ba0b 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -1,8 +1,8 @@ -# Agent Note: the inherited-history log boundary +# Agent Note: the end-seed log boundary Status: implemented -English | [中文](2026-07-30-session-inherited-log-boundary.zh.md) +English | [中文](2026-07-30-session-end-seed-log-boundary.zh.md) ## Problem @@ -14,21 +14,21 @@ Crash repair does not close the gap and must not: `interruptedTurnClosers` synth ## Decision -`Session`'s constructor appends the log-only `session/inherited` event as the seeded session's first live write, at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, the event answers it for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history. +`Session`'s constructor appends the log-only `session/end-seed` event immediately after a non-empty constructor seed, as the seeded session's first live write at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers where this lifecycle's writes start for a consumer holding the object, while `session/end-seed` answers the same question for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history. -A bracket owner reads it positionally: an unmatched opening marker below the boundary belongs to a lifecycle that has ended and cannot still be running. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it. +A bracket owner reads it positionally: an unmatched opening marker before `session/end-seed` has a smaller seq, came from the constructor seed, and belongs to a lifecycle that has ended. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it. The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating. -Two guards keep the marker from becoming noise. An empty seed writes nothing: a boundary with nothing below it marks nothing. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit. +Two guards keep the marker from becoming noise. An empty seed writes nothing because there is no seed to end. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit. ## Persistence needs no changes -The constructor append happens before `enter()`, so the session has no store attachment: the marker never publishes on `session/event`, exactly like the seed events below it. It is instead part of the log `initFor` captures as the creation seed, and persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log. +The constructor append happens before `enter()`, so the session has no store attachment: the marker never publishes on `session/event`, exactly like the seed events before it. It is instead part of the log `initFor` captures as the creation seed, and persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log. Consequences for the seam: `load()` stays a pure read, with no revision bump, no `commitRepair` on a balanced log, and no durable mark left by a rejected `append`. **Attaching is not a pure read**, though — a pickup now writes where nothing was written before, so a read-only or full disk fails at `session/created` rather than at the first real turn. That is the one cost this placement adds, and it is narrower than the load-path version's (which failed the load itself). -A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event above it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write. +A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event after it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write. ## Scope of the guarantee @@ -38,7 +38,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si **A boundary written by the persistence coordinator's cold-load path.** Built first, as the [`session/resumed` boundary](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md), and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store. -**A boundary appended at loop start.** The loop is one call above `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary. +**A boundary appended at loop start.** The loop calls `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary. **Reusing `header.seedLength`.** It is the durable *fork-lineage* boundary and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both. @@ -50,6 +50,6 @@ Bought: one boundary, written in one place, correct for all six seeded-start pat Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property. -`session/inherited` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly reports nothing as inherited. +`session/end-seed` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly classifies nothing as constructor-seed history. Not built here: no plugin reads the boundary yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary; the predicate helper belongs with that seam, where a real consumer decides its shape, rather than shipping into core untested against one. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md similarity index 73% rename from .agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md rename to .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index 83d3ce9ae8..33680c1845 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-inherited-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -1,8 +1,8 @@ -# Agent Note: 继承历史日志边界 +# Agent Note: 种子结束日志边界 Status: implemented -[English](2026-07-30-session-inherited-log-boundary.md) | 中文 +[English](2026-07-30-session-end-seed-log-boundary.md) | 中文 ## Problem @@ -14,21 +14,21 @@ Status: implemented ## Decision -`Session` 的构造函数把仅日志事件 `session/inherited` 作为带种子会话的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",该事件则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。 +`Session` 的构造函数紧接非空构造种子之后追加仅日志事件 `session/end-seed`,作为带种子会话的第一次实时写入,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答本生命周期的写入从哪里开始,`session/end-seed` 则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。 -括号所有方按位置读取它:位于边界之下的未配对开启标记属于一个已结束的生命周期,不可能仍在运行。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。 +括号所有方按位置读取它:在 `session/end-seed` 之前的未配对开启标记具有更小的 seq,来自构造种子,并且属于一个已结束的生命周期。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。 选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。 -两条守卫让这个标记不至于变成噪声。空种子不写入任何内容:下方什么都没有的边界标记不了任何东西。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。 +两条守卫让这个标记不至于变成噪声。空种子不写入任何内容,因为没有种子需要结束。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。 ## 持久化无需任何改动 -构造函数中的 append 发生在 `enter()` 之前,因此会话尚无 store attachment:该标记不会在 `session/event` 上发布,与它下方的种子事件完全一样。它属于 `initFor` 捕获的那份创建种子,并通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。 +构造函数中的 append 发生在 `enter()` 之前,因此会话尚无 store attachment:该标记不会在 `session/event` 上发布,与它之前的种子事件完全一样。它属于 `initFor` 捕获的那份创建种子,并通过普通的种子路径落盘——`onCreated` 的 `createCore` + `appendCore`,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。 对 seam 的影响:`load()` 仍是纯读取,没有 revision 递增,对平衡日志不走 `commitRepair`,被拒绝的 `append` 也不留下持久标记。但**接手不是纯读取**——如今一次拾起会在此前完全无写入的路径上产生写入,因此只读存储或磁盘写满会在 `session/created` 处报错,而不是在第一个真实轮次处。这是本放置方式新增的唯一成本,并且比加载路径方案的成本更窄(后者会让加载本身失败)。 -若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它上面的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。 +若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它之后的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。 ## 保证的适用范围 @@ -38,7 +38,7 @@ Status: implemented **由持久化协调器的冷加载路径写入边界。** 最先实现的方案,即 [`session/resumed` 边界](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md),在合并前被放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。 -**在 loop 启动时追加边界。** loop 位于 `resumeWith` 上一层,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。 +**在 loop 启动时追加边界。** loop 调用 `resumeWith`,因此覆盖恢复路径,但完全漏掉 `fork()` 与 `adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。 **复用 `header.seedLength`。** 它是持久的 *fork 血缘*边界,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。 @@ -50,6 +50,6 @@ Status: implemented 代价:带种子会话的日志长了一个事件,这在九个包(session、agent-loop、持久化契约、jsonl、session-query、session-title、subagent-inprocess、telemetry、token-meter)里挪动了 seq 期望。其中两处更新是承重的而非机械的:telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;而属性测试套件的重放不变式被重述为"种子逐字节复现,外加一个仅日志边界",并把幂等性补成一条独立属性。 -`session/inherited` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地报告没有任何内容被继承。 +`session/end-seed` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地判定没有任何内容属于构造种子历史。 此处未做:还没有任何插件读取该边界。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作;谓词辅助函数应当归属那个 seam——在那里由真实消费方决定它的形状——而不是未经真实消费方检验就先落进核心。 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml index fb497a66e5..a8dde62381 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md -2026-07-29-durable-last-activity-index.md: 2569ce8dcdef4ce85713e4c3905c3a170be00a98 -2026-07-29-durable-last-activity-index.zh.md: 64078982bb09b0f94c41d5424d195d371c1a263f +2026-07-29-durable-last-activity-index.md: 0938e7ea2aa817818ed552fd8b702795840b547a +2026-07-29-durable-last-activity-index.zh.md: 647bce817444c77ac3cd6f51385472fff745212f diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md index 2569ce8dcd..0938e7ea2a 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md @@ -8,11 +8,11 @@ English | [中文](2026-07-29-durable-last-activity-index.zh.md) A cold (persisted, unattached) session has no stored answer to "when was this last worked in". `dsh-host-apiproxy`'s `summarizeCold()` therefore approximates it with the log file's mtime where one exists — `locate()` resolves a per-session artifact for JSONL and `undefined` for SQLite, whose cold sessions fall back to `createdAt` — and the web client sorts its session tree by the resulting `updatedAt`. The two backends are wrong in opposite directions: JSONL reads too new, SQLite too old. -mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/inherited` boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough. +mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough. The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size. -The [boundary change](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there. +The [boundary change](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there. ## Proposal @@ -25,7 +25,7 @@ The two shipped backends have opposite constraints, and the proposal is delibera Three questions must be answered before implementation, and none of them is settled here: -**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/inherited`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session. +**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/end-seed`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session. **How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at today's accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree. @@ -37,7 +37,7 @@ Three questions must be answered before implementation, and none of them is sett **Keep mtime and exclude boundary writes from it.** Rejected as impossible rather than undesirable: mtime is the filesystem's, not the backend's. Nothing short of restoring the timestamp after every boundary write would preserve it, and that races any concurrent reader and lies about the artifact. -**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction. +**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction. **Derive activity from a projection cache.** `session-projection-cache` already folds tails past a watermark, so a last-activity unit would ride existing machinery. Rejected as the primary shape because the cache is an optional composition entry; a listing served only when a cache plugin is mounted makes ordering depend on composition. @@ -61,6 +61,6 @@ Three questions must be answered before implementation, and none of them is sett ## Related -- [The inherited-history log boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with. +- [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with. - [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field. - [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into. diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md index 64078982bb..647bce8174 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md @@ -8,11 +8,11 @@ Status: proposed 一个冷会话(已持久化、未附加)对「上次是什么时候在这里面工作过」没有任何已存储的答案。因此 `dsh-host-apiproxy` 的 `summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物,为 SQLite 解析出 `undefined`,而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反:JSONL 读出来偏新,SQLite 偏旧。 -mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/inherited` 边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy` 的 `agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了。 +mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy` 的 `agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了。 已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。 -[边界那次变更](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。 +[边界那次变更](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。 ## 提案 @@ -25,7 +25,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入 实现之前必须回答三个问题,本文对它们都没有定论: -**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/inherited` 回答了这个问题。一个已存储字段是在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。 +**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/end-seed` 回答了这个问题。一个已存储字段是在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。 **该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。 @@ -37,7 +37,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入 **保留 mtime,但把边界的写入排除在它之外。** 否决的理由是做不到,而不是不合意:mtime 属于文件系统,不属于后端。除了在每次边界写入之后把时间戳复原,没有别的办法能保住它,而那样做会与任何并发读取方产生竞态,也会对这份产物撒谎。 -**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。 +**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。 **从投影缓存派生活动时间。** `session-projection-cache` 本就会折叠水位线之后的尾部,因此一个最后活动单元可以搭乘既有机制。它作为主形态被否决,因为该缓存是一个可选的组合项;只有挂载了缓存插件才提供的列举,会让排序取决于如何组合。 @@ -61,6 +61,6 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入 ## 相关 -- [继承历史日志边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影。 +- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影。 - [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。 - [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。 diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml index bcfbebf293..ec95910194 100644 --- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml +++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md -2026-07-29-session-resumed-log-boundary.md: 6dbe44cbd8354369e8ed56d8cc838e9771dbaa52 -2026-07-29-session-resumed-log-boundary.zh.md: f035773b896131ca87a70b58b19cfafb280601e4 +2026-07-29-session-resumed-log-boundary.md: 877b0c780f4c92983d2762243fac4e26d945887a +2026-07-29-session-resumed-log-boundary.zh.md: a6f6ecc0d3f3a349f1a438eec0a84f27f8f649b2 diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md index 6dbe44cbd8..877b0c780f 100644 --- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md +++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.md @@ -1,6 +1,6 @@ # Agent Note: Record the resume process boundary in the session log -Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the inherited-history boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) +Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the end-seed boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) English | [中文](2026-07-29-session-resumed-log-boundary.zh.md) @@ -14,7 +14,7 @@ The pressure to fix this is immediate: moving `compact/start` to its real time p ## Proposal -`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event below the boundary was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one. +`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event before the boundary has a smaller seq and was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one. The predicate a bracket owner evaluates is purely a function of the log: an unmatched opening marker with a `session/resumed` after it is stale, and one with no `session/resumed` after it is live. diff --git a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md index f035773b89..a6f6ecc0d3 100644 --- a/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md +++ b/.agents/notes/rejected/architecture/2026-07-29-session-resumed-log-boundary.zh.md @@ -1,6 +1,6 @@ # Agent Note: 在会话日志中记录恢复的进程边界 -Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[继承历史边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)取代 +Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[种子结束边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)取代 [English](2026-07-29-session-resumed-log-boundary.md) | 中文 @@ -14,7 +14,7 @@ Status: rejected — 边界应当落在带种子 `Session` 的构造函数上, ## Proposal -`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之下的每个事件都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()` 与 `adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()`/`readFrom()` 读取也从不写入。 +`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之前的每个事件都有更小的 seq,并且都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()` 与 `adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()`/`readFrom()` 读取也从不写入。 括号所有方求值的谓词纯粹是日志的函数:未匹配的起始标记之后有 `session/resumed` 的就是陈旧的,之后没有的就是存活的。 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 95b2754409..7c99825d0a 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: fd9dcf6c6c6127a026286f11b6c5dcf16f505abb -session.zh.md: 23202e2b4c4109c4ea4abd79c6409aaf3b593679 +session.md: 769d5db301e3e81664c732ab1685c859a00cceb2 +session.zh.md: 7af459949eda1b939596c20adf1c9e55f6d2b2b4 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index fd9dcf6c6c..769d5db301 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -92,27 +92,28 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Log-only durable projection of {@link Session.firstLiveSeq}: everything - * below it was inherited through a constructor seed (resume, fork, or replay) - * and no writer in this lifecycle produced it. Payload is empty — position - * and `time` carry the meaning. + * Marks the end of a constructor seed. Events before it have smaller seq + * values and came from the seed (resume, fork, or replay); this lifecycle + * produced none of them. This log-only event is the durable projection of + * {@link Session.firstLiveSeq}. Its payload is empty — position and `time` + * carry the meaning. * - * Locate the LAST one rather than reading `firstLiveSeq`: a seed already - * ending in a boundary is not re-marked, so reopening an untouched session - * does not grow its log per pickup. + * Locate the LAST one in stored history. A seed already ending in one is not + * re-marked, so reopening an untouched session does not grow its log per + * pickup and the event need not be at the current `firstLiveSeq`. * * `Session`'s constructor is the only legitimate writer. The invariant * companion deliberately constrains nothing here, so a plugin appending one - * would silently turn every live bracket below it into dead history. + * would silently classify every live bracket before it as seed history. * * An owner of a standalone open/close bracket (`compact/start` … - * `compact/end`) reads it because inherited history and live work are - * otherwise byte-identical: an unmatched opening marker below the boundary - * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal - * about other writers — a concurrently live session holds its own boundary - * elsewhere, so tolerating concurrent writers needs a signal beyond the log. + * `compact/end`) reads it because seed history and live work are otherwise + * byte-identical: an unmatched opening marker before this event belongs to + * an ended lifecycle, whatever ended it. NOT a liveness signal about other + * writers — a concurrently live session holds its own boundary elsewhere, + * so tolerating concurrent writers needs a signal beyond the log. */ - 'session/inherited': Record + 'session/end-seed': Record } ``` @@ -342,25 +343,25 @@ declare class Session { get id(): SessionId; /** * The first seq appended IN THIS PROCESS: the length of the constructor - * seed (0 without one). Events below it entered through construction — - * replay, fork, or resume — and were never published on the `session/event` - * firehose (constructor seeds do not emit), so consumers that replay the - * log as a publication substitute (telemetry adoption) start here. Distinct - * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed - * session's constructor seed is its full stored log, while its header keeps - * the original fork value — this field is the in-process construction fact. + * seed (0 without one). Events with smaller seq values entered through + * construction — replay, fork, or resume — and were never published on the + * `session/event` firehose (constructor seeds do not emit), so consumers + * that replay the log as a publication substitute (telemetry adoption) + * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage + * boundary: a resumed session's constructor seed is its full stored log, + * while its header keeps the original fork value — this field is the + * in-process construction fact. * * Not persisted itself: a seeded session projects it into the log as the - * `session/inherited` event, which is what a consumer reading STORED history - * reads. Locate that event as the log's LAST boundary, not at this seq — a + * `session/end-seed` event, which is what a consumer reading STORED history + * reads. Locate the LAST such event, not necessarily one at this seq — a * seed already ending in one is not re-marked, so reopening an untouched - * session leaves the boundary below `firstLiveSeq`. Prefer this field - * in-process: it is exact before the marker's write reaches storage. + * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer + * this field in-process: it is exact before the marker reaches storage. * - * When this lifecycle did append a boundary it sits at this seq, appended - * before the store attached, so that event did not publish either — the - * firehose gap then runs through `firstLiveSeq` rather than stopping below - * it. Otherwise this seq holds an ordinary published write. + * When this lifecycle appends the marker, it occupies this seq before the + * store attaches and therefore does not publish either. Otherwise this seq + * holds an ordinary published write. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); @@ -540,13 +541,13 @@ A turn encloses one model-loop execution, not the whole session log. Idle inject The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md). -## The inherited-history boundary: `session/inherited` +## The end-seed boundary: `session/end-seed` -A seeded session — resume, fork, or replay — appends this log-only event as its first live write. It is the durable projection of `firstLiveSeq`: that field answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer. +A seeded session — resume, fork, or replay — appends this log-only event immediately after its constructor seed, as its first live write. Events before it have smaller seq values and came from the seed. It is the durable projection of `firstLiveSeq`: that field answers where this lifecycle's writes start for a consumer holding the object, while the event answers the same question for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer. -An empty seed writes nothing, and a seed already ending in a boundary is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the boundary as the log's LAST one rather than at `firstLiveSeq`: after a pickup with no work, the next one leaves it below that seq. +An empty seed writes nothing, and a seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq`: after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq`. -It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker below the boundary belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`. +It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`. Activity ordering excludes the boundary through `lastActivityTime(events)`: picking a session up is not work, and lazy resume means browsing writes one, so a resume picker or session list ordering by log tail would float every opened session to the top. diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 23202e2b4c..7af459949e 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -92,27 +92,28 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Log-only durable projection of {@link Session.firstLiveSeq}: everything - * below it was inherited through a constructor seed (resume, fork, or replay) - * and no writer in this lifecycle produced it. Payload is empty — position - * and `time` carry the meaning. + * Marks the end of a constructor seed. Events before it have smaller seq + * values and came from the seed (resume, fork, or replay); this lifecycle + * produced none of them. This log-only event is the durable projection of + * {@link Session.firstLiveSeq}. Its payload is empty — position and `time` + * carry the meaning. * - * Locate the LAST one rather than reading `firstLiveSeq`: a seed already - * ending in a boundary is not re-marked, so reopening an untouched session - * does not grow its log per pickup. + * Locate the LAST one in stored history. A seed already ending in one is not + * re-marked, so reopening an untouched session does not grow its log per + * pickup and the event need not be at the current `firstLiveSeq`. * * `Session`'s constructor is the only legitimate writer. The invariant * companion deliberately constrains nothing here, so a plugin appending one - * would silently turn every live bracket below it into dead history. + * would silently classify every live bracket before it as seed history. * * An owner of a standalone open/close bracket (`compact/start` … - * `compact/end`) reads it because inherited history and live work are - * otherwise byte-identical: an unmatched opening marker below the boundary - * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal - * about other writers — a concurrently live session holds its own boundary - * elsewhere, so tolerating concurrent writers needs a signal beyond the log. + * `compact/end`) reads it because seed history and live work are otherwise + * byte-identical: an unmatched opening marker before this event belongs to + * an ended lifecycle, whatever ended it. NOT a liveness signal about other + * writers — a concurrently live session holds its own boundary elsewhere, + * so tolerating concurrent writers needs a signal beyond the log. */ - 'session/inherited': Record + 'session/end-seed': Record } ``` @@ -344,25 +345,25 @@ declare class Session { get id(): SessionId; /** * The first seq appended IN THIS PROCESS: the length of the constructor - * seed (0 without one). Events below it entered through construction — - * replay, fork, or resume — and were never published on the `session/event` - * firehose (constructor seeds do not emit), so consumers that replay the - * log as a publication substitute (telemetry adoption) start here. Distinct - * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed - * session's constructor seed is its full stored log, while its header keeps - * the original fork value — this field is the in-process construction fact. + * seed (0 without one). Events with smaller seq values entered through + * construction — replay, fork, or resume — and were never published on the + * `session/event` firehose (constructor seeds do not emit), so consumers + * that replay the log as a publication substitute (telemetry adoption) + * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage + * boundary: a resumed session's constructor seed is its full stored log, + * while its header keeps the original fork value — this field is the + * in-process construction fact. * * Not persisted itself: a seeded session projects it into the log as the - * `session/inherited` event, which is what a consumer reading STORED history - * reads. Locate that event as the log's LAST boundary, not at this seq — a + * `session/end-seed` event, which is what a consumer reading STORED history + * reads. Locate the LAST such event, not necessarily one at this seq — a * seed already ending in one is not re-marked, so reopening an untouched - * session leaves the boundary below `firstLiveSeq`. Prefer this field - * in-process: it is exact before the marker's write reaches storage. + * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer + * this field in-process: it is exact before the marker reaches storage. * - * When this lifecycle did append a boundary it sits at this seq, appended - * before the store attached, so that event did not publish either — the - * firehose gap then runs through `firstLiveSeq` rather than stopping below - * it. Otherwise this seq holds an ordinary published write. + * When this lifecycle appends the marker, it occupies this seq before the + * store attaches and therefore does not publish either. Otherwise this seq + * holds an ordinary published write. */ readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); @@ -544,13 +545,13 @@ interface TurnEndReasonMap { 可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。 -## 继承历史边界:`session/inherited` +## 种子结束边界:`session/end-seed` -带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。 +带种子的会话(恢复、fork 或回放)紧接构造种子之后追加这个仅日志事件,作为自己的第一次实时写入。在它之前的事件具有更小的 seq,且来自种子。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答本生命周期的写入从哪里开始,该事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。 -空种子不写入任何内容;种子本身已以该边界结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。定位边界应取日志中的**最后一条**,而不是读 `firstLiveSeq`:在一次没有产生工作的拾起之后,下一次拾起会让边界落在该 seq 之下。 +空种子不写入任何内容;种子本身已以 `session/end-seed` 结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。应定位存储历史中的最后一条 `session/end-seed`,而不是假定 `firstLiveSeq` 处一定有一条:在一次没有产生工作的拾起之后,该事件的 seq 会小于下一个生命周期的 `firstLiveSeq`。 -它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。边界之下的开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。 +它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。 活动排序通过 `lastActivityTime(events)` 排除该边界:接手会话不算工作,而惰性恢复意味着浏览就会写入一个,因此按日志尾部排序的恢复选择器或会话列表会把每个打开过的会话顶到最前。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 6acc650789..524ba6ffd7 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:346`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) ## Events @@ -404,34 +404,35 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s ### `session/*` -#### `session/inherited` — log-only +#### `session/end-seed` — log-only ```ts persistence-catalog /** - * Log-only durable projection of {@link Session.firstLiveSeq}: everything - * below it was inherited through a constructor seed (resume, fork, or replay) - * and no writer in this lifecycle produced it. Payload is empty — position - * and `time` carry the meaning. + * Marks the end of a constructor seed. Events before it have smaller seq + * values and came from the seed (resume, fork, or replay); this lifecycle + * produced none of them. This log-only event is the durable projection of + * {@link Session.firstLiveSeq}. Its payload is empty — position and `time` + * carry the meaning. * - * Locate the LAST one rather than reading `firstLiveSeq`: a seed already - * ending in a boundary is not re-marked, so reopening an untouched session - * does not grow its log per pickup. + * Locate the LAST one in stored history. A seed already ending in one is not + * re-marked, so reopening an untouched session does not grow its log per + * pickup and the event need not be at the current `firstLiveSeq`. * * `Session`'s constructor is the only legitimate writer. The invariant * companion deliberately constrains nothing here, so a plugin appending one - * would silently turn every live bracket below it into dead history. + * would silently classify every live bracket before it as seed history. * * An owner of a standalone open/close bracket (`compact/start` … - * `compact/end`) reads it because inherited history and live work are - * otherwise byte-identical: an unmatched opening marker below the boundary - * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal - * about other writers — a concurrently live session holds its own boundary - * elsewhere, so tolerating concurrent writers needs a signal beyond the log. + * `compact/end`) reads it because seed history and live work are otherwise + * byte-identical: an unmatched opening marker before this event belongs to + * an ended lifecycle, whatever ended it. NOT a liveness signal about other + * writers — a concurrently live session holds its own boundary elsewhere, + * so tolerating concurrent writers needs a signal beyond the log. */ -'session/inherited': Record +'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) #### `session/title` — log-only diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 972b9d7e67..c47cb8c89f 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/inherited': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index 2dbb507f36..9ce1073349 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{cwd}}","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"4fbb7fab-f45a-4e3c-95e0-454a638cf4b3"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"ecede90b-f918-4b3c-81cc-aefcc375d269"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783352134840,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1783352134840,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -12,12 +12,12 @@ {"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} {"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} {"type":"assistant/chunk","seq":34,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"4dd423ef-963c-409e-8238-551c4b10273b"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"d6c3a4bf-20e0-459f-9bc9-945f6650b5f1"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} {"type":"step/end","seq":36,"time":1783352135773,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":37,"time":1783352135773,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/inherited","seq":38,"time":1785381572223,"data":{}} +{"type":"session/end-seed","seq":38,"time":1785396256785,"data":{}} {"type":"turn/start","seq":39,"time":1785381572224,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":40,"time":1785381572224,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8cfd615d-f500-4de4-b751-d19e27d23016"},"surfaceOp":"append"} +{"type":"user/message","seq":40,"time":1785381572224,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6f050d06-7445-4651-9958-345b6410f3d7"},"surfaceOp":"append"} {"type":"step/start","seq":41,"time":1785381572240,"data":{"turn":2,"step":1}} {"type":"request/header","seq":42,"time":1785381572241,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","seq":43,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -28,6 +28,6 @@ {"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} {"type":"assistant/chunk","seq":85,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} {"type":"assistant/chunk","seq":86,"time":1785381572250,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":87,"time":1785381572250,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3dc1dc0d-40e0-4cd1-a111-1bcf40a1001e"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86],"surfaceOp":"append"} +{"type":"assistant/message","seq":87,"time":1785381572250,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"db43685f-dd37-4558-926d-7a758305a84d"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86],"surfaceOp":"append"} {"type":"step/end","seq":88,"time":1785381572250,"data":{"turn":2,"step":1}} {"type":"turn/end","seq":89,"time":1785381572251,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index 9277f70139..b7af05fb61 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5c2a4880-9a66-4e38-b8d2-a2f891ff283f"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"9f3b1367-3a0e-4793-9ecf-ae67a79f24d2"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1783352142836,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -12,12 +12,12 @@ {"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} {"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} {"type":"assistant/chunk","seq":28,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"132af698-2789-4aaa-aca0-cffd1c58720a"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"59fa3190-4060-40db-a0a5-97f2fa4172f3"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} {"type":"step/end","seq":30,"time":1783352143771,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":31,"time":1783352143771,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/inherited","seq":32,"time":1785381573525,"data":{}} +{"type":"session/end-seed","seq":32,"time":1785396258235,"data":{}} {"type":"turn/start","seq":33,"time":1785381573526,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":34,"time":1785381573526,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f85229b8-dcf8-4d8c-9d61-8ef879ed5974"},"surfaceOp":"append"} +{"type":"user/message","seq":34,"time":1785381573526,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"9f252dc0-3b24-4607-b761-30711b726edb"},"surfaceOp":"append"} {"type":"step/start","seq":35,"time":1785381573543,"data":{"turn":2,"step":1}} {"type":"request/header","seq":36,"time":1785381573543,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","seq":37,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -28,6 +28,6 @@ {"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} {"type":"assistant/chunk","seq":75,"time":1785142306309,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} {"type":"assistant/chunk","seq":76,"time":1785381573552,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":77,"time":1785381573552,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5d0e4a32-f5ee-4992-8223-51cdb14d1db3"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} +{"type":"assistant/message","seq":77,"time":1785381573552,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a39affbc-097b-4106-912a-99538d18eff8"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} {"type":"step/end","seq":78,"time":1785381573553,"data":{"turn":2,"step":1}} {"type":"turn/end","seq":79,"time":1785381573553,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl b/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl index ea07941fd5..85e60621e0 100644 --- a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl +++ b/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl @@ -7,7 +7,7 @@ {"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-5","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]} {"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}} -{"type":"session/inherited","seq":8,"time":0,"data":{}} +{"type":"session/end-seed","seq":8,"time":0,"data":{}} {"type":"turn/start","seq":9,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"session/title","seq":11,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl index 7123ff0eb4..796f41aee8 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"sandbox/mode","seq":2,"time":0,"data":{"mode":"read-only"}} {"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/inherited","seq":4,"time":0,"data":{}} +{"type":"session/end-seed","seq":4,"time":0,"data":{}} {"type":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 57092fd842..d7cdc4e253 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2177,7 +2177,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/inherited\': Record;\n}', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/end-seed\': Record;\n}', }, { name: 'SessionEventMetadataFilter', diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 159d17342c..20046f766e 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1224,6 +1224,6 @@ describe('agent loop', () => { // event-by-event identity of types over the inherited prefix expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual( agent.session.events.map(e => e.type)) - expect(replayed.events.at(-1)?.type).toBe('session/inherited') + expect(replayed.events.at(-1)?.type).toBe('session/end-seed') }) }) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 969c7b9f5e..7844aebd73 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -283,7 +283,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', agentOptions: { provider: 'mock', model: 'mock' }, setup: async (agentCtx) => { expect(agentCtx.agent?.id).toBe(sessionId) - // The two persisted events plus the inherited-history boundary. + // The two persisted events plus the end-seed marker. expect(agentCtx.agent?.session.events).toHaveLength(3) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) @@ -586,10 +586,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') - // …below one boundary marking all of it inherited. + // …followed by one end-seed event marking the constructor seed. expect(a2.session.events.length).toBe(events1.length + 1) expect(a2.session.firstLiveSeq).toBe(events1.length) - expect(a2.session.events.at(-1)?.type).toBe('session/inherited') + expect(a2.session.events.at(-1)?.type).toBe('session/end-seed') const replay = new Session(SessionId('replay'), events1) expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages()) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index e4786e7222..a403e11df1 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -382,25 +382,25 @@ export class Session { /** * The first seq appended IN THIS PROCESS: the length of the constructor - * seed (0 without one). Events below it entered through construction — - * replay, fork, or resume — and were never published on the `session/event` - * firehose (constructor seeds do not emit), so consumers that replay the - * log as a publication substitute (telemetry adoption) start here. Distinct - * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed - * session's constructor seed is its full stored log, while its header keeps - * the original fork value — this field is the in-process construction fact. + * seed (0 without one). Events with smaller seq values entered through + * construction — replay, fork, or resume — and were never published on the + * `session/event` firehose (constructor seeds do not emit), so consumers + * that replay the log as a publication substitute (telemetry adoption) + * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage + * boundary: a resumed session's constructor seed is its full stored log, + * while its header keeps the original fork value — this field is the + * in-process construction fact. * * Not persisted itself: a seeded session projects it into the log as the - * `session/inherited` event, which is what a consumer reading STORED history - * reads. Locate that event as the log's LAST boundary, not at this seq — a + * `session/end-seed` event, which is what a consumer reading STORED history + * reads. Locate the LAST such event, not necessarily one at this seq — a * seed already ending in one is not re-marked, so reopening an untouched - * session leaves the boundary below `firstLiveSeq`. Prefer this field - * in-process: it is exact before the marker's write reaches storage. + * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer + * this field in-process: it is exact before the marker reaches storage. * - * When this lifecycle did append a boundary it sits at this seq, appended - * before the store attached, so that event did not publish either — the - * firehose gap then runs through `firstLiveSeq` rather than stopping below - * it. Otherwise this seq holds an ordinary published write. + * When this lifecycle appends the marker, it occupies this seq before the + * store attaches and therefore does not publish either. Otherwise this seq + * holds an ordinary published write. */ readonly firstLiveSeq: number @@ -442,8 +442,8 @@ export class Session { // captures the creation seed: no load-time write. Re-marking is skipped // because a cold session is resumed on first touch, so repeatedly opening // one must not grow its log per open. - if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/inherited') { - this.append('session/inherited', {}) + if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/end-seed') { + this.append('session/end-seed', {}) } } diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 5a119f3297..c454ad6533 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -144,7 +144,7 @@ function validateEvent( } case 'user/message': break - case 'session/inherited': + case 'session/end-seed': // Unconstrained: an unbalanced seed legally puts it inside an open turn. break case 'steering/message': diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index a4e82dabfb..1114156c2e 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -2,7 +2,7 @@ * Crash-recovery repair for an interrupted session log. It preserves a fully * written final turn and supplies the missing tool, step, and turn boundaries * needed to resume with a provider-valid transcript, plus the activity-time - * read that must skip the inherited-history boundary — which this module does + * read that must skip the end-seed boundary — which this module does * not write (`Session`'s constructor does) but whose synthetic closers can * inherit that boundary's timestamp, the one real coupling between the two. * @module @deepseek-ai/dsh-session/repair @@ -14,7 +14,7 @@ import type { SessionEvent } from './types.ts' /** * The `time` of the log's last event representing actual work, skipping the - * `session/inherited` boundary — picking a session up is not activity, so + * `session/end-seed` boundary — picking a session up is not activity, so * activity ordering must exclude it. * * Excluded by type, so a pickup time still leaks when a boundary is the last @@ -25,7 +25,7 @@ import type { SessionEvent } from './types.ts' * @returns the latest non-boundary event's `time`, or undefined when there is none. */ export function lastActivityTime(events: readonly SessionEvent[]): number | undefined { - return events.findLast(event => event.type !== 'session/inherited')?.time + return events.findLast(event => event.type !== 'session/end-seed')?.time } /** Recovery code for an assistant tool request that never reached a recorded call start. */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index aca980ccfe..ebebb95c7c 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -251,27 +251,28 @@ export interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Log-only durable projection of {@link Session.firstLiveSeq}: everything - * below it was inherited through a constructor seed (resume, fork, or replay) - * and no writer in this lifecycle produced it. Payload is empty — position - * and `time` carry the meaning. + * Marks the end of a constructor seed. Events before it have smaller seq + * values and came from the seed (resume, fork, or replay); this lifecycle + * produced none of them. This log-only event is the durable projection of + * {@link Session.firstLiveSeq}. Its payload is empty — position and `time` + * carry the meaning. * - * Locate the LAST one rather than reading `firstLiveSeq`: a seed already - * ending in a boundary is not re-marked, so reopening an untouched session - * does not grow its log per pickup. + * Locate the LAST one in stored history. A seed already ending in one is not + * re-marked, so reopening an untouched session does not grow its log per + * pickup and the event need not be at the current `firstLiveSeq`. * * `Session`'s constructor is the only legitimate writer. The invariant * companion deliberately constrains nothing here, so a plugin appending one - * would silently turn every live bracket below it into dead history. + * would silently classify every live bracket before it as seed history. * * An owner of a standalone open/close bracket (`compact/start` … - * `compact/end`) reads it because inherited history and live work are - * otherwise byte-identical: an unmatched opening marker below the boundary - * belongs to an ended lifecycle, whatever ended it. NOT a liveness signal - * about other writers — a concurrently live session holds its own boundary - * elsewhere, so tolerating concurrent writers needs a signal beyond the log. + * `compact/end`) reads it because seed history and live work are otherwise + * byte-identical: an unmatched opening marker before this event belongs to + * an ended lifecycle, whatever ended it. NOT a liveness signal about other + * writers — a concurrently live session holds its own boundary elsewhere, + * so tolerating concurrent writers needs a signal beyond the log. */ - 'session/inherited': Record + 'session/end-seed': Record } /** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index e32191a36b..b04d08e9ab 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -52,11 +52,11 @@ function lastSeq(session: Session): number { return event.seq } -/** A seeded child's inherited prefix: its log minus the constructor's boundary. */ +/** A seeded child's constructor seed: its log minus the end-seed marker. */ function inherited(session: Session): readonly SessionEvent[] { const events = session.events const last = events.at(-1) - if (last?.type !== 'session/inherited') throw new Error('seeded child is missing its inherited boundary') + if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker') return events.slice(0, -1) } @@ -166,12 +166,12 @@ describe('SessionStore.fork', () => { const child = sessions.fork(parent, undefined, SessionId('bracket-child')) - // Parent: nothing above the bracket, so its owner must treat it as live. + // Parent: no end-seed event follows the bracket, so its owner treats it as live. expect(parent.events.at(-1)).toBe(open) - expect(parent.events.some(event => event.type === 'session/inherited')).toBe(false) - // Child: the same bracket sits below its boundary, so it is dead history. + expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false) + // Child: the same bracket is before end-seed, so it belongs to the seed. const boundary = child.events.at(-1) - expect(boundary).toMatchObject({ type: 'session/inherited' }) + expect(boundary).toMatchObject({ type: 'session/end-seed' }) expect(boundary!.seq).toBeGreaterThan(open.seq) expect(child.firstLiveSeq).toBe(open.seq + 1) expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } }) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 281ba50f4c..5e075f1749 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -382,7 +382,7 @@ describe('session-log invariants', () => { .toThrow(/turn 1 is still open/) }) - it('accepts the inherited boundary whether or not a turn is open', async () => { + it('accepts end-seed whether or not a turn is open', async () => { const { ctx } = await setup() // Balanced seed: between turns. expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [ @@ -393,7 +393,7 @@ describe('session-log invariants', () => { const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, ] }) - expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited']) + expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed']) // Still open afterwards: the boundary moves no cursor. expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) .toThrow(/turn 1 is still open/) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index c4372fe377..398bd69d35 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -118,7 +118,7 @@ describe('Session properties', () => { })) }) - it('replaying an already-inherited log adds no further boundary', () => { + it('replaying a log that already ends in end-seed adds no further marker', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events]) diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index cb2a0d7b50..0e51e52c55 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -275,8 +275,8 @@ describe('interruptedTurnClosers', () => { }) describe('lastActivityTime', () => { - const inheritedAt = (seq: number, time: number): SessionEvent => - ({ type: 'session/inherited', seq, time, data: {} }) + const endSeedAt = (seq: number, time: number): SessionEvent => + ({ type: 'session/end-seed', seq, time, data: {} }) it('has no answer for an empty log', () => { expect(lastActivityTime([])).toBeUndefined() @@ -294,16 +294,16 @@ describe('lastActivityTime', () => { const events: SessionEvent[] = [ userTurnStart(1, 0), { type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } }, - inheritedAt(2, 9_000), + endSeedAt(2, 9_000), ] // Resumed long after the work, but never worked in again. expect(lastActivityTime(events)).toBe(500) }) - it('reports work done above a boundary', () => { + it('reports work appended after end-seed', () => { const events: SessionEvent[] = [ userTurnStart(1, 0), - inheritedAt(1, 9_000), + endSeedAt(1, 9_000), { type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } }, ] expect(lastActivityTime(events)).toBe(9_500) @@ -311,6 +311,6 @@ describe('lastActivityTime', () => { it('has no answer for a log of nothing but boundaries', () => { // Unreachable via the constructor, but the projection is a pure function. - expect(lastActivityTime([inheritedAt(0, 1), inheritedAt(1, 2)])).toBeUndefined() + expect(lastActivityTime([endSeedAt(0, 1), endSeedAt(1, 2)])).toBeUndefined() }) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 4c551bede0..7b7e2fae28 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -188,7 +188,7 @@ describe('Session', () => { const replayed = new Session(SessionId('s3-replay'), [...original.events]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) - // The seed verbatim, plus the boundary the constructor appends over it. + // The seed verbatim, plus the end-seed event the constructor appends. expect(replayed.events.slice(0, original.seq)).toEqual(original.events) expect(replayed.seq).toBe(original.seq + 1) expect(replayed.firstLiveSeq).toBe(original.seq) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index d48fe81928..f37d2831a0 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: c745dd6fb142b5b5a50b4593e3ce0223e2be6728 -README.zh.md: 7ddd7a37f7dd45f683a93601637c75b3f1bd8fd2 +README.md: bf5d0d762365deae83d004e00b94a0ba805d6a6f +README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index c745dd6fb1..bf5d0d7623 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -44,4 +44,4 @@ None; this package neither assembles nor sends a provider request. - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). -- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/inherited` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). +- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 7ddd7a37f7..06c2e37d25 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -44,4 +44,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。 -- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/inherited` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 +- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f8e66c33e5..89495175f7 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -165,7 +165,7 @@ function sessionBlank(session: Session): boolean { function summarize(session: Session, running: boolean): SessionSummary { return { sessionId: session.id, - // Excludes the inherited-history boundary: a resumed-but-untouched session + // Excludes end-seed: a resumed-but-untouched session // must not sort as freshly worked in. updatedAt: lastActivityTime(session.events) ?? session.header.createdAt, running, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 4730605f97..50838779c9 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -134,7 +134,7 @@ export type QueueAction = export interface SessionSummary { sessionId: SessionId /** - * Last activity. Attached: the last non-`session/inherited` event, since a + * Last activity. Attached: the last non-`session/end-seed` event, since a * pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend * with no per-session file (README Known Limitations covers the skew). */ diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 2b37bc3cb9..b9d5018975 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -78,7 +78,7 @@ describe('sessions.list cold merge', () => { }) }) -describe('attached updatedAt excludes the inherited-history boundary', () => { +describe('attached updatedAt excludes end-seed', () => { it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -97,7 +97,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => { }) ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent) const boundary = resumed.events.at(-1) - expect(boundary?.type).toBe('session/inherited') + expect(boundary?.type).toBe('session/end-seed') expect(boundary?.time).toBeGreaterThan(worked) const listed = await api.sessions.list(request({})) @@ -105,7 +105,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => { const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched') expect(summary?.updatedAt).toBe(worked) - // Real work above the boundary does move it. + // Real work appended after end-seed does move it. resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) const after = await api.sessions.list(request({})) if (!after.result.ok) throw new Error('list failed') diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 3d63ad19db..1a4a4a5680 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -678,8 +678,8 @@ describe('malformed replay and listener lifecycle', () => { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - // Seed, constructor boundary, then the append above. Only the last - // published: the boundary predates store attachment, like the seed. + // Seed, end-seed, then one live append. Only the last event published: + // end-seed predates store attachment, like the seed. expect(revisions).toEqual([3]) expect(activeMeter.measure(session).logRevision).toBe(3) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 15e1ccc319..4eeeabc67b 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -378,9 +378,9 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await ctx.sessions.flush(child) const loaded = await ctx.sessionPersistence.load(child.id) - // The inherited prefix reaches disk verbatim, then the child's boundary. + // The constructor seed reaches disk verbatim, then the child's end-seed. expect(loaded.events.slice(0, source.events.length)).toEqual(source.events) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: source.events.length }) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.events.length }) expect(loaded.meta).toMatchObject({ id: SessionId('persist-child'), cwd: '/workspace', diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index e34a37745b..d8ee35f4b7 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -218,9 +218,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< live.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.sessions.flush(live) const loaded = await ctx.sessionPersistence.load(id) - // The seeded constructor's boundary persisted between the stored + // The constructor's end-seed event persisted between the stored // turn/start and the turn/end appended live. - expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited', 'turn/end']) + expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed', 'turn/end']) expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -482,7 +482,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Fork is where the marker earns its keep: the inherited prefix may // carry a bracket the still-running parent owns. expect(loaded.events.slice(0, seed.length)).toEqual(seed) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: seed.length }) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: seed.length }) // A flush with no NEW events must not double-write. await ctx.sessions.flush(forked) const reloaded = await ctx.sessionPersistence.load(SessionId('forked')) @@ -515,9 +515,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await second.ctx.sessions.flush(s2) const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) - // 0-5 the resumed seed, 6 the boundary, 7-8 the new turn. + // 0-5 the resumed seed, 6 end-seed, 7-8 the new turn. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) - expect(reloaded.events[6]).toMatchObject({ type: 'session/inherited' }) + expect(reloaded.events[6]).toMatchObject({ type: 'session/end-seed' }) } finally { await second.fiber.dispose() await fix.cleanup() @@ -798,9 +798,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) await expect(ctx.sessions.flush(live)).resolves.toBeUndefined() const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) - // Seeded 0-5 plus the constructor's boundary at 6. + // Seeded 0-5 plus the constructor's end-seed event at 6. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6]) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' }) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' }) } finally { await fiber.dispose() await fix.cleanup() @@ -853,9 +853,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< }, { inject: ['sessions'] })) await ctx.sessions.flush(cont) const loaded = await ctx.sessionPersistence.load(SessionId('claim')) - // 6-7 the claimed suffix; 8 the boundary over the whole seed. + // 6-7 the claimed suffix; 8 end-seed after the whole seed. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' }) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' }) expect(loaded.meta).toEqual(durableMeta) expect(loaded.meta.createdAt).toBe(1000) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index cab9271614..ac8f7f91a8 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1077,7 +1077,7 @@ describe('SQLite reconciliation and source lifecycle', () => { await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) db.exec('PRAGMA query_only = OFF') - // seq 2: one-event seed, its boundary, then the message appended above. + // seq 2: one-event seed, end-seed, then the live message. await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' })) .resolves.toMatchObject({ items: [{ seq: 2 }] }) }) diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index 254f76ef21..1b426ac94c 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -185,8 +185,8 @@ describe('SessionTitleService configuration and refresh boundaries', () => { 'turn/start', 'user/message', 'turn/end', - // The seeded constructor's inherited-history boundary. - 'session/inherited', + // The seeded constructor's end-seed marker. + 'session/end-seed', 'session/title', ]) expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq]) diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 90ae134918..c77a174ee7 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -126,7 +126,7 @@ describe('in-process policy inheritance', () => { expect(child.session.header.seedLength).toBe(1) expect(child.session.firstLiveSeq).toBe(seed.length) - // seq 1 is the inherited-history boundary. + // seq 1 is the constructor's end-seed marker. expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ { seq: 0, data: { mode: 'workspace-write' } }, { seq: 2, data: { mode: 'read-only', source: 'delegation' } }, diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 58228179d5..f56f433a64 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -186,12 +186,12 @@ describe('TelemetryCoordinator adoption', () => { const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]) expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]])) - // 2 the boundary, 3 the turn/end: both this lifecycle's own writes, while + // 2 end-seed, 3 turn/end: both this lifecycle's own writes, while // inherited 0-1 stay with the parent stream. expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]]) }) - it('resume shape: a full-log seed exports only its own boundary and rebuilds the chunk projection', async () => { + it('resume shape: a full-log seed exports only its own end-seed and rebuilds the chunk projection', async () => { const backend = new FakeBackend() const ctx = new Context() await ctx.plugin(SessionStore) @@ -208,7 +208,7 @@ describe('TelemetryCoordinator adoption', () => { .filter(r => r.attributes['session.id'] === 'resumed') .map(r => r.attributes['event.seq']) // Nothing inherited is re-exported; seq 2 is this session's own first - // write — the boundary its constructor appended over the seed. + // write — the end-seed event its constructor appended after the seed. expect(ofResumed()).toEqual([2]) // The seed fed the projection: the (turn 1, step 1) first chunk already // shipped from the original process, so its continuation is re-dropped… diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 912febae70..aea3ca2037 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -839,7 +839,7 @@ describe('TUI terminal-state snapshots', () => { { type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, // A prior pickup, dated well after the work: the picker must still // show the work's date, not the pickup's. - { type: 'session/inherited', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} }, + { type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} }, ], }) const harness = await setupSnapshot({ @@ -913,7 +913,7 @@ describe('TUI terminal-state snapshots', () => { // `/status` appends its own `command/run` first, so the boundary is // never the tail here. The other two call sites pin it. dateNow.mockReturnValue(Date.parse('2026-07-22T10:10:11.000Z')) - session.append('session/inherited', {}) + session.append('session/end-seed', {}) dateNow.mockReturnValue(Date.parse('2026-07-22T09:10:11.000Z')) }, }, { columns: 92, rows: 32 }) From 9d268151264eb042778dd68fe6f5643159702183 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 15:32:17 +0800 Subject: [PATCH 41/41] test(web): wait for the driven session before snapshotting --- apps/web/tests/live-interactions.e2e.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index ca05e46a22..6d8929c9c6 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -167,6 +167,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // "no crash, composer recovers, turn logged as error". await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true) expect(await page.locator('[data-streaming="true"]').count()).toBe(0) + // The blank workspace also has an enabled composer. Wait for the driven + // session's only visible message before capturing its no-error-copy state. + await expect.poll(() => page.getByText(PROMPT, { exact: true }).first().isVisible(), { timeout: 10_000 }).toBe(true) // Golden of the same gap: the prompt bubble alone, no error copy in the // tree — the diff that changes when web-error-surface lands. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)