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" },