mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(tool-pwsh): mirror dsh-tool-bash call-for-call minus the sandbox surface
This commit is contained in:
@@ -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/bash/tool-pwsh/README.md
|
||||
README.md: 4f1d62dbf49fef678e3285776c466286535d66da
|
||||
README.zh.md: bbeece3c648d8b1903eed1a66d2e14774c7ace8c
|
||||
README.md: b5acc73a68d3b309860554d4c1e8d979eb8d1eec
|
||||
README.zh.md: 4d678c42194b78da8b4b10e01f8b9e666d6236d8
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Minimal by design — no background tasks, no sandbox escalation, no persistent shell: this is the "works on my Windows machine" profile until the full bash-tool feature set gets a PowerShell twin.
|
||||
The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker).
|
||||
|
||||
Requires a loaded executor implementation; the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`).
|
||||
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the pure `renderPwshOutput` helper and its result type; execution and presentation remain implementation details covered by same-package tests.
|
||||
The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export.
|
||||
|
||||
The plugin also contributes the `tool:pwsh` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on.
|
||||
The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -20,20 +20,23 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): check th
|
||||
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
|
||||
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
|
||||
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. |
|
||||
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
|
||||
|
||||
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
|
||||
|
||||
### Managed shell environment
|
||||
|
||||
Every call receives a freshly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified.
|
||||
Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables.
|
||||
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable timeout, signal, and exit-code markers: `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: N]`, each separated by a newline only when the accumulated text lacks one. Nonzero exit remains a model-interpreted result rather than `isError`. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`.
|
||||
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process. Programmatic consumers use the typed fields without parsing the rendered text.
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task <id>` for background acks; programmatic consumers use the typed fields without parsing the rendered text.
|
||||
|
||||
When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time.
|
||||
|
||||
## UI presentation
|
||||
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; a completed result is a `generic` card with the rendered output in a `console` fence. These presenters are pure and replay-safe.
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; a completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -46,7 +49,7 @@ Every request in this plugin's registration scope contains the pwsh guidance bel
|
||||
##### Pwsh guidance
|
||||
|
||||
```markdown
|
||||
Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.
|
||||
Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
@@ -75,7 +78,7 @@ Prefix-stable while visibility and the tool definition are unchanged. A restrict
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`.
|
||||
The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: <path>]`, `[timed out after <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]` (nonzero exits only); an empty body renders as `(no output)`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -85,11 +88,25 @@ Zero result tokens before a call. Output is bounded per stream, while each emitt
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Background result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A background start renders exactly `started background task <id>`; subsequent reads and status flow through the generic `task_output`/`task_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The ack is a fixed short line; task output is bounded per read.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Tool errors
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, and `tool call aborted`.
|
||||
Validation and infrastructure failures are normalized as `Error: <message>`. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got <value>`, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -101,7 +118,8 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Foreground-only** — no `run_in_background`; long-running work must stay within the executor timeout or wait for the bash-tool twin.
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; a confining composition denies through the executor, and escalation waits for the full twin.
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Windows-default roadmap deferred** — defaulting Windows hosts to `pwsh` over `bash`, and pwsh TUI/GUI rendering support, are planned separately and deliberately not part of this package yet.
|
||||
- **Generic UI presentation** — results use the generic card; a PowerShell-aware terminal card with exit-status pill is roadmap work.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.
|
||||
|
||||
@@ -2,106 +2,124 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向模型的 `pwsh` 工具,注册在 `ctx.bash` 执行器 seam 之上。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。刻意保持最小——无后台任务、无沙箱升级、无持久 shell:在完整 bash 工具功能集获得 PowerShell 孪生之前,这就是 "works on my Windows machine" 画像。
|
||||
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。
|
||||
|
||||
需要一个已加载的执行器实现;插件在 `ctx.bash` 存在之前保持 pending(`inject: ['tools', 'bash', 'systemPrompt']`)。
|
||||
需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
|
||||
|
||||
包根只暴露 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及纯函数 `renderPwshOutput` 及其结果类型;执行与呈现是同一包测试覆盖的实现细节。
|
||||
包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
|
||||
|
||||
该插件还贡献 `tool:pwsh` 提示词段(order 105):检查每个结果上的 `[exit code: N]` 标记,并在继续前调查失败。
|
||||
插件还贡献 `tool:pwsh` prompt section(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。
|
||||
|
||||
## 工具
|
||||
|
||||
### `pwsh`
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `command` | string(必填) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 |
|
||||
| `description` | string(必填) | 命令的一句话主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 |
|
||||
| `timeoutMs` | number | 毫秒级超时覆盖。执行器应用其配置的默认值与上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
|
||||
| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 |
|
||||
| `description` | string (required) | 命令的一行主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 |
|
||||
| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认取调用 agent 的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
|
||||
| `run_in_background` | boolean | 立即返回任务 id;不适用超时。 |
|
||||
|
||||
`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层取自调用 agent 的 `session.header.cwd`,先于 `resolve()` 应用——每个会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;只有没有会话 cwd 时,执行器才回退到自己的配置 / `process.cwd()`。
|
||||
`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。
|
||||
|
||||
### 受管 shell 环境
|
||||
### Managed shell environment
|
||||
|
||||
每次调用都会收到一份新收集的受信 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 绝对主目录(`dshHome` 配置,其次环境变量 `$DSH_HOME`,再其次 `~/.dsh`),`DSH_SHELL=1` 标识受管子进程。agent 调用额外收到 `DSH_SESSION_ID=agent.session.header.id`。该快照经由专用 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。
|
||||
每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。
|
||||
|
||||
结果文本包含 stdout、可选的 `[stderr]` 分段,以及适用的超时、信号与退出码标记:`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: N]`,仅在累积文本缺少换行时才补一个分隔换行。非零退出仍是模型自行解读的结果,而不是 `isError`。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——才产生 `isError`。
|
||||
结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。
|
||||
|
||||
规范成功值为已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`。程序化消费方使用类型化字段,而不解析渲染文本。
|
||||
规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task <id>`;编程消费者使用类型化字段而不解析渲染文本。
|
||||
|
||||
## UI 呈现
|
||||
当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。
|
||||
|
||||
工具拥有自己的 `presentCall`/`presentResult` 渲染意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡片;完成结果是 `generic` 卡片,渲染输出放在 `console` 围栏内。这些 presenter 是纯函数且可重放。
|
||||
## UI presentation
|
||||
|
||||
## 模型体验
|
||||
工具拥有自己的 `presentCall`/`presentResult` 呈现意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡;完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。
|
||||
|
||||
### 系统提示词
|
||||
## Model Experience
|
||||
|
||||
#### 模型看到的内容
|
||||
### System prompt
|
||||
|
||||
该插件注册作用域内的每个请求都包含下方 pwsh 指导。作用域工具限制可以隐藏 schema,而不移除这个独立注册的提示词段。
|
||||
#### What the model sees
|
||||
|
||||
##### Pwsh 指导
|
||||
本插件注册作用域内的每个请求都包含下面的 pwsh 指引。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。
|
||||
|
||||
##### Pwsh guidance
|
||||
|
||||
```markdown
|
||||
Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.
|
||||
Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
#### Token effect
|
||||
|
||||
插件激活期间每个请求有少量固定输入成本。
|
||||
插件激活期间每次请求的固定小额输入成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
#### KV Cache effect
|
||||
|
||||
注册作用域与提示词文本不变时前缀稳定。插件激活或销毁可能使该提示词段的复用失效。
|
||||
注册作用域与 prompt 文本不变时前缀稳定。插件激活或释放可能使该 prompt 段落的复用失效。
|
||||
|
||||
### 工具 schema
|
||||
### Tool schemas
|
||||
|
||||
#### 模型看到的内容
|
||||
#### What the model sees
|
||||
|
||||
模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。agent 作用域的工具限制可以为该 agent 移除定义。
|
||||
模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。
|
||||
|
||||
#### Token 影响
|
||||
#### Token effect
|
||||
|
||||
工具可见时每个请求有固定的 schema 成本。
|
||||
工具可见的每个请求上的固定 schema 成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
#### KV Cache effect
|
||||
|
||||
可见性与工具定义不变时前缀稳定。限制或配置变更可能从第一个改变的 token 起使复用失效。
|
||||
可见性与工具定义不变时前缀稳定。限制或配置变更可能从首个变化 token 起使复用失效。
|
||||
|
||||
### 前台结果
|
||||
### Foreground result
|
||||
|
||||
#### 模型看到的内容
|
||||
#### What the model sees
|
||||
|
||||
渲染器输出依赖数据的 stdout 尾部,然后是可选 `[stderr]` 与 stderr 尾部。条件行恰为 `[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`。
|
||||
渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: <path>]`、`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`(仅非零退出);空体渲染为 `(no output)`。
|
||||
|
||||
#### Token 影响
|
||||
#### Token effect
|
||||
|
||||
调用前零结果 token。输出按流有界,每条已发出行在压缩前保留在历史中。
|
||||
调用前零结果 token。每个流的输出有界,而每条已发出的行保留在历史中直到压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
#### KV Cache effect
|
||||
|
||||
只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
|
||||
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。
|
||||
|
||||
### 工具错误
|
||||
### Background result
|
||||
|
||||
#### 模型看到的内容
|
||||
#### What the model sees
|
||||
|
||||
校验与基础设施失败被规范化为 `Error: <message>`。本包的稳定消息为 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>` 与 `tool call aborted`。
|
||||
后台启动精确渲染为 `started background task <id>`;随后的读取与状态通过通用 `task_output`/`task_kill` 工具流转,包括内存截断丢弃未读字节时的 lossy 读取 spill 通知。
|
||||
|
||||
#### Token 影响
|
||||
#### Token effect
|
||||
|
||||
只有失败的调用会增加这些保留 token;中止的调用不增加命令输出。
|
||||
ack 是固定短行;任务输出按读取有界。
|
||||
|
||||
#### KV Cache 影响
|
||||
#### KV Cache effect
|
||||
|
||||
只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
|
||||
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。
|
||||
|
||||
## 已知局限与延期工作
|
||||
### Tool errors
|
||||
|
||||
- **仅前台**——没有 `run_in_background`;长时间运行的工作必须留在执行器超时之内,或等待 bash 工具孪生。
|
||||
- **无沙箱升级**——没有 `sandbox_permissions`/`justification`;受约束的组合通过执行器拒绝,升级等待完整孪生。
|
||||
- **PowerShell 方言契约**——模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **Windows 默认路线图延期**——让 Windows 主机默认用 `pwsh` 而非 `bash`,以及 pwsh TUI/GUI 渲染支持,都另行规划,刻意不纳入本包。
|
||||
#### What the model sees
|
||||
|
||||
校验与基础设施失败规范化为 `Error: <message>`。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>`、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。
|
||||
|
||||
#### Token effect
|
||||
|
||||
只有失败的调用会新增这些保留 token;被中止的调用不产生命令输出。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-env": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -43,13 +43,16 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
31
packages/bash/tool-pwsh/src/background.ts
Normal file
31
packages/bash/tool-pwsh/src/background.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Generic-task adaptation for background pwsh process handles — the shell-agnostic
|
||||
* twin of `dsh-tool-bash`'s background adaptation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh/background
|
||||
*/
|
||||
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */
|
||||
|
||||
/**
|
||||
* Map a settled background process onto the generic task-outcome vocabulary:
|
||||
* `killed` stays `killed` (detail: the signal when one is known), everything
|
||||
* else is `completed` with the exit code as detail. A nonzero command exit is
|
||||
* reported, not failed, exactly like the foreground rendering.
|
||||
* @param proc - the settled process handle.
|
||||
* @returns the outcome for the `ctx.tasks` registration.
|
||||
*/
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
|
||||
// infrastructure-failure outcome, then map spawn failures and
|
||||
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
|
||||
// failure with a signal-less kill and a runner failure with an ordinary
|
||||
// wrapper exit; real nonzero command exits must remain `completed`.
|
||||
if (proc.status === 'killed') {
|
||||
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
||||
}
|
||||
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
@@ -4,38 +4,48 @@
|
||||
* `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is
|
||||
* PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables.
|
||||
*
|
||||
* Minimal by design: no background tasks, no sandbox escalation — this is the
|
||||
* "works on my Windows machine" profile until the full bash-tool feature set
|
||||
* gets a PowerShell twin.
|
||||
* Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface:
|
||||
* foreground and `run_in_background` execution (background handles register
|
||||
* with the generic `ctx.tasks` runtime), the managed `DSH_*` environment
|
||||
* through the shared `bash-env` registry, and the bash marker/truncation
|
||||
* rendering story. UI presentation stays on the existing generic/terminal
|
||||
* cards; a pwsh-specific rendering twin is roadmap work.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh
|
||||
*/
|
||||
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import type { TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, DshEnvironment } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
pwsh: 'pwsh'
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'tool-pwsh'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv']
|
||||
|
||||
/** Plugin config (currently empty; kept as a schema so deployments can grow it). */
|
||||
/** Configuration for the pwsh tool. */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
|
||||
enableRunInBackground?: boolean
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the pwsh tool plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
|
||||
@@ -44,6 +54,7 @@ interface PwshToolArgs {
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
run_in_background?: boolean
|
||||
}
|
||||
|
||||
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
|
||||
@@ -72,12 +83,18 @@ function validatePwshArgs(args: PwshToolArgs): void {
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function pwshDescription(): string {
|
||||
function pwshDescription(backgroundEnabled: boolean): string {
|
||||
const background = backgroundEnabled
|
||||
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
|
||||
: 'Background execution is not available; long-running commands must finish within the timeout.'
|
||||
return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. '
|
||||
+ 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — '
|
||||
+ 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment '
|
||||
+ 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.'
|
||||
+ 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. '
|
||||
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
||||
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
|
||||
+ background
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,32 +110,7 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent
|
||||
return modelWorkdir
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing text of one foreground pwsh result: stdout, a marked
|
||||
* stderr section, then the applicable timeout, signal, and exit markers —
|
||||
* each separated by a newline only when the accumulated text lacks one, so a
|
||||
* trailing newline in stdout never produces a blank line.
|
||||
*
|
||||
* @param value - the canonical foreground result (the schema-derived value shape).
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
function renderPwshOutput(value: RenderablePwshOutput): string {
|
||||
let rendered = value.stdout.text
|
||||
const marker = (line: string): void => {
|
||||
rendered += rendered.length > 0 && !rendered.endsWith('\n') ? `\n${line}` : line
|
||||
}
|
||||
if (value.stderr.text.length > 0) marker(`[stderr]\n${value.stderr.text}`)
|
||||
if (value.timedOut) marker(`[timed out after ${value.timeoutMs}ms]`)
|
||||
if (value.signal !== null) marker(`[killed by signal: ${value.signal}]`)
|
||||
if (value.exitCode !== null) marker(`[exit code: ${value.exitCode}]`)
|
||||
return rendered
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the executor DTO from readonly seam interfaces into plain JSON data.
|
||||
* @param result - the executor's run outcome.
|
||||
* @returns the canonical foreground result the tool returns and renders.
|
||||
*/
|
||||
/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */
|
||||
function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
|
||||
const output = (stream: BashRunResult['stdout']) => ({
|
||||
text: stream.text,
|
||||
@@ -132,48 +124,32 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
|
||||
timedOut: result.timedOut,
|
||||
aborted: result.aborted,
|
||||
timeoutMs: result.timeoutMs,
|
||||
/* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */
|
||||
stdout: output(result.stdout),
|
||||
stderr: output(result.stderr),
|
||||
}
|
||||
}
|
||||
|
||||
/** The rendered fields of a foreground result — the schema-derived value shape (no `kind`, plain-string signal). */
|
||||
interface RenderablePwshOutput {
|
||||
exitCode: number | null
|
||||
signal: string | null
|
||||
timedOut: boolean
|
||||
timeoutMs: number
|
||||
stdout: { text: string }
|
||||
stderr: { text: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed `DSH_*` snapshot for one pwsh call: the harness home, a shell
|
||||
* marker, and the session identity when an agent is present.
|
||||
*/
|
||||
function collectDshEnv(exec: ToolExecution, dshHome: string): DshEnvironment {
|
||||
const values: Record<string, string> = {
|
||||
[DSH_HOME_ENV]: dshHome,
|
||||
[`${DSH_ENV_PREFIX}SHELL`]: '1',
|
||||
}
|
||||
if (exec.agent !== undefined) {
|
||||
values[`${DSH_ENV_PREFIX}SESSION_ID`] = exec.agent.session.header.id
|
||||
}
|
||||
return values
|
||||
}
|
||||
/** Canonical background-handle properties shared by the pwsh output union. */
|
||||
const BACKGROUND_OUTPUT_PROPERTIES = {
|
||||
kind: { type: 'string', required: true, const: 'background' },
|
||||
taskId: { type: 'string', required: true },
|
||||
} as const
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const dshHome = resolveDshHome(config.dshHome)
|
||||
const backgroundEnabled = config.enableRunInBackground ?? true
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:pwsh',
|
||||
order: 105,
|
||||
text: 'Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.',
|
||||
text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. '
|
||||
+ 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'pwsh',
|
||||
description: pwshDescription(),
|
||||
description: pwshDescription(backgroundEnabled),
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The PowerShell command to execute.' },
|
||||
description: {
|
||||
@@ -185,59 +161,111 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
},
|
||||
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
|
||||
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
|
||||
...backgroundEnabled ? {
|
||||
run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' },
|
||||
} : {},
|
||||
},
|
||||
output: {
|
||||
// The foreground result wire shape mirrors dsh-tool-bash's by contract —
|
||||
// consumers of one must accept the other (see the pwsh-tool-and-executor
|
||||
// Agent Note).
|
||||
/* jscpd:ignore-start -- deliberate foreground-result schema symmetry with dsh-tool-bash. */
|
||||
/* jscpd:ignore-start -- deliberate result-schema symmetry with dsh-tool-bash. */
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', required: true, const: 'foreground' },
|
||||
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
||||
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
timedOut: { type: 'boolean', required: true },
|
||||
aborted: { type: 'boolean', required: true },
|
||||
timeoutMs: { type: 'number', required: true },
|
||||
stdout: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
properties: BACKGROUND_OUTPUT_PROPERTIES,
|
||||
},
|
||||
stderr: {
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
kind: { type: 'string', required: true, const: 'foreground' },
|
||||
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
||||
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||
timedOut: { type: 'boolean', required: true },
|
||||
aborted: { type: 'boolean', required: true },
|
||||
timeoutMs: { type: 'number', required: true },
|
||||
stdout: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
text: { type: 'string', required: true },
|
||||
truncated: { type: 'boolean', required: true },
|
||||
spillPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: renderPwshOutput(value),
|
||||
text: value.kind === 'background'
|
||||
? `started background task ${value.taskId}`
|
||||
: renderPwshResult(value),
|
||||
}],
|
||||
},
|
||||
/* jscpd:ignore-start -- the foreground execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */
|
||||
/* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */
|
||||
async execute(args: PwshToolArgs, exec) {
|
||||
validatePwshArgs(args)
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
const request = {
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
dshEnv: collectDshEnv(exec, dshHome),
|
||||
dshEnv: ctx.bashEnv.collect(exec),
|
||||
}
|
||||
if (args.run_in_background === true) {
|
||||
// Undeclared keys are allowed, so schema omission also needs enforcement.
|
||||
if (!backgroundEnabled) {
|
||||
throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)')
|
||||
}
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks === undefined) {
|
||||
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
}
|
||||
// The caller owns cancellation until ctx.tasks commits detached ownership.
|
||||
/* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort;
|
||||
pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts
|
||||
already-aborted signals first, so this mirror-only guard has no reachable trigger. */
|
||||
if (exec.signal.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore end */
|
||||
// Task preflight finishes before the starter can spawn a process.
|
||||
const id = tasks.start({
|
||||
kind: 'pwsh',
|
||||
label: args.command,
|
||||
...exec.agent ? { owner: exec.agent } : {},
|
||||
run: () => {
|
||||
const proc = ctx.bash.start(ctx.bash.resolve(request))
|
||||
return {
|
||||
cancel: () => void proc.kill(),
|
||||
done: proc.done.then(() => processOutcome(proc)),
|
||||
readOutput: () => renderPwshProcessRead(proc.readOutput()),
|
||||
}
|
||||
},
|
||||
})
|
||||
return { kind: 'background' as const, taskId: id }
|
||||
}
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
...request,
|
||||
signal: exec.signal,
|
||||
}))
|
||||
if (result.aborted) {
|
||||
|
||||
81
packages/bash/tool-pwsh/src/render.ts
Normal file
81
packages/bash/tool-pwsh/src/render.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Model-facing result rendering for the pwsh tool — the PowerShell twin of
|
||||
* `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked
|
||||
* stderr section, truncation notices with spill paths, then exit-status
|
||||
* markers. Non-zero exits are reported, not errored — the model decides how to
|
||||
* react; only infrastructure failures (spawn errors, aborts) surface as
|
||||
* isError results.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh/render
|
||||
*/
|
||||
|
||||
import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */
|
||||
|
||||
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
|
||||
function streamText(output: CollectedOutput): string {
|
||||
if (!output.truncated) return output.text
|
||||
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
|
||||
}
|
||||
|
||||
/** The renderable foreground result shape (the schema-derived value, no `kind`). */
|
||||
export interface RenderablePwshResult {
|
||||
exitCode: number | null
|
||||
signal: string | null
|
||||
timedOut: boolean
|
||||
timeoutMs: number
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape one finished run into the text the model sees: stdout, then a marked
|
||||
* stderr section, then exit-status markers, matching the bash tool's story —
|
||||
* a clean exit (0, no signal) produces no marker.
|
||||
* @param result - the completed foreground run from the executor.
|
||||
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
|
||||
*/
|
||||
export function renderPwshResult(result: RenderablePwshResult): string {
|
||||
const out = streamText(result.stdout)
|
||||
const err = streamText(result.stderr)
|
||||
|
||||
let body = out
|
||||
if (err.length > 0) {
|
||||
// Single newline between sections (stdout usually ends with one already).
|
||||
if (body.length > 0 && !body.endsWith('\n')) body += '\n'
|
||||
body += `[stderr]\n${err}`
|
||||
}
|
||||
if (body.length === 0) body = '(no output)'
|
||||
|
||||
const markers: string[] = []
|
||||
// A command may trap the termination and exit 0 after timeout; still report interruption.
|
||||
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
|
||||
if (result.signal !== null) {
|
||||
markers.push(`[killed by signal: ${result.signal}]`)
|
||||
} else if (result.exitCode !== 0) {
|
||||
markers.push(`[exit code: ${result.exitCode}]`)
|
||||
}
|
||||
if (markers.length === 0) return body
|
||||
|
||||
if (!body.endsWith('\n')) body += '\n'
|
||||
return body + markers.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape one background-process read into the `task_output` delta the model
|
||||
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
||||
* spill paths) when in-memory truncation dropped unread bytes.
|
||||
* @param read - one incremental read from the process handle.
|
||||
* @returns the delta text with any loss notice appended.
|
||||
*/
|
||||
export function renderPwshProcessRead(read: BashProcessRead): string {
|
||||
const notices: string[] = []
|
||||
if (read.lossy) {
|
||||
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined)
|
||||
notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
|
||||
}
|
||||
if (notices.length === 0) return read.delta
|
||||
return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
@@ -2,10 +2,11 @@
|
||||
* Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the
|
||||
* `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell
|
||||
* process. These verify the world — actual commands run, stdout/stderr come
|
||||
* back, exit codes render, timeouts abort, and per-session cwd resolution
|
||||
* works. The suite self-skips when no `pwsh` is on PATH (a CI accommodation
|
||||
* for hosts without PowerShell); the fake-executor suite (tools.spec.ts)
|
||||
* carries the coverage gate.
|
||||
* back, exit codes render, timeouts abort, background tasks settle through the
|
||||
* generic task runtime, and per-session cwd resolution works. The suite
|
||||
* self-skips when no `pwsh` is on PATH (a CI accommodation for hosts without
|
||||
* PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage
|
||||
* gate.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -17,13 +18,18 @@ import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { PwshLocalExecutor, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
// The probe follows the executor's own resolution (Program Files installs on
|
||||
// Windows are found even when bare `pwsh` is not on PATH).
|
||||
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
|
||||
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
@@ -54,7 +60,10 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolPwsh)
|
||||
})
|
||||
@@ -65,12 +74,12 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => {
|
||||
|
||||
const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
|
||||
|
||||
it('runs a command and returns stdout with the exit marker', async () => {
|
||||
it('runs a command and returns stdout with no marker on a clean exit', async () => {
|
||||
const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected pwsh success')
|
||||
expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 })
|
||||
expect(lf(text(result))).toBe('hi\n[exit code: 0]')
|
||||
expect(lf(text(result))).toBe('hi\n')
|
||||
})
|
||||
|
||||
it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => {
|
||||
@@ -88,7 +97,7 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => {
|
||||
description: 'read greeting',
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
expect(lf(text(result))).toBe('hello pwsh\n[exit code: 0]')
|
||||
expect(lf(text(result))).toBe('hello pwsh\n')
|
||||
})
|
||||
|
||||
it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => {
|
||||
@@ -116,4 +125,30 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => {
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
|
||||
})
|
||||
|
||||
it('a background run settles through the REAL task_output tool', async () => {
|
||||
const started = await call('pwsh', {
|
||||
command: 'Start-Sleep -Milliseconds 300; Write-Output bg-done',
|
||||
description: 'background greeting',
|
||||
run_in_background: true,
|
||||
})
|
||||
expect(started.isError).toBe(false)
|
||||
if (started.isError) throw new Error('expected background pwsh success')
|
||||
expect(started.value).toMatchObject({ kind: 'background' })
|
||||
const taskId = (started.value as { taskId: string }).taskId
|
||||
|
||||
// The output delta and the terminal status can land in separate reads
|
||||
// (Windows flushes the child pipe at exit), so collect incrementally —
|
||||
// the same two-step shape as the bash background suite.
|
||||
const deadline = Date.now() + 10_000
|
||||
let output = ''
|
||||
while (Date.now() < deadline) {
|
||||
const read = await call('task_output', { task_id: taskId })
|
||||
output += text(read)
|
||||
if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
expect(output).toContain('bg-done')
|
||||
expect(output).toContain('[status: completed, exit code: 0]')
|
||||
})
|
||||
})
|
||||
|
||||
63
packages/bash/tool-pwsh/tests/loader.spec.ts
Normal file
63
packages/bash/tool-pwsh/tests/loader.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* REAL-composition tier (packages/AGENTS.md): boot the examples-owned
|
||||
* tool-pwsh Loader fixture as a subprocess through the same app/boot path a
|
||||
* deployment uses, execute real foreground and background pwsh commands
|
||||
* through the tool registry, and assert the assembled model-visible surface:
|
||||
* schema, prompt section, and rendered results. Self-skips when no `pwsh`
|
||||
* executable exists (a CI accommodation for hosts without PowerShell).
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
// The probe follows the executor's own resolution (Program Files installs on
|
||||
// Windows are found even when bare `pwsh` is not on PATH).
|
||||
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
const driver = fileURLToPath(new URL(
|
||||
'../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts',
|
||||
import.meta.url,
|
||||
))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
interface PwshLoaderReport {
|
||||
schemaHasRunInBackground: boolean
|
||||
promptHasMarkerSection: boolean
|
||||
foregroundText: string
|
||||
backgroundText: string
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => {
|
||||
it('registers the pwsh surface and renders real foreground and background results', async () => {
|
||||
let report: PwshLoaderReport | undefined
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'tool-pwsh loader smoke',
|
||||
tempDirPrefix: 'tool-pwsh-loader-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
inspect: async (cwd) => {
|
||||
report = JSON.parse(await readFile(join(cwd, 'pwsh-loader-report.json'), 'utf8')) as PwshLoaderReport
|
||||
},
|
||||
})
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(report).toBeDefined()
|
||||
expect(report).toMatchObject({
|
||||
schemaHasRunInBackground: true,
|
||||
promptHasMarkerSection: true,
|
||||
})
|
||||
expect(report?.foregroundText).toBe('loader-ok\n')
|
||||
expect(report?.backgroundText).toContain('loader-bg-ok')
|
||||
expect(report?.backgroundText).toContain('[status: completed, exit code: 0]')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -2,10 +2,11 @@
|
||||
* Consumer-surface tests for the `pwsh` tool over a FAKE bash executor,
|
||||
* exercised through `ctx.tools.execute()` so nothing bypasses the tool
|
||||
* registry. The fake executor makes every seam outcome scriptable — output
|
||||
* text, truncation, timeout, abort, nonzero exits — so these tests verify the
|
||||
* schema, argument validation, workdir derivation, managed `DSH_*` collection,
|
||||
* abort translation, canonical result projection, rendering, and the UI
|
||||
* presenters. Real-pwsh behavior is pinned separately in integration.spec.ts.
|
||||
* text, truncation, timeout, abort, nonzero exits, background handles — so
|
||||
* these tests verify the schema, argument validation, workdir derivation,
|
||||
* managed `DSH_*` collection, abort translation, canonical result projection,
|
||||
* rendering, background task wiring, and the UI presenters. Real-pwsh behavior
|
||||
* is pinned separately in integration.spec.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -15,23 +16,33 @@ import { tmpdir } from 'node:os'
|
||||
import { join, resolve as resolvePath } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashProcessRead } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
import { renderPwshProcessRead } from '../src/render.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/**
|
||||
* A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()`
|
||||
* returns the armed script, `start()` throws — the pwsh tool must NEVER create
|
||||
* a background task.
|
||||
* returns the armed foreground script, `start()` returns the armed background
|
||||
* handle.
|
||||
*/
|
||||
class FakeBash extends BashExecutor {
|
||||
requests: BashExecRequest[] = []
|
||||
specs: BashExecSpec[] = []
|
||||
startCalls = 0
|
||||
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
|
||||
backgroundHandler: (spec: BashExecSpec) => BashProcess = () => fakeProcess('bg-ok\n')
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
@@ -53,9 +64,10 @@ class FakeBash extends BashExecutor {
|
||||
return this.handler(spec)
|
||||
}
|
||||
|
||||
override start(): BashProcess {
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
this.startCalls++
|
||||
throw new Error('the pwsh tool must never start a background task')
|
||||
this.specs.push(spec)
|
||||
return this.backgroundHandler(spec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,33 +85,95 @@ function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunR
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(config: Partial<ToolPwsh.Config> = {}) {
|
||||
/** A settled successful background handle; overrides script failure shapes. */
|
||||
function fakeProcess(delta = 'bg-ok\n'): BashProcess {
|
||||
let consumed = false
|
||||
return {
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => {
|
||||
if (consumed) return { delta: '', lossy: false }
|
||||
consumed = true
|
||||
return { delta, lossy: false }
|
||||
},
|
||||
kill: () => false,
|
||||
}
|
||||
}
|
||||
|
||||
/** A running background handle whose kill() settles it as killed (like a real task_kill). */
|
||||
function killableProcess(): BashProcess {
|
||||
let resolveDone: () => void = () => {}
|
||||
const done = new Promise<void>((resolve) => { resolveDone = resolve })
|
||||
const proc: BashProcess = {
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done,
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => {
|
||||
if (proc.status !== 'running') return false
|
||||
proc.status = 'killed'
|
||||
proc.signal = 'SIGTERM'
|
||||
resolveDone()
|
||||
return true
|
||||
},
|
||||
}
|
||||
return proc
|
||||
}
|
||||
|
||||
async function setup(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(FakeBash)
|
||||
await ctx.plugin(ToolPwsh, config)
|
||||
await ctx.plugin(ToolPwsh, toolConfig)
|
||||
const bash = ctx.bash as FakeBash
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/** A stand-in agent whose session header carries the given cwd and id. */
|
||||
const agent = (cwd?: string, id = 'session-1') => ({ session: { header: { id, ...cwd !== undefined ? { cwd } : {} } } })
|
||||
/** Full harness: the generic task runtime + its control surface, then the pwsh tool. */
|
||||
async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(FakeBash)
|
||||
await ctx.plugin(ToolPwsh, toolConfig)
|
||||
const bash = ctx.bash as FakeBash
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake {@link Agent} with the shared agent/session identity, give it a
|
||||
* dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
|
||||
*/
|
||||
function registerFakeAgent(ctx: Context, sessionId: string): Agent {
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const id = SessionId(sessionId)
|
||||
const agent = {
|
||||
id,
|
||||
ctx: scopeFiber.ctx,
|
||||
session: { id, header: { version: 0, id, createdAt: 0 } },
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
options: { agent?: object; signal?: AbortSignal } = {},
|
||||
) {
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...options.agent ? { agent: options.agent as never } : {},
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
...agent ? { agent } : {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,6 +181,23 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
async function callUntilText(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
expected: string,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<Awaited<ReturnType<typeof call>>> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let last: Awaited<ReturnType<typeof call>> | undefined
|
||||
while (Date.now() < deadline) {
|
||||
last = await call(ctx, name, args)
|
||||
if (text(last).includes(expected)) return last
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`)
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers the pwsh tool with its prompt section and schema', async () => {
|
||||
const { ctx } = await setup()
|
||||
@@ -118,10 +209,12 @@ describe('registration', () => {
|
||||
description: { type: 'string' },
|
||||
timeoutMs: { type: 'number' },
|
||||
workdir: { type: 'string' },
|
||||
run_in_background: { type: 'boolean' },
|
||||
})
|
||||
expect(schema?.parameters.required).toEqual(['command', 'description'])
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Check the [exit code: N] marker on every pwsh result')
|
||||
expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers')
|
||||
expect(prompt).toContain('without a signal marker')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.bash exists (inject)', async () => {
|
||||
@@ -136,6 +229,7 @@ describe('registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(FakeBash)
|
||||
const fiber = await ctx.plugin(ToolPwsh)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
@@ -157,13 +251,15 @@ describe('argument validation', () => {
|
||||
describe('execution through the bash seam', () => {
|
||||
it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => {
|
||||
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-'))
|
||||
const { ctx, bash } = await setup({ dshHome })
|
||||
const { ctx, bash } = await setup({}, dshHome)
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const agent = registerFakeAgent(ctx, 'session-1')
|
||||
Object.assign(agent.session.header, { cwd: '/sessions/s1' })
|
||||
const result = await call(ctx, 'pwsh', {
|
||||
command: 'Write-Output hi',
|
||||
description: 'say hi',
|
||||
timeoutMs: 1234,
|
||||
}, { agent: agent('/sessions/s1') })
|
||||
}, agent)
|
||||
expect(result.isError).toBe(false)
|
||||
const request = bash.requests[0]
|
||||
expect(request?.command).toBe('Write-Output hi')
|
||||
@@ -180,9 +276,11 @@ describe('execution through the bash seam', () => {
|
||||
it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, { agent: agent('/sessions/s1') })
|
||||
const agent = registerFakeAgent(ctx, 'session-cwd')
|
||||
Object.assign(agent.session.header, { cwd: '/sessions/s1' })
|
||||
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent)
|
||||
expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir'))
|
||||
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, { agent: agent('/sessions/s1') })
|
||||
await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent)
|
||||
expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path'))
|
||||
})
|
||||
|
||||
@@ -202,7 +300,12 @@ describe('execution through the bash seam', () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }, { signal: controller.signal })
|
||||
await ctx.tools.execute({
|
||||
signal: controller.signal,
|
||||
callId: CallId('call-signal'),
|
||||
name: 'pwsh',
|
||||
arguments: { command: 'Write-Output ok', description: 'ok' },
|
||||
})
|
||||
expect(bash.requests[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
@@ -229,19 +332,60 @@ describe('execution through the bash seam', () => {
|
||||
expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]')
|
||||
})
|
||||
|
||||
it('renders the truncation tail, the exit marker, and a timeout marker from the executor streams', async () => {
|
||||
it('renders a clean exit without a marker and an empty body as (no output)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
|
||||
expect(text(clean)).toBe('hi\n')
|
||||
|
||||
bash.handler = () => runResult('')
|
||||
const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' })
|
||||
expect(text(empty)).toBe('(no output)')
|
||||
})
|
||||
|
||||
it('renders stderr-only output without a stdout prefix', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', {
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
exitCode: 1,
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
|
||||
expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]')
|
||||
})
|
||||
|
||||
it('inserts the separating newline before the stderr section when stdout lacks one', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('out', {
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
exitCode: 1,
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
|
||||
expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]')
|
||||
})
|
||||
|
||||
it('renders the truncation notice with the spill path, then markers', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('tail', {
|
||||
stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
|
||||
expect(text(result)).toBe('tail\n[exit code: 0]')
|
||||
expect(text(result)).toBe('tail\n[output truncated; full output: /spill/out.log]')
|
||||
|
||||
bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 })
|
||||
const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' })
|
||||
// A timeout kill carries both facts, mirroring the bash tool's markers.
|
||||
expect(text(timedOut)).toBe('[timed out after 500ms]\n[killed by signal: SIGTERM]')
|
||||
expect(text(timedOut)).toBe('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]')
|
||||
})
|
||||
|
||||
it('renders the truncation notice with (unavailable) when no spill path exists', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('tail', {
|
||||
stdout: { text: 'tail', truncated: true },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
|
||||
expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]')
|
||||
})
|
||||
|
||||
it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => {
|
||||
@@ -251,15 +395,124 @@ describe('execution through the bash seam', () => {
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
|
||||
})
|
||||
})
|
||||
|
||||
it('never starts a background task', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' })
|
||||
bash.handler = () => runResult('', { exitCode: 1 })
|
||||
await call(ctx, 'pwsh', { command: 'missing', description: 'missing' })
|
||||
describe('background execution through the task runtime', () => {
|
||||
it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
|
||||
const { ctx } = await setupWithTasks()
|
||||
const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true })
|
||||
expect(started.isError).toBe(false)
|
||||
if (started.isError) throw new Error('expected background pwsh success')
|
||||
expect(started.value).toEqual({ kind: 'background', taskId: 'pwsh-1' })
|
||||
expect(text(started)).toBe('started background task pwsh-1')
|
||||
|
||||
const read = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, 'bg-ok')
|
||||
expect(text(read)).toContain('bg-ok')
|
||||
// A later read reports the terminal outcome in the generic status line.
|
||||
const final = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, '[status: completed, exit code: 0]')
|
||||
expect(final.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('a running background task is killable through the REAL task_kill tool', async () => {
|
||||
const { ctx, bash } = await setupWithTasks()
|
||||
bash.backgroundHandler = () => killableProcess()
|
||||
await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
|
||||
|
||||
const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' })
|
||||
expect(text(killed)).toBe('requested cancellation of task pwsh-1')
|
||||
// The cancel reached the process handle; the task settles as killed with
|
||||
// the signal detail mapped by processOutcome.
|
||||
const final = await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true })
|
||||
expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
|
||||
})
|
||||
|
||||
it('a background task started by an agent is registered with that agent as owner', async () => {
|
||||
const { ctx } = await setupWithTasks()
|
||||
const agent = registerFakeAgent(ctx, 'sess-owner')
|
||||
const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent)
|
||||
expect(text(started)).toBe('started background task pwsh-1')
|
||||
|
||||
const anon = await call(ctx, 'task_output', { task_id: 'pwsh-1' })
|
||||
expect(anon.isError).toBe(true)
|
||||
expect(text(anon)).toMatch(/belongs to another session/)
|
||||
|
||||
const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }, agent)
|
||||
expect(killed.isError).toBe(false)
|
||||
await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan
|
||||
})
|
||||
|
||||
it('fails loud when the task runtime is not loaded', async () => {
|
||||
const { ctx } = await setup() // no LocalTaskService / ToolTasks
|
||||
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
})
|
||||
|
||||
it('a pre-aborted call is skipped before the process starts', async () => {
|
||||
const { ctx, bash } = await setupWithTasks()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('call-pre-aborted'),
|
||||
name: 'pwsh',
|
||||
arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true },
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({
|
||||
message: 'tool call aborted before dispatch',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
})
|
||||
expect(bash.startCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
|
||||
// With no control surface, task preflight fails before the executor can spawn.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(FakeBash)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
const bash = ctx.bash as FakeBash
|
||||
|
||||
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface is attached')
|
||||
// Declare-then-execute: the failed preflight means no process ever ran.
|
||||
expect(bash.startCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('enableRunInBackground: false removes the parameter and flips the description', async () => {
|
||||
const { ctx } = await setup({ enableRunInBackground: false })
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')!
|
||||
expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
|
||||
.toEqual(['command', 'description', 'timeoutMs', 'workdir'])
|
||||
expect(schema.description).toContain('Background execution is not available')
|
||||
expect(schema.description).not.toContain('run_in_background')
|
||||
|
||||
// Schema omission is advertising; execution must also enforce the opt-out.
|
||||
const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true })
|
||||
expect(forced.isError).toBe(true)
|
||||
expect(text(forced)).toContain('run_in_background is disabled for this deployment')
|
||||
const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' })
|
||||
expect(foreground.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('applies the built-in background default when apply() receives a bare config', async () => {
|
||||
// Bypasses the schemastery defaults on purpose: apply() must stand on its
|
||||
// own `?? true` fallback when embedded programmatically without the schema.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(FakeBash)
|
||||
ToolPwsh.apply(ctx, {})
|
||||
const schema = ctx.tools.schemas()[0]!
|
||||
expect(schema.parameters.properties).toHaveProperty('run_in_background')
|
||||
expect(schema.description).toContain('task_output')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI presentation', () => {
|
||||
@@ -267,11 +520,11 @@ describe('UI presentation', () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const args = { command: 'Write-Output hi', description: 'say hi' }
|
||||
const result = await call(ctx, 'pwsh', args, { agent: agent('/w') })
|
||||
const result = await call(ctx, 'pwsh', args)
|
||||
const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
|
||||
expect(view).toEqual({
|
||||
card: 'generic',
|
||||
content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }],
|
||||
content: [{ type: 'text', text: '```console\nhi\n```' }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -294,3 +547,75 @@ describe('UI presentation', () => {
|
||||
expect(definition?.presentResult?.(args, image as never)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderPwshProcessRead', () => {
|
||||
const base: BashProcessRead = { delta: 'out\n', lossy: false }
|
||||
|
||||
it('returns the delta verbatim for a lossless read', () => {
|
||||
expect(renderPwshProcessRead(base)).toBe('out\n')
|
||||
expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('')
|
||||
})
|
||||
|
||||
it('appends the loss notice with the available spill paths', () => {
|
||||
expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]')
|
||||
expect(renderPwshProcessRead({
|
||||
...base,
|
||||
lossy: true,
|
||||
stdoutSpillPath: 'C:\\spill\\out.log',
|
||||
stderrSpillPath: 'C:\\spill\\err.log',
|
||||
}))
|
||||
.toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]')
|
||||
})
|
||||
|
||||
it('reports (unavailable) when a lossy read has no safe spill path', () => {
|
||||
expect(renderPwshProcessRead({ ...base, lossy: true }))
|
||||
.toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
|
||||
it('an empty lossy delta is the notice alone', () => {
|
||||
expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' }))
|
||||
.toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]')
|
||||
})
|
||||
|
||||
it('inserts the separating newline only when the delta lacks one', () => {
|
||||
expect(renderPwshProcessRead({ delta: 'tail', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true }))
|
||||
.toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('processOutcome', () => {
|
||||
function settled(over: Partial<BashProcess>): BashProcess {
|
||||
return {
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
readOutput: () => ({ delta: '', lossy: false }),
|
||||
kill: () => false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
it('maps a signal-killed process to killed with the signal detail', () => {
|
||||
expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
|
||||
.toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
})
|
||||
|
||||
it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
|
||||
expect(processOutcome(settled({ status: 'killed', exitCode: null })))
|
||||
.toEqual({ status: 'killed', detail: 'killed before exit' })
|
||||
})
|
||||
|
||||
it('maps a completed process to its exit code', () => {
|
||||
expect(processOutcome(settled({ exitCode: 3 })))
|
||||
.toEqual({ status: 'completed', detail: 'exit code: 3' })
|
||||
})
|
||||
|
||||
it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
|
||||
expect(processOutcome(settled({ exitCode: null })))
|
||||
.toEqual({ status: 'completed', detail: 'exit code: 0' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
|
||||
Reference in New Issue
Block a user