fix(tools): harden persistent tool integrations
This commit is contained in:
@@ -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:*",
|
||||
|
||||
Reference in New Issue
Block a user