fix(tools): harden persistent tool integrations

This commit is contained in:
Yichen Jiang
2026-07-29 15:21:56 +08:00
parent 73379d9c68
commit 260ea24594
26 changed files with 927 additions and 112 deletions
+9 -3
View File
@@ -153,7 +153,11 @@ export interface Config {
sessionTitle?: SessionTitleConfig sessionTitle?: SessionTitleConfig
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false 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 skills?: SkillConfig
/** Model-facing bash tool config, or false when another plugin owns `bash`. */ /** Model-facing bash tool config, or false when another plugin owns `bash`. */
toolBash?: toolBash.Config | false toolBash?: toolBash.Config | false
@@ -1589,7 +1593,7 @@ export interface Config {
} }
``` ```
Source: [`packages/pty/tool-bash-persistent/src/index.ts:340`](../packages/pty/tool-bash-persistent/src/index.ts) Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts)
## `@deepseek-ai/dsh-tool-cordis` ## `@deepseek-ai/dsh-tool-cordis`
@@ -1762,10 +1766,12 @@ export interface Config {
description?: string description?: string
/** Require local absolute paths like the canonical editor contract (default true). */ /** Require local absolute paths like the canonical editor contract (default true). */
requireAbsolutePath?: boolean requireAbsolutePath?: boolean
/** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */
expandTabsOnMutation?: boolean
} }
``` ```
Source: [`packages/fs/tool-str-replace-editor/src/index.ts:373`](../packages/fs/tool-str-replace-editor/src/index.ts) Source: [`packages/fs/tool-str-replace-editor/src/index.ts:539`](../packages/fs/tool-str-replace-editor/src/index.ts)
## `@deepseek-ai/dsh-tool-subagent` ## `@deepseek-ai/dsh-tool-subagent`
+2 -2
View File
@@ -26,9 +26,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
+3 -1
View File
@@ -670,6 +670,8 @@ flowchart TD
pkg_tool_fs_search --> pkg_tools pkg_tool_fs_search --> pkg_tools
pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_fs
pkg_tool_str_replace_editor --> pkg_invariants pkg_tool_str_replace_editor --> pkg_invariants
pkg_tool_str_replace_editor --> pkg_sandbox
pkg_tool_str_replace_editor --> pkg_sandbox_policy
pkg_tool_str_replace_editor --> pkg_tools pkg_tool_str_replace_editor --> pkg_tools
pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_agent
pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_invariants
@@ -1095,7 +1097,7 @@ flowchart TD
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
+3 -3
View File
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md
README.md: 6ee4e9d824315bde76b7a534679f018df9a6d3e8 README.md: 9a4c715e8988f52b647dbbd6b14a478cc1357d92
README.zh.md: dc9b6233e7074e7a9b13bf10bcd2f310b0ad7bf3 README.zh.md: fa792f9cf6bdad4f32a980f478a131f19de97611
+13
View File
@@ -25,3 +25,16 @@ The surrounding runtime also loads JSONL session persistence and automatic conte
| `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | | `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona |
Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js.
## Persistent tools variant
[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) is a minimal runnable variant whose model-facing surface is exactly:
- owner-scoped persistent `bash`
- `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`
It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless behavior snapshot drives the shipped JSON-RPC runtime through both tools and proves that shell cwd/environment survive across calls:
```bash
pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts
```
+13
View File
@@ -25,3 +25,16 @@
| `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 |
通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。
## 持久工具变体
[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) 是一个最小可运行变体,面向模型的能力严格只有:
- agent 独占、状态持久的 `bash`
- 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`
它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥行为快照会通过正式 JSON-RPC runtime 驱动这两个工具,并验证 shell 的 cwd 与环境变量能跨调用保留:
```bash
pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts
```
@@ -0,0 +1,58 @@
# Minimal unattended composition for the persistent Bash and string-replace
# editor. It is runnable through the JSON-RPC example runtime and intentionally
# keeps the model-facing surface to exactly these two tools.
- id: jsonrpc
name: '@deepseek-ai/dsh-jsonrpc'
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: danger-full-access
workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd()
- id: pty
name: '@deepseek-ai/dsh-pty'
- id: pty-local
name: '@deepseek-ai/dsh-pty-local'
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
config:
cwd: !!js process.env.DSH_CWD ?? process.cwd()
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: agent-spine
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
includeHarnessIdentity: false
persona: 'You are a helpful software engineer assistant.'
workspaceContext: false
skills:
enabled: false
toolBash: false
toolTasks: false
- id: persistent-bash
name: '@deepseek-ai/dsh-tool-bash-persistent'
- id: str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
- id: sessions
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions'
compression: none
@@ -0,0 +1,214 @@
import { createServer } from 'node:http'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
const configPath = fileURLToPath(new URL('../persistent-tools.cordis.yml', import.meta.url))
const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const expectedPath = fileURLToPath(new URL('./snapshots/persistent-tools/behavior.expected.json', import.meta.url))
interface ModelRequest {
messages?: Array<Record<string, unknown>>
tools?: Array<{ function?: { name?: string; parameters?: { required?: string[] } } }>
}
function sseToolCall(id: string, name: string, args: Record<string, unknown>): string[] {
return [
'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n',
`data: ${JSON.stringify({
choices: [{
delta: {
tool_calls: [{
index: 0,
id,
type: 'function',
function: { name, arguments: JSON.stringify(args) },
}],
},
}],
})}\n\n`,
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n',
'data: [DONE]\n\n',
]
}
function sseText(text: string): string[] {
return [
'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n',
`data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`,
'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n',
'data: [DONE]\n\n',
]
}
function messageText(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content.flatMap((block) => {
if (typeof block !== 'object' || block === null) return []
const text = (block as { text?: unknown }).text
return typeof text === 'string' ? [text] : []
}).join('')
}
function latestToolCall(messages: Array<Record<string, unknown>>): { id: string; name: string } {
for (const message of messages.toReversed()) {
const calls = message.tool_calls
if (!Array.isArray(calls)) continue
const call = (calls as unknown[]).at(-1)
if (typeof call !== 'object' || call === null) continue
const id = (call as { id?: unknown }).id
const fn = (call as { function?: { name?: unknown } }).function
if (typeof id === 'string' && typeof fn?.name === 'string') return { id, name: fn.name }
}
throw new Error('model request has no preceding tool call')
}
function normalize(value: string, cwd: string): string {
return value.replaceAll(cwd, '{{cwd}}')
}
describe('jsonrpc persistent tools snapshot', () => {
it('runs persistent shell state and editor mutations keylessly', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-persistent-tools-'))
const sessionRoot = join(cwd, '.sessions')
const target = join(cwd, 'note.txt')
const requests: ModelRequest[] = []
const modelServer = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
const parsed = JSON.parse(body) as ModelRequest
requests.push(parsed)
const messages = parsed.messages ?? []
const latest = messages.at(-1)
if (latest === undefined) throw new Error('model request has no messages')
let chunks: string[]
if (latest.role !== 'tool') {
chunks = sseToolCall('bash-1', 'bash', {
command: 'cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"',
})
} else {
const call = latestToolCall(messages)
const toolText = messageText(latest.content)
if (call.id === 'bash-1') {
expect(toolText).toContain('COUNT=1 CWD=/tmp')
chunks = sseToolCall('bash-2', 'bash', {
command: 'DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"',
})
} else if (call.id === 'bash-2') {
expect(toolText).toContain('COUNT=2 CWD=/tmp')
chunks = sseToolCall('editor-create', 'str_replace_editor', {
command: 'create',
path: target,
file_text: 'alpha\n',
})
} else if (call.id === 'editor-create') {
expect(toolText).toContain('New file created successfully')
chunks = sseToolCall('editor-replace', 'str_replace_editor', {
command: 'str_replace',
path: target,
old_str: 'alpha',
new_str: 'beta',
})
} else if (call.id === 'editor-replace') {
expect(toolText).toContain('has been edited successfully')
chunks = sseText('PERSISTENT_TOOLS_OK')
} else {
throw new Error(`unexpected tool call ${call.id}`)
}
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
for (const chunk of chunks) response.write(chunk)
response.end()
})
})
await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
const address = modelServer.address()
if (address === null || typeof address === 'string') throw new Error('model server did not bind')
const launch = resolveExampleLaunch({
srcBin: runtimeBin,
configArgs: [],
tsconfigPath: repoTsconfig,
})
const harness = new DeepSeekHarness({
launch: {
command: launch.command,
args: launch.args,
cwd: repoRoot,
env: {
...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record<string, string>,
...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record<string, string>,
DSH_CORDIS_CONFIG: configPath,
DSH_CWD: cwd,
DSH_SESSION_ROOT: sessionRoot,
DEEPSEEK_API_KEY: 'keyless-local-mock',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
},
requestTimeoutMs: 60_000,
},
cwd,
provider: 'deepseek',
model: 'deepseek-v4-flash',
})
try {
const result = await harness.run(
'Prove that bash state persists, then create and edit note.txt.',
{ sessionId: 'persistent-tools-snapshot' },
)
const calls = result.events.flatMap((event) => {
if (event.type !== 'tool/call') return []
return [{
name: event.data.name,
arguments: normalize(event.data.arguments, cwd),
}]
})
const results = result.events.flatMap((event) => {
if (event.type !== 'tool/result') return []
return event.data.message.content.flatMap((block) => {
if (block.type !== 'tool-result') return []
return block.content.flatMap(content =>
content.type === 'text'
? [{ text: normalize(content.text, cwd) }]
: [])
})
})
const tools = (requests[0]?.tools ?? []).map(tool => ({
name: tool.function?.name,
required: tool.function?.parameters?.required ?? [],
})).sort((left, right) => {
const leftName = String(left.name)
const rightName = String(right.name)
return leftName < rightName ? -1 : leftName > rightName ? 1 : 0
})
const behavior = {
tools,
calls,
results,
final: {
status: result.status,
reason: result.reason,
response: result.finalResponse,
file: await readFile(target, 'utf8'),
},
}
if (process.env.DSH_SNAPSHOT === 'refresh') {
await writeFile(expectedPath, `${JSON.stringify(behavior, null, 2)}\n`)
}
expect(behavior).toEqual(JSON.parse(await readFile(expectedPath, 'utf8')))
} finally {
await harness.close()
await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
await rm(cwd, { recursive: true, force: true })
}
}, 75_000)
})
@@ -0,0 +1,57 @@
{
"tools": [
{
"name": "bash",
"required": [
"command"
]
},
{
"name": "str_replace_editor",
"required": [
"command",
"path"
]
}
],
"calls": [
{
"name": "bash",
"arguments": "{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"
},
{
"name": "bash",
"arguments": "{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"
},
{
"name": "str_replace_editor",
"arguments": "{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"
},
{
"name": "str_replace_editor",
"arguments": "{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"
}
],
"results": [
{
"text": "COUNT=1 CWD=/tmp"
},
{
"text": "COUNT=2 CWD=/tmp"
},
{
"text": "New file created successfully at: {{cwd}}/note.txt"
},
{
"text": "The file {{cwd}}/note.txt has been edited successfully."
}
],
"final": {
"status": "ok",
"reason": {
"kind": "completed"
},
"response": "PERSISTENT_TOOLS_OK",
"file": "beta\n"
}
}
+2
View File
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-timeout-policy": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tool-ask-user": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:*",
"@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*",
"@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*",
"@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*",
@@ -64,6 +65,7 @@
"@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-pty": "workspace:*",
"@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*",
"@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*",
"@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*",
"@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*",
"@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*",
@@ -105,7 +105,11 @@ export interface Config {
sessionTitle?: SessionTitleConfig sessionTitle?: SessionTitleConfig
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false 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 skills?: SkillConfig
/** Model-facing bash tool config, or false when another plugin owns `bash`. */ /** Model-facing bash tool config, or false when another plugin owns `bash`. */
toolBash?: toolBash.Config | false toolBash?: toolBash.Config | false
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # pnpm run verify-translation-pairing --write packages/fs/tool-str-replace-editor/README.md
README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b README.md: 8ac6a22f24ddcdd3818b346e3426e58e718027e2
README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d README.zh.md: cf82b132b63730af209c159066a70f6a18b77f39
@@ -11,6 +11,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w
| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | | `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. |
| `description` | Editor command guide | Model-facing tool description. | | `description` | Editor command guide | Model-facing tool description. |
| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | | `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. |
| `expandTabsOnMutation` | `true` | Preserve the canonical Claude SWE behavior that expands tabs across the whole file before replace/insert. Set `false` for atomic literal replacement that preserves unrelated tabs. |
## Tool ## Tool
@@ -36,7 +37,7 @@ Prefix-stable while the configured description and schema remain unchanged.
#### What the model sees #### What the model sees
Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. Views return numbered text or a shallow directory listing. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
#### Token effect #### Token effect
@@ -50,5 +51,5 @@ Append-only tool results follow the reusable request prefix.
- Operations target UTF-8 text; binary files are unsupported. - Operations target UTF-8 text; binary files are unsupported.
- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. - `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument.
- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor. - Canonical mode (`expandTabsOnMutation: true`) expands tabs in the entire file before replacement or insertion, including lines outside the edited region. Set it to `false` for Makefiles and other tab-sensitive files.
- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins. - 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.
@@ -11,6 +11,7 @@
| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | | `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 |
| `description` | 编辑器命令指南 | 面向模型的工具描述。 | | `description` | 编辑器命令指南 | 面向模型的工具描述。 |
| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | | `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 |
| `expandTabsOnMutation` | `true` | 保留 Claude SWE 参考行为:替换/插入前展开整个文件的制表符。设为 `false` 时使用原子字面量替换,并保留未触及的制表符。 |
## 工具 ## 工具
@@ -36,7 +37,7 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使
#### 模型所见 #### 模型所见
查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
#### Token 影响 #### Token 影响
@@ -50,5 +51,5 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使
- 操作面向 UTF-8 文本,不支持二进制文件。 - 操作面向 UTF-8 文本,不支持二进制文件。
- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 - `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。 - 规范模式(`expandTabsOnMutation: true`)会在替换或插入前展开整个文件中的制表符,包括未编辑区域。Makefile 等依赖制表符的文件应设为 `false`。
- 安全与先读后改策略委托给挂载的文件系统和策略插件。 - 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。
@@ -28,6 +28,8 @@
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^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", "@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7" "cordis": "^4.0.0-rc.7"
}, },
@@ -38,8 +40,12 @@
"@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "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-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "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-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^",
+210 -40
View File
@@ -7,9 +7,12 @@ import { isAbsolute } from 'node:path'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import { FsError } from '@deepseek-ai/dsh-fs' import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs' import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { defineTool } from '@deepseek-ai/dsh-tools' import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolRunContext } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolRunContext } from '@deepseek-ai/dsh-tools'
const TRUNCATED_MESSAGE = '<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 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>'
@@ -49,17 +52,68 @@ function expandTabs(content: string, tabSize = 8): string {
return result return result
} }
function codepointCompare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0
}
function matchOffsets(content: string, search: string): number[] {
const offsets: number[] = []
let offset = 0
while (true) {
const match = content.indexOf(search, offset)
if (match < 0) return offsets
offsets.push(match)
offset = match + search.length
}
}
function lineNumbersAt(content: string, offsets: readonly number[]): number[] {
let line = 1
let cursor = 0
return offsets.map((offset) => {
while (cursor < offset) {
if (content[cursor] === '\n') line += 1
cursor += 1
}
return line
})
}
class MutationPolicy {
private readonly policy: SandboxPolicyService | undefined
constructor(ctx: Context) {
this.policy = ctx.fs.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy')
if (ctx.fs.sandboxMode !== undefined && this.policy === undefined) {
throw new Error('tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing')
}
}
resolve(exec: ToolRunContext): SandboxExecutionPolicy | undefined {
return this.policy?.resolve({
...exec.agent === undefined ? {} : { session: exec.agent.session },
})
}
mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown {
if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
const mode = (policy as SandboxExecutionPolicy).mode
return new FsError(sandboxDenialMarker(mode), 'FS_SANDBOX_DENIED', { cause: error })
}
}
async function resolveTarget( async function resolveTarget(
ctx: Context, ctx: Context,
path: string, path: string,
requireAbsolutePath: boolean, requireAbsolutePath: boolean,
exec: ToolRunContext, exec: ToolRunContext,
workspaceRoot?: string,
): Promise<FsTarget> { ): Promise<FsTarget> {
if (path.trim().length === 0) throw new Error('path must be a non-empty string') if (path.trim().length === 0) throw new Error('path must be a non-empty string')
if (requireAbsolutePath && !isAbsolute(path)) { if (requireAbsolutePath && !isAbsolute(path)) {
throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`)
} }
const cwd = exec.agent?.session.header.cwd const cwd = exec.agent?.session.header.cwd ?? workspaceRoot
return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal }) return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal })
} }
@@ -158,8 +212,8 @@ async function listDirectory(
const rows: string[] = [] const rows: string[] = []
for (const entry of entries.filter(candidate => for (const entry of entries.filter(candidate =>
!candidate.name.startsWith('.') !candidate.name.startsWith('.')
&& !candidate.name.startsWith('node_modules') && candidate.name !== 'node_modules'
&& !candidate.name.startsWith('__pycache__'))) { && candidate.name !== '__pycache__')) {
const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?' const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?'
rows.push(`${type}\t${entry.target.displayPath}`) rows.push(`${type}\t${entry.target.displayPath}`)
if (entry.type === 'directory' && depth < 2) { if (entry.type === 'directory' && depth < 2) {
@@ -172,7 +226,7 @@ async function listDirectory(
rows.sort((left, right) => { rows.sort((left, right) => {
const leftPath = left.slice(left.indexOf('\t') + 1) const leftPath = left.slice(left.indexOf('\t') + 1)
const rightPath = right.slice(right.indexOf('\t') + 1) const rightPath = right.slice(right.indexOf('\t') + 1)
return leftPath.localeCompare(rightPath) return codepointCompare(leftPath, rightPath)
}) })
const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars) 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` return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n`
@@ -204,78 +258,124 @@ async function viewPath(
async function createFile( async function createFile(
ctx: Context, ctx: Context,
policy: MutationPolicy,
path: string, path: string,
fileText: string | undefined, fileText: string | undefined,
requireAbsolutePath: boolean, requireAbsolutePath: boolean,
exec: ToolRunContext, exec: ToolRunContext,
): Promise<string> { ): Promise<string> {
const content = requiredForCommand(fileText, 'file_text', 'create') const content = requiredForCommand(fileText, 'file_text', 'create')
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot)
if (await ctx.fs.stat(target, exec.signal) !== undefined) { if (await ctx.fs.stat(target, exec.signal) !== undefined) {
throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`)
} }
const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal) const intent = await ctx.waterfall(
'fs/write-intent',
target,
exec,
() => ({ kind: 'createIfAbsent' } as const),
)
let outcome
try {
outcome = await ctx.fs.writeText(
target,
content,
intent ?? { kind: 'createIfAbsent' },
exec.signal,
sandboxPolicy,
)
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec) ctx.emit('fs/observed', target, outcome.version, exec)
return `New file created successfully at: ${target.displayPath}` return `New file created successfully at: ${target.displayPath}`
} }
async function replaceInFile( async function replaceInFile(
ctx: Context, ctx: Context,
policy: MutationPolicy,
path: string, path: string,
oldStr: string | undefined, oldStr: string | undefined,
newStr: string | undefined, newStr: string | undefined,
requireAbsolutePath: boolean, requireAbsolutePath: boolean,
expandTabsOnMutation: boolean,
exec: ToolRunContext, exec: ToolRunContext,
): Promise<string> { ): Promise<string> {
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) const sandboxPolicy = policy.resolve(exec)
const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false)) const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot)
const newValue = expandTabs(newStr ?? '') const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const rawOldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false)
const oldValue = expandTabsOnMutation ? expandTabs(rawOldValue) : rawOldValue
const newValue = expandTabsOnMutation ? expandTabs(newStr ?? '') : newStr ?? ''
const info = await statExisting(ctx, target, 'str_replace', exec) const info = await statExisting(ctx, target, 'str_replace', exec)
if (info.type !== 'file') { if (info.type !== 'file') {
throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
} }
const before = expandTabs(await ctx.fs.readText(target, exec.signal)) const rawBefore = await ctx.fs.readText(target, exec.signal)
const occurrences = before.split(oldValue).length - 1 const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore
if (occurrences === 0) { const offsets = matchOffsets(before, oldValue)
if (offsets.length === 0) {
throw new FsError( throw new FsError(
`No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`,
'FS_EDIT_NOT_FOUND', 'FS_EDIT_NOT_FOUND',
) )
} }
if (occurrences > 1) { if (offsets.length > 1) {
const lines = before.split('\n') const lines = lineNumbersAt(before, offsets)
.flatMap((line, index) => line.includes(oldValue) ? [index + 1] : [])
throw new FsError( throw new FsError(
`No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`, `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`,
'FS_AMBIGUOUS_EDIT', 'FS_AMBIGUOUS_EDIT',
) )
} }
const outcome = await ctx.fs.writeText( let outcome
target, try {
before.replace(oldValue, newValue), outcome = expandTabsOnMutation
{ kind: 'replaceIfVersion', version: info.version }, ? await ctx.fs.writeText(
exec.signal, target,
) before.replace(oldValue, newValue),
intent === undefined
? { kind: 'replaceIfVersion', version: info.version }
: { kind: 'replaceIfVersion', version: intent.version },
exec.signal,
sandboxPolicy,
)
: await ctx.fs.editText(
target,
{ oldString: oldValue, newString: newValue, replaceAll: false },
intent ?? { version: info.version },
exec.signal,
sandboxPolicy,
)
} catch (error: unknown) {
throw policy.mapError(error, sandboxPolicy)
}
ctx.emit('fs/observed', target, outcome.version, exec) ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.` return `The file ${target.displayPath} has been edited successfully.`
} }
async function insertInFile( async function insertInFile(
ctx: Context, ctx: Context,
policy: MutationPolicy,
path: string, path: string,
insertLine: number | undefined, insertLine: number | undefined,
newStr: string | undefined, newStr: string | undefined,
requireAbsolutePath: boolean, requireAbsolutePath: boolean,
expandTabsOnMutation: boolean,
exec: ToolRunContext, exec: ToolRunContext,
): Promise<string> { ): Promise<string> {
if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert')
const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert')) const rawValue = requiredForCommand(newStr, 'new_str', 'insert')
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) const value = expandTabsOnMutation ? expandTabs(rawValue) : rawValue
const sandboxPolicy = policy.resolve(exec)
const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot)
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const info = await statExisting(ctx, target, 'insert', exec) const info = await statExisting(ctx, target, 'insert', exec)
if (info.type !== 'file') { if (info.type !== 'file') {
throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
} }
const before = expandTabs(await ctx.fs.readText(target, exec.signal)) const rawBefore = await ctx.fs.readText(target, exec.signal)
const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore
const lines = before.split('\n') const lines = before.split('\n')
if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) {
throw new Error( throw new Error(
@@ -287,12 +387,15 @@ async function insertInFile(
...value.split('\n'), ...value.split('\n'),
...lines.slice(insertLine), ...lines.slice(insertLine),
].join('\n') ].join('\n')
const outcome = await ctx.fs.writeText( const expected: FsWriteIntent = intent === undefined
target, ? { kind: 'replaceIfVersion', version: info.version }
after, : { kind: 'replaceIfVersion', version: intent.version }
{ kind: 'replaceIfVersion', version: info.version }, let outcome
exec.signal, 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) ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.` return `The file ${target.displayPath} has been edited successfully.`
} }
@@ -301,10 +404,59 @@ interface ResolvedConfig {
maxOutputChars: number maxOutputChars: number
description: string description: string
requireAbsolutePath: boolean requireAbsolutePath: boolean
expandTabsOnMutation: boolean
}
function presentEditorCall(args: {
command: 'view' | 'create' | 'str_replace' | 'insert'
path: string
file_text?: string
insert_line?: number
new_str?: string
old_str?: string
}): ToolCallView {
switch (args.command) {
case 'view':
return {
card: 'generic',
title: `view ${args.path}`,
kind: 'read',
locations: [{ path: args.path }],
}
case 'create':
return {
card: 'diff',
title: `create ${args.path}`,
diffs: [{ path: args.path, oldText: null, newText: args.file_text ?? '' }],
locations: [{ path: args.path }],
}
case 'str_replace':
return {
card: 'diff',
title: `str_replace ${args.path}`,
diffs: [{
path: args.path,
oldText: args.old_str ?? null,
newText: args.new_str ?? '',
}],
locations: [{ path: args.path }],
}
case 'insert':
return {
card: 'generic',
title: `insert ${args.path}`,
kind: 'edit',
locations: [{
path: args.path,
...args.insert_line === undefined ? {} : { line: Math.max(1, args.insert_line + 1) },
}],
}
}
} }
/** Register the model-facing `str_replace_editor` tool. */ /** Register the model-facing `str_replace_editor` tool. */
function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
const policy = new MutationPolicy(ctx)
ctx.tools.register(defineTool({ ctx.tools.register(defineTool({
name: 'str_replace_editor', name: 'str_replace_editor',
description: config.description, description: config.description,
@@ -351,18 +503,32 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
case 'view': case 'view':
return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec)
case 'create': case 'create':
return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec) return createFile(ctx, policy, args.path, args.file_text, config.requireAbsolutePath, exec)
case 'str_replace': case 'str_replace':
return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec) return replaceInFile(
ctx,
policy,
args.path,
args.old_str,
args.new_str,
config.requireAbsolutePath,
config.expandTabsOnMutation,
exec,
)
case 'insert': case 'insert':
return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec) return insertInFile(
ctx,
policy,
args.path,
args.insert_line,
args.new_str,
config.requireAbsolutePath,
config.expandTabsOnMutation,
exec,
)
} }
}, },
presentCall: args => ({ presentCall: presentEditorCall,
card: 'generic',
title: `${args.command} ${args.path}`,
kind: args.command === 'view' ? 'read' : 'edit',
}),
})) }))
} }
@@ -377,6 +543,8 @@ export interface Config {
description?: string description?: string
/** Require local absolute paths like the canonical editor contract (default true). */ /** Require local absolute paths like the canonical editor contract (default true). */
requireAbsolutePath?: boolean requireAbsolutePath?: boolean
/** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */
expandTabsOnMutation?: boolean
} }
/** Runtime configuration schema for the string-replacement editor tool. */ /** Runtime configuration schema for the string-replacement editor tool. */
@@ -384,6 +552,7 @@ export const Config: z<Config> = z.object({
maxOutputChars: z.number().default(16_000), maxOutputChars: z.number().default(16_000),
description: z.string().default(DEFAULT_DESCRIPTION), description: z.string().default(DEFAULT_DESCRIPTION),
requireAbsolutePath: z.boolean().default(true), requireAbsolutePath: z.boolean().default(true),
expandTabsOnMutation: z.boolean().default(true),
}) })
/** Register one `str_replace_editor` tool over `ctx.fs`. */ /** Register one `str_replace_editor` tool over `ctx.fs`. */
@@ -392,6 +561,7 @@ export function apply(ctx: Context, config: Config): void {
maxOutputChars: config.maxOutputChars ?? 16_000, maxOutputChars: config.maxOutputChars ?? 16_000,
description: config.description ?? DEFAULT_DESCRIPTION, description: config.description ?? DEFAULT_DESCRIPTION,
requireAbsolutePath: config.requireAbsolutePath ?? true, requireAbsolutePath: config.requireAbsolutePath ?? true,
expandTabsOnMutation: config.expandTabsOnMutation ?? true,
} }
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer')
@@ -9,6 +9,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local' 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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
@@ -57,7 +60,10 @@ function call(ctx: Context, owner: Agent | undefined, args: unknown) {
}) })
} }
async function setup(config: ToolStrReplaceEditor.Config = {}) { async function setup(
config: ToolStrReplaceEditor.Config = {},
options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {},
) {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-')) const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-'))
roots.push(root) roots.push(root)
const ctx = new Context() const ctx = new Context()
@@ -65,7 +71,13 @@ async function setup(config: ToolStrReplaceEditor.Config = {}) {
await ctx.plugin(SystemPrompt) await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry) await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: root }) if (options.sandboxMode === undefined) {
await ctx.plugin(LocalFileSystem, { cwd: root })
} else {
await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root })
await ctx.plugin(SandboxedFileSystem, { cwd: root })
}
if (options.fsPolicy === true) await ctx.plugin(FsPolicy)
await ctx.plugin(ToolStrReplaceEditor, config) await ctx.plugin(ToolStrReplaceEditor, config)
return { ctx, root, owner: agent(ctx, root) } return { ctx, root, owner: agent(ctx, root) }
} }
@@ -85,13 +97,38 @@ describe('tool-str-replace-editor', () => {
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'view', command: 'view',
path: '/workspace/a.txt', path: '/workspace/a.txt',
})).toMatchObject({ card: 'generic', kind: 'read' }) })).toMatchObject({
card: 'generic',
kind: 'read',
locations: [{ path: '/workspace/a.txt' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'create',
path: '/workspace/a.txt',
file_text: 'hello',
})).toMatchObject({
card: 'diff',
diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'str_replace',
path: '/workspace/a.txt',
old_str: 'old',
new_str: 'new',
})).toMatchObject({
card: 'diff',
diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }],
})
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'insert', command: 'insert',
path: '/workspace/a.txt', path: '/workspace/a.txt',
insert_line: 0, insert_line: 0,
new_str: 'x', new_str: 'x',
})).toMatchObject({ card: 'generic', kind: 'edit' }) })).toMatchObject({
card: 'generic',
kind: 'edit',
locations: [{ path: '/workspace/a.txt', line: 1 }],
})
}) })
it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
@@ -136,16 +173,20 @@ describe('tool-str-replace-editor', () => {
}) })
it('lists visible entries to depth two and clips at the configured view limit', async () => { it('lists visible entries to depth two and clips at the configured view limit', async () => {
const { ctx, root, owner } = await setup({ maxOutputChars: 10 }) const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 })
await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true })
await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { 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__'), { recursive: true })
await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true })
await writeFile(join(root, 'dir', 'visible.txt'), 'ok') await writeFile(join(root, 'dir', 'visible.txt'), 'ok')
await writeFile(join(root, 'dir', '.hidden'), 'hidden') await writeFile(join(root, 'dir', '.hidden'), 'hidden')
await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child') 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', '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', '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__', 'module.pyc'), 'cache')
await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source')
const listDir = ctx.fs.listDir.bind(ctx.fs) const listDir = ctx.fs.listDir.bind(ctx.fs)
const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other')) const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other'))
ctx.fs.listDir = async (target, signal) => { ctx.fs.listDir = async (target, signal) => {
@@ -156,14 +197,19 @@ describe('tool-str-replace-editor', () => {
} }
const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') })) const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') }))
expect(listing).toContain('<response clipped>')
expect(listing).not.toContain('.hidden') expect(listing).not.toContain('.hidden')
expect(listing).not.toContain('too-deep.txt') expect(listing).not.toContain('too-deep.txt')
expect(listing).not.toContain('index.js') expect(listing).not.toContain('index.js')
expect(listing).not.toContain('module.pyc') expect(listing).not.toContain('module.pyc')
expect(listing).toContain('node_modules_old/kept.js')
expect(listing).toContain('__pycache__backup/kept.py')
await writeFile(join(root, 'large.txt'), 'x'.repeat(100)) const clipped = await setup({ maxOutputChars: 10 })
expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') }))) 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>') .toContain('<response clipped>')
}) })
@@ -233,10 +279,20 @@ describe('tool-str-replace-editor', () => {
expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]') expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]')
expect(text(repeated)).not.toContain('replace_all') expect(text(repeated)).not.toContain('replace_all')
await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta')
const repeatedMultiline = await call(ctx, owner, {
command: 'str_replace',
path: ambiguous,
old_str: 'alpha\nbeta',
new_str: 'x',
})
expect(text(repeatedMultiline))
.toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]')
const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' })
expect(relative.isError).toBe(true) expect(relative.isError).toBe(true)
expect(text(relative)).toContain('is not an absolute path') expect(text(relative)).toContain('is not an absolute path')
expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame') expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta')
}) })
it('reports invalid commands or arguments without mutating files', async () => { it('reports invalid commands or arguments without mutating files', async () => {
@@ -302,6 +358,63 @@ describe('tool-str-replace-editor', () => {
.toContain("Here's the content of") .toContain("Here's the content of")
}) })
it('delegates read-before-edit decisions to fs-policy', async () => {
const { ctx, root, owner } = await setup({}, { fsPolicy: true })
const existing = join(root, 'existing.txt')
const created = join(root, 'created.txt')
await writeFile(existing, 'before')
const blindEdit = await call(ctx, owner, {
command: 'str_replace',
path: existing,
old_str: 'before',
new_str: 'after',
})
expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
expect(await readFile(existing, 'utf8')).toBe('before')
await call(ctx, owner, { command: 'view', path: existing })
expect((await call(ctx, owner, {
command: 'str_replace',
path: existing,
old_str: 'before',
new_str: 'after',
})).isError).toBe(false)
expect(await readFile(existing, 'utf8')).toBe('after')
expect((await call(ctx, owner, {
command: 'create',
path: created,
file_text: 'new',
})).isError).toBe(false)
expect(await readFile(created, 'utf8')).toBe('new')
})
it('passes the session sandbox policy to every mutation', async () => {
const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' })
const path = join(root, 'blocked.txt')
const result = await call(ctx, owner, {
command: 'create',
path,
file_text: 'blocked',
})
expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
expect(text(result)).toContain('[sandbox: file access denied under read-only mode]')
})
it('can preserve tabs outside the edited region', async () => {
const { ctx, root, owner } = await setup({ expandTabsOnMutation: false })
const path = join(root, 'Makefile')
await writeFile(path, 'target:\n\told\n')
await call(ctx, owner, {
command: 'str_replace',
path,
old_str: 'old',
new_str: 'new',
})
expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n')
})
it('rejects invalid plugin config', () => { it('rejects invalid plugin config', () => {
expect(() => { expect(() => {
ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 }) ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 })
@@ -9,6 +9,8 @@
{ "path": "../../../vendor/cordis" }, { "path": "../../../vendor/cordis" },
{ "path": "../../core/tools" }, { "path": "../../core/tools" },
{ "path": "../fs" }, { "path": "../fs" },
{ "path": "../../sandbox/sandbox" },
{ "path": "../../sandbox/sandbox-policy" },
{ "path": "../../support/invariants" } { "path": "../../support/invariants" }
] ]
} }
+69 -27
View File
@@ -16,7 +16,10 @@ const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this comma
const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' const SHELL_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 SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' 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 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.' const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
@@ -101,7 +104,7 @@ function commandOutput(
const start = startMarker < 0 ? 0 : startMarker + marker.start.length const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return { return {
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
incomplete: startMarker < 0 || snapshot.truncated, incomplete: startMarker < 0,
} }
} }
@@ -115,22 +118,29 @@ function partialOutput(
snapshot: RetainedOutput, snapshot: RetainedOutput,
marker: CommandMarkers, marker: CommandMarkers,
fallback: string, fallback: string,
fallbackTruncated = false,
): CapturedOutput { ): CapturedOutput {
const startMarker = snapshot.text.lastIndexOf(marker.start) const startMarker = snapshot.text.lastIndexOf(marker.start)
if (startMarker >= 0) { if (startMarker >= 0) {
return { return {
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
incomplete: snapshot.truncated, incomplete: false,
} }
} }
const fallbackStart = fallback.lastIndexOf(marker.start)
const afterStart = fallbackStart < 0
? fallback
: fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
const fallbackEnd = afterStart.lastIndexOf(marker.end)
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
return { return {
text: stripPrompt(fallback), text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')),
incomplete: snapshot.truncated, incomplete: fallbackTruncated || fallbackStart < 0,
} }
} }
async function pause(): Promise<void> { async function pause(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25)) await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
} }
function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined {
@@ -142,11 +152,13 @@ function retainedScrollback(
ctx: Context, ctx: Context,
owner: Agent, owner: Agent,
id: PtySessionId, id: PtySessionId,
latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
): RetainedOutput { ): RetainedOutput {
const pages: string[] = [] const pages: string[] = latest.text.length === 0 ? [] : [latest.text]
let offset = 0 let offset = latest.lineEnd
let truncated = false let truncated = latest.truncated
while (true) { while (true) {
if (offset >= latest.totalLines) break
const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
truncated ||= page.truncated truncated ||= page.truncated
if (page.text.length > 0) pages.unshift(page.text) if (page.text.length > 0) pages.unshift(page.text)
@@ -167,7 +179,10 @@ function renderCaptured(output: CapturedOutput, maxOutputChars: number): string
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
const pending = new WeakMap<Agent, Promise<PtySessionId>>() const pending = new WeakMap<Agent, Promise<PtySessionId>>()
const live = new Map<Agent, PtySessionId>() const live = new Map<Agent, PtySessionId>()
const creating = new Set<Promise<PtySessionId>>()
const ownerCleanupInstalled = new WeakSet<Agent>() const ownerCleanupInstalled = new WeakSet<Agent>()
const lifecycle = new AbortController()
let disposed = false
const close = async (owner: Agent, id: PtySessionId, reason: string): Promise<void> => { const close = async (owner: Agent, id: PtySessionId, reason: string): Promise<void> => {
if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return
@@ -175,6 +190,9 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
} }
ctx.effect(() => async () => { ctx.effect(() => async () => {
disposed = true
lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation'))
await Promise.allSettled([...creating])
const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
await Promise.all(closing) await Promise.all(closing)
live.clear() live.clear()
@@ -188,15 +206,17 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
} }
const get = (owner: Agent, signal: AbortSignal): Promise<PtySessionId> => { const get = (owner: Agent, signal: AbortSignal): Promise<PtySessionId> => {
if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed'))
const existing = pending.get(owner) const existing = pending.get(owner)
if (existing !== undefined) return existing if (existing !== undefined) return existing
const creating = (async () => { const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
const creation = (async () => {
try { try {
const cwd = owner.session.header.cwd const cwd = owner.session.header.cwd
const spawned = await ctx.pty.spawn(owner, { const spawned = await ctx.pty.spawn(owner, {
type: config.backendType, type: config.backendType,
...cwd === undefined ? {} : { cwd }, ...cwd === undefined ? {} : { cwd },
}, signal) }, combinedSignal)
live.set(owner, spawned.sessionId) live.set(owner, spawned.sessionId)
if (!ownerCleanupInstalled.has(owner)) { if (!ownerCleanupInstalled.has(owner)) {
ownerCleanupInstalled.add(owner) ownerCleanupInstalled.add(owner)
@@ -208,7 +228,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
const setup = ctx.pty.startSend(owner, spawned.sessionId, { const setup = ctx.pty.startSend(owner, spawned.sessionId, {
text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`, text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
submit: true, submit: true,
signal, signal: combinedSignal,
}) })
const result = await setup.done const result = await setup.done
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
@@ -220,8 +240,12 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
throw error throw error
} }
})() })()
pending.set(owner, creating) const tracked = creation.finally(() => {
return creating creating.delete(tracked)
})
creating.add(tracked)
pending.set(owner, tracked)
return tracked
} }
return { get, reset } return { get, reset }
@@ -241,21 +265,32 @@ async function executeCommand(
const wrapped = wrapCommand(command, marker) const wrapped = wrapCommand(command, marker)
let first = true let first = true
let fallback = '' let fallback = ''
let fallbackTruncated = false
while (true) { while (true) {
const operation = ctx.pty.startSend(owner, id, { let operation
text: first ? wrapped : '', let result
submit: first, try {
signal: commandDeadline.signal, operation = ctx.pty.startSend(owner, id, {
}) text: first ? wrapped : '',
first = false submit: first,
const result = await operation.done signal: commandDeadline.signal,
fallback += result.viewport })
const snapshot = retainedScrollback(ctx, owner, id) 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) const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
if (timedOut !== undefined) { if (timedOut !== undefined) {
const snapshot = retainedScrollback(ctx, owner, id, latest)
const partial = renderCaptured( const partial = renderCaptured(
partialOutput(snapshot, marker, fallback), partialOutput(snapshot, marker, fallback, fallbackTruncated),
config.maxOutputChars, config.maxOutputChars,
) )
await shells.reset(owner, 'persistent bash command timed out') await shells.reset(owner, 'persistent bash command timed out')
@@ -265,12 +300,15 @@ async function executeCommand(
SHELL_RESET_MESSAGE, SHELL_RESET_MESSAGE,
].join('\n') ].join('\n')
} }
const complete = commandOutput(snapshot, marker) if (latest.text.includes(marker.end)) {
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
}
if (result.sessionStatus.kind === 'exited') { if (result.sessionStatus.kind === 'exited') {
const snapshot = retainedScrollback(ctx, owner, id, latest)
await shells.reset(owner, 'persistent bash shell exited') await shells.reset(owner, 'persistent bash shell exited')
return [ return [
renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars), renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
SHELL_RESET_MESSAGE, SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n') ].filter(part => part.length > 0).join('\n')
} }
@@ -279,7 +317,11 @@ async function executeCommand(
commandDeadline.signal.throwIfAborted() commandDeadline.signal.throwIfAborted()
} }
if (promptCompleted(result)) { if (promptCompleted(result)) {
return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated) const snapshot = retainedScrollback(ctx, owner, id, latest)
return renderCaptured(
partialOutput(snapshot, marker, fallback, fallbackTruncated),
config.maxOutputChars,
)
} }
await pause() await pause()
} }
@@ -85,6 +85,8 @@ type StubMode =
| 'init-exit' | 'init-exit'
| 'init-timeout' | 'init-timeout'
| 'spawn-error' | 'spawn-error'
| 'send-error'
| 'prompt-after-idle'
class StubPtySession implements PtyBackendSession { class StubPtySession implements PtyBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
@@ -95,6 +97,7 @@ class StubPtySession implements PtyBackendSession {
mode: StubMode mode: StubMode
sends = 0 sends = 0
pendingText = '' pendingText = ''
historyTruncated = false
constructor(mode: StubMode) { constructor(mode: StubMode) {
this.mode = mode this.mode = mode
@@ -112,6 +115,7 @@ class StubPtySession implements PtyBackendSession {
} }
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
} }
if (this.mode === 'send-error') throw new Error('stub send failed')
if (this.mode === 'wait-for-abort') { if (this.mode === 'wait-for-abort') {
const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => { const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => {
request.signal?.addEventListener('abort', () => { request.signal?.addEventListener('abort', () => {
@@ -126,6 +130,17 @@ class StubPtySession implements PtyBackendSession {
this.pendingText = request.text this.pendingText = request.text
return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) 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') { if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') {
const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
const output = `bash: syntax error${newline}${this.motd}${newline}` const output = `bash: syntax error${newline}${this.motd}${newline}`
@@ -166,7 +181,7 @@ class StubPtySession implements PtyBackendSession {
totalLines: lines.length, totalLines: lines.length,
lineBegin: 0, lineBegin: 0,
lineEnd: lines.length, lineEnd: lines.length,
truncated: false, truncated: this.historyTruncated,
} }
} }
@@ -316,6 +331,29 @@ describe('tool-bash-persistent', () => {
expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
}) })
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'prompt-after-idle'
session.scrollback = ''
const result = text(await call(ctx, owner, 'bad {'))
expect(result).toContain('partial syntax output')
expect(result).toContain('bash: syntax error')
expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
})
it('does not attribute old scrollback truncation to a complete current command', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.historyTruncated = true
const result = text(await call(ctx, owner, 'short command'))
expect(result).toBe('hello from stub')
expect(result).not.toContain('<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 () => { it('closes a timed-out shell and reports bounded partial output', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
await call(ctx, owner, 'warm up') await call(ctx, owner, 'warm up')
@@ -359,6 +397,48 @@ describe('tool-bash-persistent', () => {
expect(stub.sessions).toHaveLength(0) 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 () => { it('rejects invalid config and invalid calls', async () => {
const { ctx, owner, stub } = await setup() const { ctx, owner, stub } = await setup()
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
+18
View File
@@ -593,6 +593,9 @@ importers:
'@deepseek-ai/dsh-tool-ask-user': '@deepseek-ai/dsh-tool-ask-user':
specifier: workspace:* specifier: workspace:*
version: link:../packages/ui/tool-ask-user version: link:../packages/ui/tool-ask-user
'@deepseek-ai/dsh-tool-bash-persistent':
specifier: workspace:*
version: link:../packages/pty/tool-bash-persistent
'@deepseek-ai/dsh-tool-cordis': '@deepseek-ai/dsh-tool-cordis':
specifier: workspace:* specifier: workspace:*
version: link:../packages/cordis/tool-cordis version: link:../packages/cordis/tool-cordis
@@ -617,6 +620,9 @@ importers:
'@deepseek-ai/dsh-tool-session-query': '@deepseek-ai/dsh-tool-session-query':
specifier: workspace:* specifier: workspace:*
version: link:../packages/session-query/tool-session-query version: link:../packages/session-query/tool-session-query
'@deepseek-ai/dsh-tool-str-replace-editor':
specifier: workspace:*
version: link:../packages/fs/tool-str-replace-editor
'@deepseek-ai/dsh-tool-subagent': '@deepseek-ai/dsh-tool-subagent':
specifier: workspace:* specifier: workspace:*
version: link:../packages/subagent/tool-subagent version: link:../packages/subagent/tool-subagent
@@ -2593,12 +2599,24 @@ importers:
'@deepseek-ai/dsh-fs-local': '@deepseek-ai/dsh-fs-local':
specifier: workspace:^ specifier: workspace:^
version: link:../fs-local version: link:../fs-local
'@deepseek-ai/dsh-fs-policy':
specifier: workspace:^
version: link:../fs-policy
'@deepseek-ai/dsh-fs-sandbox':
specifier: workspace:^
version: link:../fs-sandbox
'@deepseek-ai/dsh-invariants': '@deepseek-ai/dsh-invariants':
specifier: workspace:^ specifier: workspace:^
version: link:../../support/invariants version: link:../../support/invariants
'@deepseek-ai/dsh-llm': '@deepseek-ai/dsh-llm':
specifier: workspace:^ specifier: workspace:^
version: link:../../llm/llm version: link:../../llm/llm
'@deepseek-ai/dsh-sandbox':
specifier: workspace:^
version: link:../../sandbox/sandbox
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../../sandbox/sandbox-policy
'@deepseek-ai/dsh-session': '@deepseek-ai/dsh-session':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/session version: link:../../core/session
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write python/sdk-runtime/README.md # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md
README.md: 977bce41191d6c7716548dafde81d2c7ec14dec7 README.md: ee3791eddf26b526316d4f3952793a03cc48841e
README.zh.md: ade8455c56c27fcbe3e43a68abeaad98421cf720 README.zh.md: 59d40ee56688cb377902ff126b7fa77606c7ad8b
+1 -1
View File
@@ -15,7 +15,7 @@ Both carriers hold the same content, defined once: the [package.json](package.js
A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers.
Each wheel contains exactly one runtime executable and its matching native spawn helper. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. Each wheel contains exactly one runtime executable and its matching native spawn helper. A missing sidecar makes the runtime installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools; old exe-only wheels are intentionally unsupported. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it.
## Resolution API ## Resolution API
+1 -1
View File
@@ -15,7 +15,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,
exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。
每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。缺少伴随文件意味着运行时安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此;旧的仅 exe wheel 有意不再兼容。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。
## 解析 API ## 解析 API
+3 -1
View File
@@ -260,7 +260,9 @@ class SingleExeBuild {
'--config.node-linker=hoisted', '--config.node-linker=hoisted',
'--config.auto-install-peers=false', '--config.auto-install-peers=false',
'--config.link-workspace-packages=true', '--config.link-workspace-packages=true',
// The production closure intentionally omits patched dev-only packages. // The production closure intentionally omits the patched dev-only
// @earendil-works/pi-tui package. The root frozen install still validates
// every patch; this exception is scoped only to the production deploy.
'--config.allow-unused-patches=true', '--config.allow-unused-patches=true',
this.staging, this.staging,
]) ])
+23 -12
View File
@@ -27,7 +27,7 @@ WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value witho
WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor."
PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok"
PERSISTENT_EDITOR_PATH: str | None = None PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: "
PERSISTENT_BASH_COMMAND = ( PERSISTENT_BASH_COMMAND = (
"counter=$(( ${counter:-0} + 1 )); export counter; " "counter=$(( ${counter:-0} + 1 )); export counter; "
"printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; "
@@ -198,7 +198,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
raise AssertionError(f"unexpected tool follow-up: {tool_name}") raise AssertionError(f"unexpected tool follow-up: {tool_name}")
prompt = message_text(latest.get("content")) prompt = message_text(latest.get("content"))
if prompt == PERSISTENT_TOOLS_PROMPT: if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"):
names = advertised_tool_names(body) names = advertised_tool_names(body)
if names != {"bash", "str_replace_editor"}: if names != {"bash", "str_replace_editor"}:
raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}")
@@ -261,14 +261,27 @@ def persistent_tool_followup(
if call_id == "persistent-bash-2" and tool_name == "bash": if call_id == "persistent-bash-2" and tool_name == "bash":
if "COUNT=2 CWD=/tmp" not in tool_text: if "COUNT=2 CWD=/tmp" not in tool_text:
raise AssertionError(f"persistent bash did not retain state: {tool_text}") raise AssertionError(f"persistent bash did not retain state: {tool_text}")
if PERSISTENT_EDITOR_PATH is None: messages = body.get("messages")
raise AssertionError("persistent editor smoke path was not initialized") if not isinstance(messages, list):
raise AssertionError("persistent editor smoke request has no messages")
editor_path = next(
(
text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip()
for message in messages
if isinstance(message, dict) and message.get("role") == "user"
for text in [message_text(message.get("content"))]
if PERSISTENT_EDITOR_PATH_PREFIX in text
),
None,
)
if editor_path is None:
raise AssertionError("persistent editor smoke prompt has no editor path")
return tool_call_chunks( return tool_call_chunks(
"persistent-editor", "persistent-editor",
"str_replace_editor", "str_replace_editor",
{ {
"command": "create", "command": "create",
"path": PERSISTENT_EDITOR_PATH, "path": editor_path,
"file_text": "created by packaged editor\n", "file_text": "created by packaged editor\n",
}, },
) )
@@ -545,12 +558,12 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None:
"""Exercise native PTY state and the editor through the packaged executable.""" """Exercise native PTY state and the editor through the packaged executable."""
global PERSISTENT_EDITOR_PATH
from deepseek_harness import DeepSeekHarness from deepseek_harness import DeepSeekHarness
with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary:
root = Path(temporary).resolve() root = Path(temporary).resolve()
PERSISTENT_EDITOR_PATH = str(root / "created.txt") editor_path = root / "created.txt"
prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}"
sessions = root / "sessions" sessions = root / "sessions"
cordis = root / "cordis.yml" cordis = root / "cordis.yml"
cordis.write_text(PERSISTENT_TOOLS_CORDIS) cordis.write_text(PERSISTENT_TOOLS_CORDIS)
@@ -565,17 +578,15 @@ def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None:
base_url=base_url, base_url=base_url,
request_timeout_seconds=60, request_timeout_seconds=60,
) as harness: ) as harness:
result = harness.run(PERSISTENT_TOOLS_PROMPT, session_id="persistent-tools-smoke") result = harness.run(prompt, session_id="persistent-tools-smoke")
assert result.status == "ok", result assert result.status == "ok", result
event_text = json.dumps(result.events) event_text = json.dumps(result.events)
if PERSISTENT_TOOLS_TEXT not in event_text: if PERSISTENT_TOOLS_TEXT not in event_text:
raise AssertionError(f"packaged tools run emitted no final response: {result.events}") raise AssertionError(f"packaged tools run emitted no final response: {result.events}")
created = root / "created.txt" if editor_path.read_text() != "created by packaged editor\n":
if created.read_text() != "created by packaged editor\n": raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}")
raise AssertionError(f"packaged editor wrote unexpected content: {created.read_text()!r}")
assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
PERSISTENT_EDITOR_PATH = None
def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: