Merge origin/master into codex/sandbox-policy-context
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/core/system-prompt/README.md
|
||||
README.md: 79badba0b84b27c01f25e9c31b5df78c556411ea
|
||||
README.zh.md: fb3ed08bc9ea546033acac3b577980f500ce243d
|
||||
README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f
|
||||
README.zh.md: 1fd4febc1c15acda19e7abfca94079b9585c1972
|
||||
|
||||
@@ -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 `'<unlisted-tools>'` 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
|
||||
|
||||
|
||||
@@ -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` 列表,包含一个 `'<unlisted-tools>'` 其余项(`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 影响
|
||||
|
||||
|
||||
@@ -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<Config> = 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -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: acd8c06940b03e90e368314cd725846a2b92b656
|
||||
README.md: 6bbd99217bcdce0e8a0e8fd22a8d39d0c64224b9
|
||||
README.zh.md: 4a7e7a1b7eced9317f94eeddf2442ded9a3e0e13
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 交给工具注册表以配置呈现模式;`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` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `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 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。
|
||||
组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现模式;`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` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `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 生产方;独立加载的生产方保留各自配置。工作区指令先于 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 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个组合包。
|
||||
- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务;配置可以省略组合包内的目标、skill、bash 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个组合包。
|
||||
- **不变式 seam 与配套插件仍是固定成员**:`invariants.enabled: false` 或包筛选器会抑制检查,但不会移除服务或配套插件注册;Session 始终启用的校验与冻结是另一套机制。
|
||||
|
||||
@@ -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`). */
|
||||
@@ -102,10 +105,14 @@ export interface Config {
|
||||
sessionTitle?: SessionTitleConfig
|
||||
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
|
||||
workspaceContext: workspaceContext.Config | false
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
/**
|
||||
* Skill registry, local provider, and model-facing consumer config.
|
||||
* Skills use `enabled` because one nested config controls a provider stack;
|
||||
* single model-tool plugins use `Config | false` to disable that one consumer.
|
||||
*/
|
||||
skills?: SkillConfig
|
||||
/** Model-facing bash tool config, 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 +134,8 @@ export const SessionTitleConfigSchema: z<SessionTitleConfig> = SessionTitleServi
|
||||
.default(EXAMPLE_SESSION_TITLE_CONFIG)
|
||||
|
||||
/** The bash-tool config schema exported for app packages that forward `toolBash`. */
|
||||
export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
|
||||
export const ToolBashConfigSchema: z<toolBash.Config | false> =
|
||||
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> = toolTasks.Config
|
||||
@@ -163,6 +171,7 @@ export const Config = z.intersect([
|
||||
export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'agents'> {
|
||||
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 +210,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 +233,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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -654,9 +654,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 },
|
||||
@@ -670,6 +688,7 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
}
|
||||
|
||||
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
|
||||
includeHarnessIdentity: appConfig.includeHarnessIdentity,
|
||||
persona: appConfig.persona,
|
||||
toolOrder: appConfig.toolOrder,
|
||||
tools: appConfig.tools,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/README.md
|
||||
README.md: 4d954455ea920be4882530bcfe90b48a364c29b5
|
||||
README.zh.md: ed154cbbaf7c7f45c42c17799957254fee918153
|
||||
README.md: b5e0ac9d1c0c550eb372b8a66fc6358711fddc07
|
||||
README.zh.md: ee64a617aa0d9549bcaf00b20821a6d59c6673c8
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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(agent 决策记录)](../../.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 所述的共置部署。
|
||||
|
||||
|
||||
6
packages/fs/tool-str-replace-editor/README.i18n.yaml
Normal file
6
packages/fs/tool-str-replace-editor/README.i18n.yaml
Normal file
@@ -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: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f
|
||||
README.zh.md: 48358eb3c9d81ddad6a83c4ff3ef0cf6542096b1
|
||||
52
packages/fs/tool-str-replace-editor/README.md
Normal file
52
packages/fs/tool-str-replace-editor/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# @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. |
|
||||
|
||||
## Tool
|
||||
|
||||
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### 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. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
|
||||
|
||||
#### Token effect
|
||||
|
||||
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.
|
||||
- Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins.
|
||||
52
packages/fs/tool-str-replace-editor/README.zh.md
Normal file
52
packages/fs/tool-str-replace-editor/README.zh.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# @deepseek-ai/dsh-tool-str-replace-editor
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端表面组合。
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 默认值 | 含义 |
|
||||
|---|---:|---|
|
||||
| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 |
|
||||
| `description` | 编辑器命令指南 | 面向模型的工具描述。 |
|
||||
|
||||
## 工具
|
||||
|
||||
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 不变时前缀稳定。
|
||||
|
||||
### 工具结果
|
||||
|
||||
#### 模型所见
|
||||
|
||||
查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
随数据变化,并受 `maxOutputChars` 与固定截断提示约束。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
工具结果以追加方式位于可复用请求前缀之后。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- 操作面向 UTF-8 文本,不支持二进制文件。
|
||||
- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
|
||||
- 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。
|
||||
54
packages/fs/tool-str-replace-editor/package.json
Normal file
54
packages/fs/tool-str-replace-editor/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"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-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"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-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
522
packages/fs/tool-str-replace-editor/src/index.ts
Normal file
522
packages/fs/tool-str-replace-editor/src/index.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* 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, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallView, ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const TRUNCATED_MESSAGE = '<response clipped><NOTE>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.</NOTE>'
|
||||
|
||||
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 \`<response clipped>\`
|
||||
|
||||
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 codepointCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function matchOffsets(content: string, search: string): number[] {
|
||||
const offsets: number[] = []
|
||||
let offset = 0
|
||||
while (true) {
|
||||
const match = content.indexOf(search, offset)
|
||||
if (match < 0) return offsets
|
||||
offsets.push(match)
|
||||
offset = match + search.length
|
||||
}
|
||||
}
|
||||
|
||||
function lineNumbersAt(content: string, offsets: readonly number[]): number[] {
|
||||
let line = 1
|
||||
let cursor = 0
|
||||
return offsets.map((offset) => {
|
||||
while (cursor < offset) {
|
||||
if (content[cursor] === '\n') line += 1
|
||||
cursor += 1
|
||||
}
|
||||
return line
|
||||
})
|
||||
}
|
||||
|
||||
class MutationPolicy {
|
||||
private readonly policy: SandboxPolicyService | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
this.policy = ctx.fs.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy')
|
||||
if (ctx.fs.sandboxMode !== undefined && this.policy === undefined) {
|
||||
throw new Error('tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing')
|
||||
}
|
||||
}
|
||||
|
||||
resolve(exec: ToolRunContext): SandboxExecutionPolicy | undefined {
|
||||
return this.policy?.resolve({
|
||||
...exec.agent === undefined ? {} : { session: exec.agent.session },
|
||||
})
|
||||
}
|
||||
|
||||
mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown {
|
||||
if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
|
||||
const mode = (policy as SandboxExecutionPolicy).mode
|
||||
return new FsError(sandboxDenialMarker(mode), 'FS_SANDBOX_DENIED', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTarget(
|
||||
ctx: Context,
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FsTarget> {
|
||||
if (path.trim().length === 0) throw new Error('path must be a non-empty string')
|
||||
if (!isAbsolute(path)) {
|
||||
throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`)
|
||||
}
|
||||
return ctx.fs.resolve(path, { signal })
|
||||
}
|
||||
|
||||
async function statExisting(
|
||||
ctx: Context,
|
||||
target: FsTarget,
|
||||
command: 'view' | 'str_replace' | 'insert',
|
||||
exec: ToolRunContext,
|
||||
): Promise<FsInfo> {
|
||||
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 = lines
|
||||
.map((line, index) => `${String(initialLine + index).padStart(6, ' ')} ${line}`)
|
||||
.join('\n')
|
||||
return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars)
|
||||
}
|
||||
|
||||
async function listDirectory(
|
||||
ctx: Context,
|
||||
target: FsTarget,
|
||||
maxOutputChars: number,
|
||||
exec: ToolRunContext,
|
||||
): Promise<string> {
|
||||
async function visit(dir: FsTarget, depth: number): Promise<string[]> {
|
||||
const entries = await ctx.fs.listDir(dir, exec.signal)
|
||||
const rows: string[] = []
|
||||
for (const entry of entries.filter(candidate =>
|
||||
!candidate.name.startsWith('.')
|
||||
&& candidate.name !== 'node_modules'
|
||||
&& candidate.name !== '__pycache__')) {
|
||||
const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?'
|
||||
rows.push(`${type}\t${entry.target.displayPath}`)
|
||||
if (entry.type === 'directory' && depth < 2) {
|
||||
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 codepointCompare(leftPath, rightPath)
|
||||
})
|
||||
const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars)
|
||||
return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n`
|
||||
}
|
||||
|
||||
async function viewPath(
|
||||
ctx: Context,
|
||||
path: string,
|
||||
viewRange: number[] | undefined,
|
||||
maxOutputChars: number,
|
||||
exec: ToolRunContext,
|
||||
): Promise<string> {
|
||||
const target = await resolveTarget(ctx, path, exec.signal)
|
||||
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,
|
||||
policy: MutationPolicy,
|
||||
path: string,
|
||||
fileText: string | undefined,
|
||||
exec: ToolRunContext,
|
||||
): Promise<string> {
|
||||
const content = requiredForCommand(fileText, 'file_text', 'create')
|
||||
const sandboxPolicy = policy.resolve(exec)
|
||||
const target = await resolveTarget(ctx, path, exec.signal)
|
||||
if (await ctx.fs.stat(target, exec.signal) !== undefined) {
|
||||
throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`)
|
||||
}
|
||||
const intent = await ctx.waterfall(
|
||||
'fs/write-intent',
|
||||
target,
|
||||
exec,
|
||||
() => ({ kind: 'createIfAbsent' } as const),
|
||||
)
|
||||
let outcome
|
||||
try {
|
||||
outcome = await ctx.fs.writeText(
|
||||
target,
|
||||
content,
|
||||
intent,
|
||||
exec.signal,
|
||||
sandboxPolicy,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
throw policy.mapError(error, sandboxPolicy)
|
||||
}
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
return `New file created successfully at: ${target.displayPath}`
|
||||
}
|
||||
|
||||
async function replaceInFile(
|
||||
ctx: Context,
|
||||
policy: MutationPolicy,
|
||||
path: string,
|
||||
oldStr: string | undefined,
|
||||
newStr: string | undefined,
|
||||
exec: ToolRunContext,
|
||||
): Promise<string> {
|
||||
const sandboxPolicy = policy.resolve(exec)
|
||||
const target = await resolveTarget(ctx, path, exec.signal)
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false)
|
||||
const newValue = newStr ?? ''
|
||||
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 = await ctx.fs.readText(target, exec.signal)
|
||||
const offsets = matchOffsets(before, oldValue)
|
||||
const offset = offsets[0]
|
||||
if (offset === undefined) {
|
||||
throw new FsError(
|
||||
`No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`,
|
||||
'FS_EDIT_NOT_FOUND',
|
||||
)
|
||||
}
|
||||
if (offsets.length > 1) {
|
||||
const lines = lineNumbersAt(before, offsets)
|
||||
throw new FsError(
|
||||
`No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`,
|
||||
'FS_AMBIGUOUS_EDIT',
|
||||
)
|
||||
}
|
||||
let outcome
|
||||
try {
|
||||
outcome = await ctx.fs.writeText(
|
||||
target,
|
||||
before.slice(0, offset) + newValue + before.slice(offset + oldValue.length),
|
||||
intent === undefined
|
||||
? { kind: 'replaceIfVersion', version: info.version }
|
||||
: { kind: 'replaceIfVersion', version: intent.version },
|
||||
exec.signal,
|
||||
sandboxPolicy,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
throw policy.mapError(error, sandboxPolicy)
|
||||
}
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
return `The file ${target.displayPath} has been edited successfully.`
|
||||
}
|
||||
|
||||
async function insertInFile(
|
||||
ctx: Context,
|
||||
policy: MutationPolicy,
|
||||
path: string,
|
||||
insertLine: number | undefined,
|
||||
newStr: string | undefined,
|
||||
exec: ToolRunContext,
|
||||
): Promise<string> {
|
||||
if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert')
|
||||
const value = requiredForCommand(newStr, 'new_str', 'insert')
|
||||
const sandboxPolicy = policy.resolve(exec)
|
||||
const target = await resolveTarget(ctx, path, exec.signal)
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
const info = await statExisting(ctx, target, 'insert', exec)
|
||||
if (info.type !== 'file') {
|
||||
throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
const before = 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 expected: FsWriteIntent = intent === undefined
|
||||
? { kind: 'replaceIfVersion', version: info.version }
|
||||
: { kind: 'replaceIfVersion', version: intent.version }
|
||||
let outcome
|
||||
try {
|
||||
outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy)
|
||||
} catch (error: unknown) {
|
||||
throw policy.mapError(error, sandboxPolicy)
|
||||
}
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
return `The file ${target.displayPath} has been edited successfully.`
|
||||
}
|
||||
|
||||
interface ResolvedConfig {
|
||||
maxOutputChars: number
|
||||
description: string
|
||||
}
|
||||
|
||||
function presentEditorCall(args: {
|
||||
command: 'view' | 'create' | 'str_replace' | 'insert'
|
||||
path: string
|
||||
file_text?: string
|
||||
insert_line?: number
|
||||
new_str?: string
|
||||
old_str?: string
|
||||
}): ToolCallView {
|
||||
switch (args.command) {
|
||||
case 'view':
|
||||
return {
|
||||
card: 'generic',
|
||||
title: `view ${args.path}`,
|
||||
kind: 'read',
|
||||
locations: [{ path: args.path }],
|
||||
}
|
||||
case 'create':
|
||||
return {
|
||||
card: 'diff',
|
||||
title: `create ${args.path}`,
|
||||
diffs: [{ path: args.path, oldText: null, newText: args.file_text ?? '' }],
|
||||
locations: [{ path: args.path }],
|
||||
}
|
||||
case 'str_replace':
|
||||
return {
|
||||
card: 'diff',
|
||||
title: `str_replace ${args.path}`,
|
||||
diffs: [{
|
||||
path: args.path,
|
||||
oldText: args.old_str ?? null,
|
||||
newText: args.new_str ?? '',
|
||||
}],
|
||||
locations: [{ path: args.path }],
|
||||
}
|
||||
case 'insert':
|
||||
return {
|
||||
card: 'generic',
|
||||
title: `insert ${args.path}`,
|
||||
kind: 'edit',
|
||||
locations: [{
|
||||
path: args.path,
|
||||
...args.insert_line === undefined ? {} : { line: Math.max(1, args.insert_line + 1) },
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the model-facing `str_replace_editor` tool. */
|
||||
function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
|
||||
const policy = new MutationPolicy(ctx)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'str_replace_editor',
|
||||
description: config.description,
|
||||
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, exec)
|
||||
case 'create':
|
||||
return createFile(ctx, policy, args.path, args.file_text, exec)
|
||||
case 'str_replace':
|
||||
return replaceInFile(
|
||||
ctx,
|
||||
policy,
|
||||
args.path,
|
||||
args.old_str,
|
||||
args.new_str,
|
||||
exec,
|
||||
)
|
||||
case 'insert':
|
||||
return insertInFile(
|
||||
ctx,
|
||||
policy,
|
||||
args.path,
|
||||
args.insert_line,
|
||||
args.new_str,
|
||||
exec,
|
||||
)
|
||||
}
|
||||
},
|
||||
presentCall: presentEditorCall,
|
||||
}))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the string-replacement editor tool. */
|
||||
export const Config: z<Config> = z.object({
|
||||
maxOutputChars: z.number().default(16_000),
|
||||
description: z.string().default(DEFAULT_DESCRIPTION),
|
||||
})
|
||||
|
||||
/** 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,
|
||||
}
|
||||
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)
|
||||
}
|
||||
30
packages/fs/tool-str-replace-editor/src/invariant.ts
Normal file
30
packages/fs/tool-str-replace-editor/src/invariant.ts
Normal file
@@ -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 */
|
||||
547
packages/fs/tool-str-replace-editor/tests/tools.spec.ts
Normal file
547
packages/fs/tool-str-replace-editor/tests/tools.spec.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
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 * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
|
||||
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
|
||||
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: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
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 = {},
|
||||
options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {},
|
||||
) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-'))
|
||||
roots.push(root)
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (options.sandboxMode === undefined) {
|
||||
await ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
} else {
|
||||
await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root })
|
||||
await ctx.plugin(SandboxedFileSystem, { cwd: root })
|
||||
}
|
||||
if (options.fsPolicy === true) await ctx.plugin(FsPolicy)
|
||||
const fiber = await ctx.plugin(ToolStrReplaceEditor, config)
|
||||
return { ctx, root, fiber, owner: agent(ctx, root) }
|
||||
}
|
||||
|
||||
describe('tool-str-replace-editor', () => {
|
||||
it('registers the standalone schema and configurable description', async () => {
|
||||
const { ctx, fiber } = await setup({ description: 'custom editor description' })
|
||||
const schema = ctx.tools.schemas()[0]
|
||||
expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor'])
|
||||
expect(schema?.description).toBe('custom editor description')
|
||||
const properties = (schema?.parameters as {
|
||||
properties: Record<string, { type?: string; items?: { type?: string } }>
|
||||
}).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',
|
||||
locations: [{ path: '/workspace/a.txt' }],
|
||||
})
|
||||
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
|
||||
command: 'create',
|
||||
path: '/workspace/a.txt',
|
||||
file_text: 'hello',
|
||||
})).toMatchObject({
|
||||
card: 'diff',
|
||||
diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }],
|
||||
})
|
||||
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
|
||||
command: 'str_replace',
|
||||
path: '/workspace/a.txt',
|
||||
old_str: 'old',
|
||||
new_str: 'new',
|
||||
})).toMatchObject({
|
||||
card: 'diff',
|
||||
diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }],
|
||||
})
|
||||
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
|
||||
command: 'insert',
|
||||
path: '/workspace/a.txt',
|
||||
insert_line: 0,
|
||||
new_str: 'x',
|
||||
})).toMatchObject({
|
||||
card: 'generic',
|
||||
kind: 'edit',
|
||||
locations: [{ path: '/workspace/a.txt', line: 1 }],
|
||||
})
|
||||
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
|
||||
command: 'create',
|
||||
path: '/workspace/empty.txt',
|
||||
})).toMatchObject({
|
||||
diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }],
|
||||
})
|
||||
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
|
||||
command: 'str_replace',
|
||||
path: '/workspace/a.txt',
|
||||
})).toMatchObject({
|
||||
diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }],
|
||||
})
|
||||
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
|
||||
command: 'insert',
|
||||
path: '/workspace/a.txt',
|
||||
})).toMatchObject({
|
||||
locations: [{ path: '/workspace/a.txt' }],
|
||||
})
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toEqual([])
|
||||
expect(ctx.tools.get('str_replace_editor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
|
||||
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('writes replacement text literally', async () => {
|
||||
const { ctx, root, owner } = await setup()
|
||||
const sample = join(root, 'literal.txt')
|
||||
const replacement = "$&|$`|$'|$$"
|
||||
await writeFile(sample, 'before OLD after')
|
||||
|
||||
expect((await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path: sample,
|
||||
old_str: 'OLD',
|
||||
new_str: replacement,
|
||||
})).isError).toBe(false)
|
||||
expect(await readFile(sample, 'utf8')).toBe(`before ${replacement} after`)
|
||||
})
|
||||
|
||||
it('lists visible entries to depth two and clips at the configured view limit', async () => {
|
||||
const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 })
|
||||
await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true })
|
||||
await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true })
|
||||
await mkdir(join(root, 'dir', 'node_modules_old'), { recursive: true })
|
||||
await mkdir(join(root, 'dir', '__pycache__'), { recursive: true })
|
||||
await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true })
|
||||
await writeFile(join(root, 'dir', 'visible.txt'), 'ok')
|
||||
await writeFile(join(root, 'dir', '.hidden'), 'hidden')
|
||||
await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child')
|
||||
await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep')
|
||||
await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency')
|
||||
await writeFile(join(root, 'dir', 'node_modules_old', 'kept.js'), 'visible source')
|
||||
await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache')
|
||||
await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source')
|
||||
const listDir = ctx.fs.listDir.bind(ctx.fs)
|
||||
const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other'))
|
||||
ctx.fs.listDir = async (target, signal) => {
|
||||
const entries = await listDir(target, signal)
|
||||
return target.displayPath === join(root, 'dir')
|
||||
? [
|
||||
{ name: 'same-target', type: 'other', target: otherTarget },
|
||||
{ name: 'other', type: 'other', target: otherTarget },
|
||||
...entries.toReversed(),
|
||||
]
|
||||
: entries
|
||||
}
|
||||
|
||||
const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') }))
|
||||
expect(listing).not.toContain('.hidden')
|
||||
expect(listing).not.toContain('too-deep.txt')
|
||||
expect(listing).not.toContain('index.js')
|
||||
expect(listing).not.toContain('module.pyc')
|
||||
expect(listing).toContain('node_modules_old/kept.js')
|
||||
expect(listing).toContain('__pycache__backup/kept.py')
|
||||
|
||||
const clipped = await setup({ maxOutputChars: 10 })
|
||||
await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100))
|
||||
expect(text(await call(clipped.ctx, clipped.owner, {
|
||||
command: 'view',
|
||||
path: join(clipped.root, 'large.txt'),
|
||||
})))
|
||||
.toContain('<response clipped>')
|
||||
})
|
||||
|
||||
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')
|
||||
expect((await call(ctx, undefined, {
|
||||
command: 'create',
|
||||
path: join(root, 'ownerless.txt'),
|
||||
file_text: 'ownerless',
|
||||
})).isError).toBe(false)
|
||||
|
||||
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')
|
||||
|
||||
await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta')
|
||||
const repeatedMultiline = await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path: ambiguous,
|
||||
old_str: 'alpha\nbeta',
|
||||
new_str: 'x',
|
||||
})
|
||||
expect(text(repeatedMultiline))
|
||||
.toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]')
|
||||
|
||||
const mixedEol = join(root, 'mixed-eol.txt')
|
||||
await writeFile(mixedEol, 'alpha\r\nbeta\nmiddle\nalpha\nbeta')
|
||||
expect((await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path: mixedEol,
|
||||
old_str: 'alpha\r\nbeta',
|
||||
new_str: 'replaced',
|
||||
})).isError).toBe(false)
|
||||
expect(await readFile(mixedEol, 'utf8')).toBe('replaced\nmiddle\nalpha\nbeta')
|
||||
|
||||
const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' })
|
||||
expect(relative.isError).toBe(true)
|
||||
expect(text(relative)).toContain('is not an absolute path')
|
||||
expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta')
|
||||
})
|
||||
|
||||
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('delegates read-before-edit decisions to fs-policy', async () => {
|
||||
const { ctx, root, owner } = await setup({}, { fsPolicy: true })
|
||||
const existing = join(root, 'existing.txt')
|
||||
const created = join(root, 'created.txt')
|
||||
await writeFile(existing, 'before')
|
||||
|
||||
const blindEdit = await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path: existing,
|
||||
old_str: 'before',
|
||||
new_str: 'after',
|
||||
})
|
||||
expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
|
||||
expect(await readFile(existing, 'utf8')).toBe('before')
|
||||
|
||||
await call(ctx, owner, { command: 'view', path: existing })
|
||||
expect((await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path: existing,
|
||||
old_str: 'before',
|
||||
new_str: 'after',
|
||||
})).isError).toBe(false)
|
||||
expect(await readFile(existing, 'utf8')).toBe('after')
|
||||
|
||||
expect((await call(ctx, owner, {
|
||||
command: 'insert',
|
||||
path: existing,
|
||||
insert_line: 1,
|
||||
new_str: 'tail',
|
||||
})).isError).toBe(false)
|
||||
expect(await readFile(existing, 'utf8')).toBe('after\ntail')
|
||||
|
||||
expect((await call(ctx, owner, {
|
||||
command: 'create',
|
||||
path: created,
|
||||
file_text: 'new',
|
||||
})).isError).toBe(false)
|
||||
expect(await readFile(created, 'utf8')).toBe('new')
|
||||
})
|
||||
|
||||
it('passes the session sandbox policy to every mutation', async () => {
|
||||
const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' })
|
||||
const path = join(root, 'blocked.txt')
|
||||
const result = await call(ctx, owner, {
|
||||
command: 'create',
|
||||
path,
|
||||
file_text: 'blocked',
|
||||
})
|
||||
expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
|
||||
expect(text(result)).toContain('[sandbox: file access denied under read-only mode]')
|
||||
|
||||
const ownerless = await call(ctx, undefined, {
|
||||
command: 'create',
|
||||
path: join(root, 'ownerless-blocked.txt'),
|
||||
file_text: 'blocked',
|
||||
})
|
||||
expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
|
||||
})
|
||||
|
||||
it('preserves tabs outside the edited region', async () => {
|
||||
const { ctx, root, owner } = await setup()
|
||||
const path = join(root, 'Makefile')
|
||||
await writeFile(path, 'target:\n\told\nremove\n')
|
||||
expect(text(await call(ctx, owner, { command: 'view', path })))
|
||||
.toContain(' 2 \told')
|
||||
await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path,
|
||||
old_str: '\told',
|
||||
new_str: '\tnew',
|
||||
})
|
||||
await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path,
|
||||
old_str: 'remove\n',
|
||||
})
|
||||
await call(ctx, owner, {
|
||||
command: 'insert',
|
||||
path,
|
||||
insert_line: 1,
|
||||
new_str: '\tkept',
|
||||
})
|
||||
expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n')
|
||||
})
|
||||
|
||||
it('reports missing sandbox-policy composition during plugin startup', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-'))
|
||||
roots.push(root)
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' })
|
||||
|
||||
await expect(ctx.plugin(ToolStrReplaceEditor))
|
||||
.rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing')
|
||||
})
|
||||
|
||||
it('maps unexpected backend write failures for replace and insert', async () => {
|
||||
const { ctx, root, owner } = await setup()
|
||||
const path = join(root, 'backend-error.txt')
|
||||
await writeFile(path, 'old\n')
|
||||
const failWrite = async (): Promise<never> => {
|
||||
throw new Error('backend write failed')
|
||||
}
|
||||
ctx.fs.writeText = failWrite
|
||||
|
||||
const replace = await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path,
|
||||
old_str: 'old',
|
||||
new_str: 'new',
|
||||
})
|
||||
expect(replace.isError).toBe(true)
|
||||
expect(text(replace)).toContain('backend write failed')
|
||||
|
||||
const insert = await call(ctx, owner, {
|
||||
command: 'insert',
|
||||
path,
|
||||
insert_line: 1,
|
||||
new_str: 'new',
|
||||
})
|
||||
expect(insert.isError).toBe(true)
|
||||
expect(text(insert)).toContain('backend write failed')
|
||||
})
|
||||
|
||||
it('rejects invalid plugin config', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
16
packages/fs/tool-str-replace-editor/tsconfig.json
Normal file
16
packages/fs/tool-str-replace-editor/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../fs" },
|
||||
{ "path": "../../sandbox/sandbox" },
|
||||
{ "path": "../../sandbox/sandbox-policy" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/pty/README.md
|
||||
README.md: a9121455519a5f83a63a005cb857fec0f0e06b92
|
||||
README.zh.md: 4e36ef934d31861d72748b9b9585695f86749171
|
||||
README.md: e54dcf64db083665f37b7dc19a7a92e21494442b
|
||||
README.zh.md: 0bb7e565a5799002ffb8b74dce12e129a7b2665e
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。
|
||||
|
||||
6
packages/pty/tool-bash-persistent/README.i18n.yaml
Normal file
6
packages/pty/tool-bash-persistent/README.i18n.yaml
Normal file
@@ -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: cfb3acf41803f5b06ecb3f56ee8ffc29ce9147b1
|
||||
README.zh.md: a525485933344a8f9bc218456c099efdef572902
|
||||
50
packages/pty/tool-bash-persistent/README.md
Normal file
50
packages/pty/tool-bash-persistent/README.md
Normal file
@@ -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` | Maximum retained command-output characters; fixed diagnostics are added afterward. |
|
||||
| `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. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result.
|
||||
|
||||
#### 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` and timeout discard shell state. Cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell.
|
||||
- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default.
|
||||
50
packages/pty/tool-bash-persistent/README.zh.md
Normal file
50
packages/pty/tool-bash-persistent/README.zh.md
Normal file
@@ -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 提示符。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell,并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并报告该重置。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
随数据变化。`maxOutputChars` 限制保留的命令输出;固定的截断、前缀丢失、状态、超时与重置诊断可能使结果更长。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
工具结果以追加方式位于可复用请求前缀之后。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- 工具需要拥有它的 Agent 和真实 PTY 后端。
|
||||
- 显式 `exit` 与超时会丢弃 shell 状态。取消同样会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此;下次调用创建新 shell。
|
||||
- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。
|
||||
55
packages/pty/tool-bash-persistent/package.json
Normal file
55
packages/pty/tool-bash-persistent/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
445
packages/pty/tool-bash-persistent/src/index.ts
Normal file
445
packages/pty/tool-bash-persistent/src/index.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
|
||||
const TRUNCATED_MESSAGE = '<response clipped><NOTE>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.</NOTE>'
|
||||
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\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'
|
||||
// One page is enough to find a just-emitted completion marker; the full
|
||||
// scrollback is assembled only when a command settles or needs partial output.
|
||||
const SCROLLBACK_PAGE_LINES = 1_000
|
||||
const POLL_INTERVAL_MS = 25
|
||||
|
||||
const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
|
||||
|
||||
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
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
interface PersistentShells {
|
||||
get(owner: Agent, signal: AbortSignal): Promise<PtySessionId>
|
||||
reset(owner: Agent, reason: string): Promise<void>
|
||||
}
|
||||
|
||||
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.replace(/\r?\n$/, '')
|
||||
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)
|
||||
const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
|
||||
if (status === undefined) return undefined
|
||||
const startMarker = text.lastIndexOf(marker.start, end)
|
||||
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
|
||||
return {
|
||||
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
|
||||
incomplete: startMarker < 0,
|
||||
exitCode: Number(status),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
fallbackTruncated = false,
|
||||
): CapturedOutput {
|
||||
const startMarker = snapshot.text.lastIndexOf(marker.start)
|
||||
if (startMarker >= 0) {
|
||||
return {
|
||||
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
|
||||
incomplete: false,
|
||||
}
|
||||
}
|
||||
const fallbackStart = fallback.lastIndexOf(marker.start)
|
||||
const afterStart = fallbackStart < 0
|
||||
? fallback
|
||||
: fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
|
||||
const fallbackEnd = afterStart.lastIndexOf(marker.end)
|
||||
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
|
||||
return {
|
||||
text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')),
|
||||
incomplete: fallbackTruncated || fallbackStart < 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function pause(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
}
|
||||
|
||||
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,
|
||||
latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
|
||||
): RetainedOutput {
|
||||
const pages: string[] = latest.text.length === 0 ? [] : [latest.text]
|
||||
let offset = latest.lineEnd
|
||||
let truncated = latest.truncated
|
||||
while (true) {
|
||||
if (offset >= latest.totalLines) break
|
||||
const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
|
||||
truncated ||= page.truncated
|
||||
if (page.text.length > 0) pages.unshift(page.text)
|
||||
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)
|
||||
const withPrefix = output.incomplete && output.text.length > 0
|
||||
? LOST_PREFIX_MESSAGE + rendered
|
||||
: rendered
|
||||
const marker = output.exitCode !== undefined && output.exitCode !== 0
|
||||
? `[exit code: ${output.exitCode}]`
|
||||
: undefined
|
||||
return appendStatusMarker(withPrefix, marker)
|
||||
}
|
||||
|
||||
function appendStatusMarker(content: string, marker: string | undefined): string {
|
||||
if (marker === undefined) return content
|
||||
return content.length === 0 ? marker : `${content}\n${marker}`
|
||||
}
|
||||
|
||||
function renderShellExitStatus(
|
||||
content: string,
|
||||
exitCode: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): string {
|
||||
const marker = signal !== null
|
||||
? `[shell killed by signal: ${signal}]`
|
||||
: exitCode !== null
|
||||
? `[shell exited: code ${exitCode}]`
|
||||
: '[shell exited]'
|
||||
return appendStatusMarker(content, marker)
|
||||
}
|
||||
|
||||
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
|
||||
const pending = new WeakMap<Agent, Promise<PtySessionId>>()
|
||||
const live = new Map<Agent, PtySessionId>()
|
||||
const creating = new Set<Promise<PtySessionId>>()
|
||||
const ownerCleanupInstalled = new WeakSet<Agent>()
|
||||
const lifecycle = new AbortController()
|
||||
|
||||
const close = async (owner: Agent, id: PtySessionId, reason: string): Promise<void> => {
|
||||
if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return
|
||||
await ctx.pty.kill(owner, id, reason)
|
||||
}
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation'))
|
||||
await Promise.allSettled([...creating])
|
||||
const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
|
||||
await Promise.all(closing)
|
||||
live.clear()
|
||||
}, 'tool-bash-persistent shell cleanup')
|
||||
|
||||
const reset = async (owner: Agent, reason: string): Promise<void> => {
|
||||
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<PtySessionId> => {
|
||||
const existing = pending.get(owner)
|
||||
if (existing !== undefined) return existing
|
||||
const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
|
||||
const creation = (async () => {
|
||||
try {
|
||||
const cwd = owner.session.header.cwd
|
||||
const spawned = await ctx.pty.spawn(owner, {
|
||||
type: config.backendType,
|
||||
...cwd === undefined ? {} : { cwd },
|
||||
}, combinedSignal)
|
||||
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: combinedSignal,
|
||||
})
|
||||
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
|
||||
}
|
||||
})()
|
||||
const tracked = creation.finally(() => {
|
||||
creating.delete(tracked)
|
||||
})
|
||||
creating.add(tracked)
|
||||
pending.set(owner, tracked)
|
||||
return tracked
|
||||
}
|
||||
|
||||
return { get, reset }
|
||||
}
|
||||
|
||||
async function executeCommand(
|
||||
ctx: Context,
|
||||
shells: PersistentShells,
|
||||
owner: Agent,
|
||||
command: string,
|
||||
config: ResolvedConfig,
|
||||
upstream: AbortSignal,
|
||||
): Promise<string> {
|
||||
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 = ''
|
||||
let fallbackTruncated = false
|
||||
|
||||
while (true) {
|
||||
let operation
|
||||
let result
|
||||
try {
|
||||
operation = ctx.pty.startSend(owner, id, {
|
||||
text: first ? wrapped : '',
|
||||
submit: first,
|
||||
signal: commandDeadline.signal,
|
||||
})
|
||||
first = false
|
||||
result = await operation.done
|
||||
} catch (error: unknown) {
|
||||
await shells.reset(owner, 'persistent bash send failed')
|
||||
throw error
|
||||
}
|
||||
const incremental = operation.readOutput()
|
||||
fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
|
||||
fallbackTruncated ||= incremental.truncated || result.truncated
|
||||
const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES })
|
||||
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
|
||||
if (timedOut !== undefined) {
|
||||
const snapshot = retainedScrollback(ctx, owner, id, latest)
|
||||
const partial = renderCaptured(
|
||||
partialOutput(snapshot, marker, fallback, fallbackTruncated),
|
||||
config.maxOutputChars,
|
||||
)
|
||||
await shells.reset(owner, 'persistent bash command timed out')
|
||||
return [
|
||||
// TODO: Report a timeout only; this signal does not establish an OOM.
|
||||
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`,
|
||||
partial,
|
||||
SHELL_RESET_MESSAGE,
|
||||
].join('\n')
|
||||
}
|
||||
if (commandDeadline.signal.aborted) {
|
||||
await shells.reset(owner, 'persistent bash command aborted')
|
||||
commandDeadline.signal.throwIfAborted()
|
||||
}
|
||||
if (latest.text.includes(marker.end)) {
|
||||
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
|
||||
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
|
||||
}
|
||||
if (result.sessionStatus.kind === 'exited') {
|
||||
const snapshot = retainedScrollback(ctx, owner, id, latest)
|
||||
await shells.reset(owner, 'persistent bash shell exited')
|
||||
return [
|
||||
renderShellExitStatus(
|
||||
renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
|
||||
result.sessionStatus.exitCode,
|
||||
result.sessionStatus.signal,
|
||||
),
|
||||
SHELL_RESET_MESSAGE,
|
||||
].filter(part => part.length > 0).join('\n')
|
||||
}
|
||||
if (promptCompleted(result)) {
|
||||
const snapshot = retainedScrollback(ctx, owner, id, latest)
|
||||
return renderCaptured(
|
||||
partialOutput(snapshot, marker, fallback, fallbackTruncated),
|
||||
config.maxOutputChars,
|
||||
)
|
||||
}
|
||||
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<Agent, Promise<void>>()
|
||||
|
||||
const serialized = async <T>(owner: Agent, operation: () => Promise<T>): Promise<T> => {
|
||||
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<Config> = 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)
|
||||
}
|
||||
31
packages/pty/tool-bash-persistent/src/invariant.ts
Normal file
31
packages/pty/tool-bash-persistent/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 adapter's private owner-to-shell cache has no
|
||||
* observable event or data relation. Lifecycle tests prove its cleanup without
|
||||
* adding a public surface solely for an invariant.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
@@ -0,0 +1,157 @@
|
||||
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: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
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<string, unknown>([
|
||||
['@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<typeof context.loader.internal>
|
||||
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('<response clipped>')
|
||||
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)
|
||||
})
|
||||
538
packages/pty/tool-bash-persistent/tests/tools.spec.ts
Normal file
538
packages/pty/tool-bash-persistent/tests/tools.spec.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
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: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
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'
|
||||
| 'signal-exit'
|
||||
| 'unknown-exit'
|
||||
| 'wait-for-abort'
|
||||
| 'end-on-abort'
|
||||
| 'idle-then-normal'
|
||||
| 'large'
|
||||
| 'nonzero'
|
||||
| 'torn-status'
|
||||
| 'finish-torn-status'
|
||||
| 'end-only'
|
||||
| 'init-exit'
|
||||
| 'init-timeout'
|
||||
| 'spawn-error'
|
||||
| 'send-error'
|
||||
| 'prompt-after-idle'
|
||||
| 'empty-page-after-latest'
|
||||
|
||||
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 = ''
|
||||
historyTruncated = false
|
||||
|
||||
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 === 'send-error') throw new Error('stub send failed')
|
||||
if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') {
|
||||
const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => {
|
||||
request.signal?.addEventListener('abort', () => {
|
||||
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
|
||||
const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(request.text)?.[0]
|
||||
const output = this.mode === 'end-on-abort'
|
||||
? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}`
|
||||
: 'partial output'
|
||||
this.scrollback += output
|
||||
resolve(this.result(output, 'stdin_read'))
|
||||
}, { once: true })
|
||||
})
|
||||
return this.operation(done)
|
||||
}
|
||||
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-after-idle') {
|
||||
if (request.text.length > 0) {
|
||||
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
|
||||
const output = `${start ?? ''}\npartial syntax output\n`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
|
||||
}
|
||||
const output = `bash: syntax error\n${this.motd}`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') {
|
||||
const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
|
||||
const output = `bash: syntax error${newline}${this.motd}${newline}`
|
||||
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 === 'torn-status') {
|
||||
const output = `${start ?? ''}\nhello from stub\n${end ?? ''}`
|
||||
this.scrollback += output
|
||||
this.mode = 'finish-torn-status'
|
||||
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
|
||||
}
|
||||
if (this.mode === 'finish-torn-status') {
|
||||
const output = `7\n${this.motd}`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
if (this.mode === 'end-only') {
|
||||
const output = `recovered output\n${end ?? ''}0\n${this.motd}`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
const commandOutput = this.mode === 'large'
|
||||
? 'x'.repeat(100)
|
||||
: this.mode === 'nonzero' ? '' : 'hello from stub'
|
||||
const exitCode = this.mode === 'nonzero' ? 7 : 0
|
||||
const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}`
|
||||
this.scrollback += output
|
||||
if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') {
|
||||
const exitedOutput = `${start ?? ''}\nhello from stub\n`
|
||||
this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
|
||||
this.statusValue = this.mode === 'signal-exit'
|
||||
? { kind: 'exited', exitCode: null, signal: 'SIGTERM' }
|
||||
: this.mode === 'exit'
|
||||
? { kind: 'exited', exitCode: 9, signal: null }
|
||||
: { kind: 'exited', exitCode: null, signal: null }
|
||||
return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit')))
|
||||
}
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
|
||||
read(request: PtyReadRequest) {
|
||||
if (this.mode === 'empty-read') {
|
||||
return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }
|
||||
}
|
||||
if (this.mode === 'stalled-read') {
|
||||
return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false }
|
||||
}
|
||||
if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) {
|
||||
return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false }
|
||||
}
|
||||
const lines = this.scrollback.split('\n')
|
||||
return {
|
||||
text: this.scrollback,
|
||||
totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
|
||||
lineBegin: 0,
|
||||
lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length,
|
||||
truncated: this.historyTruncated,
|
||||
}
|
||||
}
|
||||
|
||||
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<ReturnType<StubPtySession['result']>>): 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, fiber } = 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)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toEqual([])
|
||||
expect(ctx.tools.get('bash')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
|
||||
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('<response clipped>')
|
||||
|
||||
session.mode = 'large'
|
||||
expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
|
||||
|
||||
session.mode = 'nonzero'
|
||||
expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]')
|
||||
|
||||
session.mode = 'exit'
|
||||
const exited = text(await call(ctx, owner, 'exit'))
|
||||
expect(exited).toContain('hello from')
|
||||
expect(exited).toContain('[shell exited: code 9]')
|
||||
expect(exited).not.toContain('[exit code: 9]')
|
||||
expect(exited).toContain('next bash call starts from the workspace')
|
||||
expect(session.closed).toContain('persistent bash shell exited')
|
||||
|
||||
await call(ctx, owner, 'new shell')
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
const replacement = stub.sessions[1]!
|
||||
replacement.mode = 'signal-exit'
|
||||
expect(text(await call(ctx, owner, 'kill shell')))
|
||||
.toContain('[shell killed by signal: SIGTERM]')
|
||||
|
||||
await call(ctx, owner, 'another shell')
|
||||
expect(stub.sessions).toHaveLength(3)
|
||||
const externallyClosed = ctx.pty.list(owner)[0]?.sessionId
|
||||
expect(externallyClosed).toBeDefined()
|
||||
await ctx.pty.kill(owner, externallyClosed!, 'external cleanup')
|
||||
await fiber.dispose()
|
||||
expect(stub.sessions[2]?.closed).toEqual(['external cleanup'])
|
||||
})
|
||||
|
||||
it('waits for status digits after a torn completion marker', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = 'torn-status'
|
||||
stub.sessions[0]!.scrollback = ''
|
||||
|
||||
expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]')
|
||||
})
|
||||
|
||||
it('reports a shell exit when the backend has no code or signal', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = 'unknown-exit'
|
||||
|
||||
expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]')
|
||||
})
|
||||
|
||||
it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
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')
|
||||
|
||||
session.mode = 'empty-page-after-latest'
|
||||
expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub')
|
||||
})
|
||||
|
||||
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
session.mode = 'prompt-after-idle'
|
||||
session.scrollback = ''
|
||||
const result = text(await call(ctx, owner, 'bad {'))
|
||||
expect(result).toContain('partial syntax output')
|
||||
expect(result).toContain('bash: syntax error')
|
||||
expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
|
||||
expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
|
||||
})
|
||||
|
||||
it('does not attribute old scrollback truncation to a complete current command', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.historyTruncated = true
|
||||
const result = text(await call(ctx, owner, 'short command'))
|
||||
expect(result).toBe('hello from stub')
|
||||
expect(result).not.toContain('<response clipped>')
|
||||
expect(result).not.toContain('beginning of this command output was dropped')
|
||||
})
|
||||
|
||||
it('closes a timed-out shell and reports bounded partial output', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
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.each(['wait-for-abort', 'end-on-abort'] as const)(
|
||||
'cancels %s work, resets the shell, and releases a queued call',
|
||||
async (mode) => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = mode
|
||||
const controller = new AbortController()
|
||||
const cancelled = call(ctx, owner, 'hang', controller.signal)
|
||||
const queued = call(ctx, owner, 'after cancellation')
|
||||
setTimeout(() => {
|
||||
controller.abort(new Error('caller stopped'))
|
||||
}, 5)
|
||||
|
||||
expect((await cancelled).isError).toBe(true)
|
||||
expect(text(await queued)).toBe('hello from stub')
|
||||
expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted')
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
},
|
||||
)
|
||||
|
||||
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('resets a cached shell after startSend fails', async () => {
|
||||
const { ctx, owner, stub } = await setup()
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = 'send-error'
|
||||
expect((await call(ctx, owner, 'fails')).isError).toBe(true)
|
||||
expect(stub.sessions[0]?.closed).toContain('persistent bash send failed')
|
||||
expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub')
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(PtyService)
|
||||
const spawnStarted = Promise.withResolvers<undefined>()
|
||||
const spawnAborted = Promise.withResolvers<undefined>()
|
||||
ctx.pty.registerBackend({
|
||||
type: 'slow',
|
||||
spawn: spec => new Promise((_resolve, reject) => {
|
||||
spawnStarted.resolve(undefined)
|
||||
spec.signal?.addEventListener('abort', () => {
|
||||
spawnAborted.resolve(undefined)
|
||||
const reason: unknown = spec.signal?.reason
|
||||
reject(reason instanceof Error
|
||||
? reason
|
||||
: new Error('slow PTY spawn aborted', { cause: reason }))
|
||||
}, { once: true })
|
||||
}),
|
||||
})
|
||||
const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' })
|
||||
const owner = agent(ctx, '/workspace')
|
||||
const running = call(ctx, owner, 'pwd')
|
||||
await spawnStarted.promise
|
||||
await fiber.dispose()
|
||||
await spawnAborted.promise
|
||||
expect((await running).isError).toBe(true)
|
||||
expect(ctx.pty.list(owner)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects invalid config and invalid calls', async () => {
|
||||
const { ctx, owner, stub } = await setup()
|
||||
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
|
||||
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')
|
||||
})
|
||||
})
|
||||
16
packages/pty/tool-bash-persistent/tsconfig.json
Normal file
16
packages/pty/tool-bash-persistent/tsconfig.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user