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