dsh-acp-enhanced 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh.md +13 -3
- package/README.md +15 -5
- package/lib/codec.js +185 -0
- package/lib/index.js +152 -23
- package/package.json +1 -1
- package/scripts/dsh-acp-zed.sh +11 -0
package/README-zh.md
CHANGED
|
@@ -16,11 +16,19 @@ ACP 线上。
|
|
|
16
16
|
`agent_thought_chunk`),取消/重试不留半截输出
|
|
17
17
|
- **完整遥测**:上下文用量环 + 缓存命中率 / TPS / 输入-输出-推理 token / 工具耗时 /
|
|
18
18
|
轮次计数(`usage_update._meta` 携带全量明细)
|
|
19
|
+
- **图片支持(多模态)**:当 dsh 组合挂载了附件存储(dsh 0.1.1-rc.2+,`dsh-base`
|
|
20
|
+
默认装配 `dsh-attachment-local`)时,会声明 `promptCapabilities.image` 并把粘贴/
|
|
21
|
+
上传的图片持久化进 harness 附件存储——支持视觉的模型(如 `deepseek-v4-flash-vision-exp`)
|
|
22
|
+
可按线序原生读取,图文交替不乱序。旧版栈(无附件存储)自动降级:不声明 image、
|
|
23
|
+
收到图片 prompt 明确报错。
|
|
19
24
|
|
|
20
25
|
### 模型与权限
|
|
21
26
|
|
|
22
27
|
- **模型切换**:实时 `provider/model` 目录下拉(按 ACP 规范分组线格式)
|
|
23
|
-
- **推理强度**:`reasoning_effort` 下拉——仅当当前路由暴露可选 efforts
|
|
28
|
+
- **推理强度**:`reasoning_effort` 下拉——仅当当前路由暴露可选 efforts 时出现;
|
|
29
|
+
每个模型都会记住它上次使用的强度(按 profile 持久化),切回时自动恢复,
|
|
30
|
+
首次切换的模型则回退到它自己的默认值——没有默认值时取第一个可选值,
|
|
31
|
+
绝不出现空的 "unknown" 选择
|
|
24
32
|
- **权限预设**:read-only / workspace-write / full-access 三种会话模式
|
|
25
33
|
- **审批**:工具调用弹出原生 allow-once / reject-once 审批
|
|
26
34
|
|
|
@@ -196,7 +204,7 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
|
|
|
196
204
|
|---|---|
|
|
197
205
|
| `exec: dsh: not found`(status 127) | 用随附 `dsh-acp-zed.sh` 启动器(自定位 node/dsh) |
|
|
198
206
|
| `no API key for provider route "xxx"` | 写入 `~/.dsh/.credentials.yaml`,或在 agent_servers 里设 `env.DEEPSEEK_API_KEY` |
|
|
199
|
-
| 无法切换模型 | 保存的 `reasoning_effort` 默认值(或会话当前 effort
|
|
207
|
+
| 无法切换模型 | 保存的 `reasoning_effort` 默认值(或会话当前 effort)被带到新模型上。0.3.6 起本桥按模型记住上次使用的强度(随 profile 持久化):不被新模型支持的 effort 会被该模型记忆值替换——没有记忆则回退其默认值,再无默认则取第一个可选值,既不会切换失败也不会出现 "unknown"。另检查:是否选到了不可路由的"幽灵 provider"——本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
|
|
200
208
|
| 上下文用量不显示 | 选到了不可路由的"幽灵 provider";本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
|
|
201
209
|
| 需要详细诊断 | `ACP_DEBUG=1 dsh --profile acp-enhanced`(stderr 生命周期 trace) |
|
|
202
210
|
|
|
@@ -208,11 +216,13 @@ node scripts/acp-client-tools.mjs # 客户端工具测试(模拟 Zed 的 f
|
|
|
208
216
|
node scripts/acp-mcp-test.mjs # MCP 挂载测试(无模型调用)
|
|
209
217
|
node scripts/acp-smoke-keyless.mjs # keyless 冒烟(CI 用)
|
|
210
218
|
node scripts/acp-resume-test.mjs # 会话恢复测试
|
|
219
|
+
node scripts/codec-image-test.mjs # 图片编解码单元测试(无网络,假 store)
|
|
220
|
+
node scripts/acp-image-e2e.mjs # 图片能力端到端(vision 模型段需 API key)
|
|
211
221
|
```
|
|
212
222
|
|
|
213
223
|
## 已知限制
|
|
214
224
|
|
|
215
|
-
|
|
225
|
+
不支持音频附件(不声明 audio 能力)、文本按块粒度流式、每会话同时一个 in-flight
|
|
216
226
|
prompt。MCP 支持 stdio 与 streamable HTTP(不声明 legacy SSE / `acp` 传输)。
|
|
217
227
|
`session/close` / `session/fork` / `session/resume` 未实现(不声明能力,合规客户端
|
|
218
228
|
不会调用);`session/delete` 因 dsh 持久化无官方删除 API,采用直接删除后端目录的方式。
|
package/README.md
CHANGED
|
@@ -18,12 +18,20 @@ over the ACP wire.
|
|
|
18
18
|
torn output
|
|
19
19
|
- **Full telemetry**: context usage ring plus cache hit rate / TPS / input-output-reasoning
|
|
20
20
|
tokens / tool timing / turn counts (`usage_update._meta` carries the full breakdown)
|
|
21
|
+
- **Image support (multimodal)**: when the dsh composition mounts an attachment store
|
|
22
|
+
(dsh 0.1.1-rc.2+ with `dsh-attachment-local`, the default in dsh-base), `promptCapabilities.image`
|
|
23
|
+
is advertised and pasted/uploaded images are ingested into the harness's durable attachment
|
|
24
|
+
store — a vision-capable model (e.g. `deepseek-v4-flash-vision-exp`) reads them natively,
|
|
25
|
+
in wire order with surrounding text. Older stacks (no attachment store) automatically
|
|
26
|
+
downgrade: image is not advertised and an image prompt is refused with a clear error.
|
|
21
27
|
|
|
22
28
|
### Model & permissions
|
|
23
29
|
|
|
24
30
|
- **Model switching**: live `provider/model` catalog dropdown (ACP grouped-select wire shape)
|
|
25
31
|
- **Reasoning effort**: `reasoning_effort` dropdown — only when the routed model exposes
|
|
26
|
-
selectable efforts
|
|
32
|
+
selectable efforts; each model remembers the effort it last used (persisted per profile),
|
|
33
|
+
so switching back restores it, and a first-time model falls back to its own default — or
|
|
34
|
+
its first offered effort — instead of an empty "unknown" selection
|
|
27
35
|
- **Permission presets**: read-only / workspace-write / full-access session modes
|
|
28
36
|
- **Approval**: native allow-once / reject-once prompts per tool call
|
|
29
37
|
|
|
@@ -215,7 +223,7 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
|
|
|
215
223
|
|---|---|
|
|
216
224
|
| `exec: dsh: not found` (status 127) | Use the shipped `dsh-acp-zed.sh` launcher (locates node/dsh itself) |
|
|
217
225
|
| `no API key for provider route "xxx"` | Write `~/.dsh/.credentials.yaml`, or set `env.DEEPSEEK_API_KEY` on the agent_servers entry |
|
|
218
|
-
| Cannot switch models | The saved `reasoning_effort` default (or the session's current effort) is carried onto the new model
|
|
226
|
+
| Cannot switch models | The saved `reasoning_effort` default (or the session's current effort) is carried onto the new model. Since 0.3.6 the bridge remembers the last effort per model (per-profile JSON): an unsupported carried effort is replaced by that model's remembered effort, else its own default, else its first offered effort — never an "unknown" dropdown, never a failed switch. Also check: a "phantom provider" route was picked — this bridge filters them by default (only `config.provider`'s models are advertised), so point the profile's provider at a real route |
|
|
219
227
|
| Context usage missing | A "phantom provider" route was picked; this bridge filters them by default (only `config.provider`'s models are advertised) — point the profile's provider at a real route |
|
|
220
228
|
| Need detailed diagnostics | `ACP_DEBUG=1 dsh --profile acp-enhanced` (stderr lifecycle trace) |
|
|
221
229
|
|
|
@@ -227,13 +235,15 @@ node scripts/acp-client-tools.mjs # client-tool tests (mocks Zed fs/terminal
|
|
|
227
235
|
node scripts/acp-mcp-test.mjs # MCP mount test (no model calls)
|
|
228
236
|
node scripts/acp-smoke-keyless.mjs # keyless boot smoke (CI)
|
|
229
237
|
node scripts/acp-resume-test.mjs # session resume test
|
|
238
|
+
node scripts/codec-image-test.mjs # image-codec unit tests (no network, fake store)
|
|
239
|
+
node scripts/acp-image-e2e.mjs # image capability e2e (vision-model leg needs an API key)
|
|
230
240
|
```
|
|
231
241
|
|
|
232
242
|
## Known limitations
|
|
233
243
|
|
|
234
|
-
|
|
235
|
-
one in-flight prompt per session. MCP supports stdio and streamable HTTP
|
|
236
|
-
`acp` transports are not advertised).
|
|
244
|
+
Audio attachments are not supported (audio capability is not advertised), text streams at
|
|
245
|
+
block granularity, one in-flight prompt per session. MCP supports stdio and streamable HTTP
|
|
246
|
+
(legacy SSE / `acp` transports are not advertised).
|
|
237
247
|
`session/close` / `session/fork` / `session/resume` are not implemented (capabilities
|
|
238
248
|
undeclared, compliant clients will not call them); `session/delete` removes the
|
|
239
249
|
persisted directory directly because dsh persistence has no official delete API.
|
package/lib/codec.js
CHANGED
|
@@ -57,6 +57,191 @@ export function promptHasUnsupportedContent(prompt) {
|
|
|
57
57
|
return prompt.some((block) => block.type !== 'text' && block.type !== 'resource_link')
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Raster media types the harness attachment seam admits (dsh-attachment). */
|
|
61
|
+
const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Canonicalize an ACP image MIME type for the harness attachment store.
|
|
65
|
+
* @param mimeType - the client-declared type (may be `image/jpg`, which
|
|
66
|
+
* the raster vocabulary spells `image/jpeg`).
|
|
67
|
+
* @returns the harness media type, or `undefined` when the value is not a
|
|
68
|
+
* raster we ingest.
|
|
69
|
+
*/
|
|
70
|
+
export function canonicalImageMediaType(mimeType) {
|
|
71
|
+
const lower = String(mimeType ?? '').trim().toLowerCase()
|
|
72
|
+
const mapped = lower === 'image/jpg' ? 'image/jpeg' : lower
|
|
73
|
+
return IMAGE_MEDIA_TYPES.has(mapped) ? mapped : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Error for prompt content this adapter does not advertise. */
|
|
77
|
+
export class UnsupportedPromptContentError extends Error {
|
|
78
|
+
constructor(contentType) {
|
|
79
|
+
super(`unsupported prompt content type: ${contentType}`)
|
|
80
|
+
this.name = 'UnsupportedPromptContentError'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Error when an advertised image cannot be ingested (limits, decode, store). */
|
|
85
|
+
export class PromptImageError extends Error {
|
|
86
|
+
constructor(message, options) {
|
|
87
|
+
super(message, options)
|
|
88
|
+
this.name = 'PromptImageError'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Narrow an unknown `ctx.attachments` value to the ingest surface used by
|
|
94
|
+
* {@link convertPrompt}. Capability detection instead of version detection:
|
|
95
|
+
* the service exists (with methods) on dsh 0.1.1-rc.2+, while the 0.1.0-rc.x
|
|
96
|
+
* seam is an empty shell without `validateImage`/`saveImage` — and a
|
|
97
|
+
* deployment without the attachment-local row has no service at all. All
|
|
98
|
+
* three fall back to `undefined` here, so the caller simply does not
|
|
99
|
+
* advertise image support.
|
|
100
|
+
* @param value - `ctx.get('attachments')` (or anything shaped like it).
|
|
101
|
+
* @returns the ingest surface, or `undefined` when absent/empty.
|
|
102
|
+
*/
|
|
103
|
+
export function attachmentIngestOf(value) {
|
|
104
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
105
|
+
const candidate = value
|
|
106
|
+
if (typeof candidate.validateImage !== 'function' || typeof candidate.saveImage !== 'function') {
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
const limits = candidate.imageLimits
|
|
110
|
+
if (limits === undefined
|
|
111
|
+
|| typeof limits.maxImagesPerMessage !== 'number'
|
|
112
|
+
|| typeof limits.maxMessageImageBytes !== 'number'
|
|
113
|
+
|| typeof limits.maxImageBytes !== 'number') {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
return candidate
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function decodeImageData(data) {
|
|
120
|
+
if (typeof data !== 'string' || data.length === 0) throw new PromptImageError('image data is empty')
|
|
121
|
+
const decoded = Buffer.from(data, 'base64')
|
|
122
|
+
if (decoded.byteLength === 0) throw new PromptImageError('image data is empty')
|
|
123
|
+
return new Uint8Array(decoded)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Display name from an image URI's leaf, with local path info stripped. */
|
|
127
|
+
function imageName(uri) {
|
|
128
|
+
if (typeof uri !== 'string' || uri.length === 0) return undefined
|
|
129
|
+
let leaf
|
|
130
|
+
try {
|
|
131
|
+
leaf = new URL(uri).pathname.split('/').filter(Boolean).at(-1)
|
|
132
|
+
} catch {
|
|
133
|
+
leaf = uri.split(/[/\\]/).filter(Boolean).at(-1)
|
|
134
|
+
}
|
|
135
|
+
if (leaf === undefined || leaf.length === 0) return undefined
|
|
136
|
+
try {
|
|
137
|
+
return decodeURIComponent(leaf)
|
|
138
|
+
} catch {
|
|
139
|
+
return leaf
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Flush accumulated text into the block list (keeps 图文交替 wire order). */
|
|
144
|
+
function flushText(parts, blocks) {
|
|
145
|
+
const text = parts.join('')
|
|
146
|
+
parts.length = 0
|
|
147
|
+
if (text.length > 0) blocks.push({ type: 'text', text })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Convert an ACP prompt's content blocks into harness user-message content
|
|
152
|
+
* blocks. Text and resource links concatenate in wire order; when the
|
|
153
|
+
* composition provides an attachment ingest, ACP `image` blocks are decoded,
|
|
154
|
+
* admission-checked against the store limits, and durably committed with
|
|
155
|
+
* `saveImage`, keeping block order with surrounding text. Binary `resource`
|
|
156
|
+
* payloads and audio stay rejected — silently dropping them would be worse
|
|
157
|
+
* than refusing.
|
|
158
|
+
* @param prompt - ACP `session/prompt` content, in wire order.
|
|
159
|
+
* @param attachments - `ctx.attachments` ingest when the composition mounted
|
|
160
|
+
* one; omit (or pass `undefined`) to refuse images.
|
|
161
|
+
* @returns `{ blocks, displayText }` ready for `createUserMessage` plus a
|
|
162
|
+
* human-readable text rendering (used for titles, transcripts, commands).
|
|
163
|
+
* @throws UnsupportedPromptContentError for audio/binary blocks, or images
|
|
164
|
+
* with no ingest; PromptImageError when advertised image bytes fail
|
|
165
|
+
* admission.
|
|
166
|
+
*/
|
|
167
|
+
export async function convertPrompt(prompt, attachments) {
|
|
168
|
+
const preparedImages = []
|
|
169
|
+
for (const block of prompt) {
|
|
170
|
+
if (block?.type !== 'image') continue
|
|
171
|
+
if (attachments === undefined) throw new UnsupportedPromptContentError('image')
|
|
172
|
+
const mediaType = canonicalImageMediaType(block.mimeType)
|
|
173
|
+
if (mediaType === undefined) throw new PromptImageError(`unsupported image media type: ${block.mimeType}`)
|
|
174
|
+
const data = decodeImageData(block.data)
|
|
175
|
+
preparedImages.push({
|
|
176
|
+
data,
|
|
177
|
+
mediaType,
|
|
178
|
+
...imageName(block.uri) === undefined ? {} : { name: imageName(block.uri) },
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (preparedImages.length > 0) {
|
|
183
|
+
const { maxImagesPerMessage, maxMessageImageBytes, maxImageBytes } = attachments.imageLimits
|
|
184
|
+
if (preparedImages.length > maxImagesPerMessage) {
|
|
185
|
+
throw new PromptImageError('prompt exceeds the configured image-count limit')
|
|
186
|
+
}
|
|
187
|
+
const totalBytes = preparedImages.reduce((sum, image) => sum + image.data.byteLength, 0)
|
|
188
|
+
if (totalBytes > maxMessageImageBytes) {
|
|
189
|
+
throw new PromptImageError('prompt exceeds the configured aggregate image-byte limit')
|
|
190
|
+
}
|
|
191
|
+
for (const image of preparedImages) {
|
|
192
|
+
if (image.data.byteLength > maxImageBytes) {
|
|
193
|
+
throw new PromptImageError('image exceeds the configured encoded-byte limit')
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await attachments.validateImage({ data: image.data, mediaType: image.mediaType, ...image.name === undefined ? {} : { name: image.name } })
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
199
|
+
throw new PromptImageError(`image validation failed: ${message}`, { cause: error })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const parts = []
|
|
205
|
+
const display = []
|
|
206
|
+
const blocks = []
|
|
207
|
+
let imageIndex = 0
|
|
208
|
+
for (const block of prompt) {
|
|
209
|
+
switch (block?.type) {
|
|
210
|
+
case 'text':
|
|
211
|
+
parts.push(block.text)
|
|
212
|
+
display.push(block.text)
|
|
213
|
+
break
|
|
214
|
+
case 'resource_link':
|
|
215
|
+
// Mirror the baseline bridge's textual reference so plain clients
|
|
216
|
+
// keep file mentions without the bridge dropping them.
|
|
217
|
+
parts.push(`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`)
|
|
218
|
+
display.push(`@${block.name}`)
|
|
219
|
+
break
|
|
220
|
+
case 'image': {
|
|
221
|
+
if (attachments === undefined) throw new UnsupportedPromptContentError('image')
|
|
222
|
+
const prepared = preparedImages[imageIndex]
|
|
223
|
+
imageIndex += 1
|
|
224
|
+
if (prepared === undefined) throw new PromptImageError('image block was not prepared')
|
|
225
|
+
flushText(parts, blocks)
|
|
226
|
+
let attachment
|
|
227
|
+
try {
|
|
228
|
+
attachment = await attachments.saveImage(prepared)
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
231
|
+
throw new PromptImageError(message, { cause: error })
|
|
232
|
+
}
|
|
233
|
+
blocks.push({ type: 'image', attachment })
|
|
234
|
+
display.push(`[image${prepared.name === undefined ? '' : `: ${prepared.name}`}]`)
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
default:
|
|
238
|
+
throw new UnsupportedPromptContentError(block?.type ?? 'unknown')
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
flushText(parts, blocks)
|
|
242
|
+
return { blocks, displayText: display.join(' ').trim() }
|
|
243
|
+
}
|
|
244
|
+
|
|
60
245
|
/** Kramdown attribute-style inline markup (SiYuan exports), including
|
|
61
246
|
* truncation-damaged tails — titles are byte-budgeted upstream, so a cut
|
|
62
247
|
* can land mid-attribute (unterminated `"` or no closing `]`):
|
package/lib/index.js
CHANGED
|
@@ -36,9 +36,10 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import { randomUUID } from 'node:crypto'
|
|
39
|
-
import {
|
|
39
|
+
import { readFileSync } from 'node:fs'
|
|
40
|
+
import { rm, writeFile } from 'node:fs/promises'
|
|
40
41
|
import { createRequire } from 'node:module'
|
|
41
|
-
import { isAbsolute, dirname, resolve } from 'node:path'
|
|
42
|
+
import { isAbsolute, dirname, join, resolve } from 'node:path'
|
|
42
43
|
import { Readable, Writable } from 'node:stream'
|
|
43
44
|
import Schema from '@deepseek-ai/schemastery'
|
|
44
45
|
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk'
|
|
@@ -46,12 +47,69 @@ import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/d
|
|
|
46
47
|
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
47
48
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
48
49
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
49
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
attachmentIngestOf,
|
|
52
|
+
convertPrompt,
|
|
53
|
+
PromptImageError,
|
|
54
|
+
sanitizeWireTitle,
|
|
55
|
+
turnEndToStopReason,
|
|
56
|
+
UnsupportedPromptContentError,
|
|
57
|
+
usageTelemetry,
|
|
58
|
+
} from './codec.js'
|
|
50
59
|
|
|
51
60
|
/** Agent version advertised on the ACP wire — read from package.json so the
|
|
52
61
|
* handshake can never drift from the released package version. */
|
|
53
62
|
const AGENT_VERSION = createRequire(import.meta.url)('../package.json').version
|
|
54
63
|
|
|
64
|
+
/** State-kept per-model reasoning-effort memory (`provider/model` → effort
|
|
65
|
+
* id). A model switch carries the session's current effort onto the new
|
|
66
|
+
* model; when the target does not offer it, the effort this bridge last
|
|
67
|
+
* successfully applied to that route is restored instead — and switching
|
|
68
|
+
* back restores the effort that model had before, so the editor's dropdown
|
|
69
|
+
* never falls back to an empty ("unknown") selection. Persisted as a small
|
|
70
|
+
* JSON file next to the profile (the shipped Zed launcher exports
|
|
71
|
+
* DSH_ACP_PROFILE_DIR); without that variable the memory simply lives for
|
|
72
|
+
* the process. */
|
|
73
|
+
const effortMemoryFile = process.env.DSH_ACP_PROFILE_DIR !== undefined && process.env.DSH_ACP_PROFILE_DIR.length > 0
|
|
74
|
+
? join(process.env.DSH_ACP_PROFILE_DIR, 'dsh-acp-enhanced-effort-memory.json')
|
|
75
|
+
: undefined
|
|
76
|
+
|
|
77
|
+
function loadEffortMemory() {
|
|
78
|
+
if (effortMemoryFile === undefined) return {}
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(readFileSync(effortMemoryFile, 'utf8'))
|
|
81
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code !== 'ENOENT') {
|
|
84
|
+
console.warn(`acp-enhanced: effort memory unreadable (${effortMemoryFile}): ${String(error)}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const effortMemory = loadEffortMemory()
|
|
91
|
+
|
|
92
|
+
function writeEffortMemory() {
|
|
93
|
+
if (effortMemoryFile === undefined) return
|
|
94
|
+
writeFile(effortMemoryFile, JSON.stringify(effortMemory, null, 2)).catch((error) => {
|
|
95
|
+
console.warn(`acp-enhanced: effort memory write failed (${effortMemoryFile}): ${String(error)}`)
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The effort last successfully applied to a route, when one was recorded. */
|
|
100
|
+
function rememberedEffort(provider, model) {
|
|
101
|
+
const effort = effortMemory[`${provider}/${model}`]
|
|
102
|
+
return typeof effort === 'string' && effort.length > 0 ? effort : undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Record the effort applied to a route, persisting when possible. */
|
|
106
|
+
function rememberEffort(provider, model, effort) {
|
|
107
|
+
const key = `${provider}/${model}`
|
|
108
|
+
if (effortMemory[key] === effort) return
|
|
109
|
+
effortMemory[key] = effort
|
|
110
|
+
writeEffortMemory()
|
|
111
|
+
}
|
|
112
|
+
|
|
55
113
|
export const name = 'acp-enhanced'
|
|
56
114
|
/** The bridge creates and owns agents; every other concern is carried by the composition. */
|
|
57
115
|
export const inject = ['agents', 'llm', 'approval', 'tools', 'commands', 'systemPrompt']
|
|
@@ -510,6 +568,21 @@ export function apply(ctx, config) {
|
|
|
510
568
|
// Final accounting when the adapter reported usage on the message
|
|
511
569
|
// rather than as a stream chunk.
|
|
512
570
|
if (event.data.usage !== undefined) emitUsage(record, event.data.usage, event)
|
|
571
|
+
// Model-produced image blocks never stream through the text chunk
|
|
572
|
+
// path; surface them as a wire placeholder so the reply is not
|
|
573
|
+
// silently missing a block (ACP clients render the text).
|
|
574
|
+
for (const block of event.data.message?.content ?? []) {
|
|
575
|
+
if (block?.type === 'image' && block.attachment?.attachmentId !== undefined) {
|
|
576
|
+
notify({
|
|
577
|
+
sessionId: session.header.id,
|
|
578
|
+
update: {
|
|
579
|
+
sessionUpdate: 'agent_message_chunk',
|
|
580
|
+
messageId: record.messageId,
|
|
581
|
+
content: { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` },
|
|
582
|
+
},
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
}
|
|
513
586
|
break
|
|
514
587
|
case 'turn/start': {
|
|
515
588
|
record.turnCount += 1
|
|
@@ -858,7 +931,11 @@ export function apply(ctx, config) {
|
|
|
858
931
|
name: 'Reasoning effort',
|
|
859
932
|
description: 'Reasoning level applied to model requests for this session.',
|
|
860
933
|
category: 'thought_level',
|
|
861
|
-
|
|
934
|
+
// The chain mirrors applySelection: selected effort, the model's own
|
|
935
|
+
// default, then the first offered effort — a non-empty selection
|
|
936
|
+
// unless the model declares no reasoning at all. This backstop keeps
|
|
937
|
+
// the editor's dropdown from ever showing an "unknown" effort.
|
|
938
|
+
currentValue: selected.reasoningEffort ?? modelInfo?.reasoning?.defaultEffort ?? efforts[0]?.id ?? '',
|
|
862
939
|
options: efforts.map((effort) => ({
|
|
863
940
|
value: effort.id,
|
|
864
941
|
name: effort.name,
|
|
@@ -901,28 +978,49 @@ export function apply(ctx, config) {
|
|
|
901
978
|
|
|
902
979
|
/** Resolve and apply a new model/effort selection to the session. */
|
|
903
980
|
async function applySelection(record, next) {
|
|
904
|
-
|
|
981
|
+
// Per-model effort restoration. An explicit effort (Zed re-applies its
|
|
982
|
+
// saved default_config_options on every session, and a model switch
|
|
983
|
+
// carries the session's current effort onto the newly picked model) wins
|
|
984
|
+
// when the target offers it. Unsupported efforts are not just dropped:
|
|
985
|
+
// the effort last successfully applied to this route is restored, and a
|
|
986
|
+
// first-time route falls back to its own default — or, when it declares
|
|
987
|
+
// none, to the FIRST effort it offers — so the selection never carries an
|
|
988
|
+
// empty effort that renders as "unknown" in the editor's dropdown. A
|
|
989
|
+
// model without reasoning metadata resolves without an effort.
|
|
990
|
+
let info
|
|
905
991
|
try {
|
|
906
|
-
|
|
907
|
-
} catch
|
|
908
|
-
|
|
909
|
-
// re-applies its saved `default_config_options` on every session, and
|
|
910
|
-
// switching models carries the session's current effort onto the newly
|
|
911
|
-
// picked model, which may not offer it (glm-* and gpt-5.6-luna reject
|
|
912
|
-
// "max"). Rejecting the whole switch snapped Zed's dropdown back and
|
|
913
|
-
// made model switching itself appear broken, so drop the stale effort
|
|
914
|
-
// and resolve against the target model's own default instead. Any other
|
|
915
|
-
// failure (unknown route, missing credential) still surfaces.
|
|
916
|
-
if (error?.code !== 'UNSUPPORTED_REASONING_EFFORT' || next.reasoningEffort === undefined) throw error
|
|
917
|
-
logger.warn(`acp-enhanced: dropped reasoning effort "${next.reasoningEffort}" for ${next.provider}/${next.model} (unsupported); using the model default`)
|
|
918
|
-
resolved = await llm.resolveCallConfig({ ...next, reasoningEffort: undefined })
|
|
992
|
+
info = await llm.resolveModelInfo(next.provider, next.model)
|
|
993
|
+
} catch {
|
|
994
|
+
info = undefined
|
|
919
995
|
}
|
|
996
|
+
const efforts = info?.reasoning?.efforts ?? []
|
|
997
|
+
let candidate = next.reasoningEffort
|
|
998
|
+
if (candidate !== undefined && !efforts.some((effort) => effort.id === candidate)) {
|
|
999
|
+
logger.warn(`acp-enhanced: dropped reasoning effort "${candidate}" for ${next.provider}/${next.model} (unsupported)`)
|
|
1000
|
+
candidate = undefined
|
|
1001
|
+
}
|
|
1002
|
+
if (candidate === undefined && efforts.length > 0) {
|
|
1003
|
+
const remembered = rememberedEffort(next.provider, next.model)
|
|
1004
|
+
candidate = remembered !== undefined && efforts.some((effort) => effort.id === remembered)
|
|
1005
|
+
? remembered
|
|
1006
|
+
: info?.reasoning?.defaultEffort ?? efforts[0].id
|
|
1007
|
+
}
|
|
1008
|
+
const resolved = await llm.resolveCallConfig(
|
|
1009
|
+
candidate === undefined
|
|
1010
|
+
? { provider: next.provider, model: next.model }
|
|
1011
|
+
: { ...next, reasoningEffort: candidate },
|
|
1012
|
+
)
|
|
920
1013
|
const selected = {
|
|
921
1014
|
provider: resolved.provider,
|
|
922
1015
|
model: resolved.model,
|
|
923
1016
|
...resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort },
|
|
924
1017
|
}
|
|
925
1018
|
record.selection.current = selected
|
|
1019
|
+
// Remember the effective effort for this route so a later switch back
|
|
1020
|
+
// restores it (a model without reasoning metadata records nothing).
|
|
1021
|
+
if (selected.reasoningEffort !== undefined) {
|
|
1022
|
+
rememberEffort(selected.provider, selected.model, selected.reasoningEffort)
|
|
1023
|
+
}
|
|
926
1024
|
// Persist as the default only when the chosen route is one this bridge is
|
|
927
1025
|
// wired to serve. On a single-provider machine the default route is the
|
|
928
1026
|
// routable one, so switching within it always persists; a stray cross-provider
|
|
@@ -1510,6 +1608,11 @@ export function apply(ctx, config) {
|
|
|
1510
1608
|
const parts = []
|
|
1511
1609
|
for (const block of content ?? []) {
|
|
1512
1610
|
if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
1611
|
+
else if (block?.type === 'image' && block.attachment?.attachmentId !== undefined) {
|
|
1612
|
+
// Model-produced images replay as a textual reference — the wire
|
|
1613
|
+
// surface does not carry attachment bytes.
|
|
1614
|
+
parts.push(`[image attachment ${block.attachment.attachmentId}]`)
|
|
1615
|
+
}
|
|
1513
1616
|
}
|
|
1514
1617
|
return parts.join('\n')
|
|
1515
1618
|
}
|
|
@@ -1614,7 +1717,15 @@ export function apply(ctx, config) {
|
|
|
1614
1717
|
// workspace root on session/new / session/load instead of showing
|
|
1615
1718
|
// the "doesn't currently support multi-root workspaces" callout).
|
|
1616
1719
|
sessionCapabilities: { list: {}, delete: {}, additionalDirectories: {} },
|
|
1617
|
-
|
|
1720
|
+
// Image support is a live capability: the harness advertises
|
|
1721
|
+
// `image: true` only when the composition mounted a working
|
|
1722
|
+
// attachment store (duck-typed, so dsh 0.1.1-rc.2+ with
|
|
1723
|
+
// dsh-attachment-local enables it and older stacks report false).
|
|
1724
|
+
promptCapabilities: {
|
|
1725
|
+
image: attachmentIngestOf(ctx.get('attachments')) !== undefined,
|
|
1726
|
+
audio: false,
|
|
1727
|
+
embeddedContext: false,
|
|
1728
|
+
},
|
|
1618
1729
|
// Stdio MCP servers always work; streamable HTTP maps onto
|
|
1619
1730
|
// dsh-mcp-client's second transport. Legacy SSE does not.
|
|
1620
1731
|
mcpCapabilities: { http: true, sse: false },
|
|
@@ -1735,10 +1846,28 @@ export function apply(ctx, config) {
|
|
|
1735
1846
|
if (record.inflight !== undefined) {
|
|
1736
1847
|
throw invalidParams('a prompt is already in flight for this session')
|
|
1737
1848
|
}
|
|
1738
|
-
|
|
1739
|
-
|
|
1849
|
+
// Capability-gated content conversion: text/resource_link always work;
|
|
1850
|
+
// image blocks are ingested through the composition's attachment
|
|
1851
|
+
// store when one is mounted (advertised on initialize). A client that
|
|
1852
|
+
// sends unsupported content gets the exact failing kind — never a
|
|
1853
|
+
// silent drop — and an image that fails admission (bad type, over
|
|
1854
|
+
// limits, store rejection) surfaces its precise reason.
|
|
1855
|
+
let blocks
|
|
1856
|
+
let text
|
|
1857
|
+
try {
|
|
1858
|
+
const ingest = attachmentIngestOf(ctx.get('attachments'))
|
|
1859
|
+
const converted = await convertPrompt(params.prompt, ingest)
|
|
1860
|
+
blocks = converted.blocks
|
|
1861
|
+
text = converted.displayText
|
|
1862
|
+
} catch (error) {
|
|
1863
|
+
if (error instanceof UnsupportedPromptContentError) {
|
|
1864
|
+
throw invalidParams(error.message)
|
|
1865
|
+
}
|
|
1866
|
+
if (error instanceof PromptImageError) {
|
|
1867
|
+
throw invalidParams(`image rejected: ${error.message}`)
|
|
1868
|
+
}
|
|
1869
|
+
throw error
|
|
1740
1870
|
}
|
|
1741
|
-
const text = acpPromptToText(params.prompt)
|
|
1742
1871
|
if (text.trim().length === 0) throw invalidParams('empty prompt')
|
|
1743
1872
|
|
|
1744
1873
|
// Insurance: by the first prompt the client is guaranteed to know the
|
|
@@ -1786,7 +1915,7 @@ export function apply(ctx, config) {
|
|
|
1786
1915
|
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
|
1787
1916
|
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
|
1788
1917
|
}
|
|
1789
|
-
const message = createUserMessage({ content:
|
|
1918
|
+
const message = createUserMessage({ content: blocks, source: { kind: 'user' } })
|
|
1790
1919
|
if (process.env.ACP_DEBUG) process.stderr.write(`[acp-debug] followup queued, agent phase=${record.agent.phase?.kind} inboxPending=${record.agent.inbox?.hasPending}\n`)
|
|
1791
1920
|
const stopReason = await new Promise((resolve, reject) => {
|
|
1792
1921
|
const inflight = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-acp-enhanced",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Enhanced ACP server for DeepSeek Harness: block-level streaming, usage/stat telemetry (cache hit rate, token speed, input/output tokens, context length, turns, tool timing), model & reasoning-effort switching, and permission-preset control over the ACP wire (Zed-friendly)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
package/scripts/dsh-acp-zed.sh
CHANGED
|
@@ -61,4 +61,15 @@ if [ -z "${DEEPSEEK_API_KEY:-}" ]; then
|
|
|
61
61
|
fi
|
|
62
62
|
fi
|
|
63
63
|
|
|
64
|
+
# Derive the profile root from this launcher's own location:
|
|
65
|
+
# …/profiles/<name>/node_modules/<pkg>/scripts/dsh-acp-zed.sh
|
|
66
|
+
# — three levels above this script is the profile dir. pwd -L keeps the
|
|
67
|
+
# logical path (pnpm lays out node_modules as symlinks), so the climb lands
|
|
68
|
+
# on the profile root. The bridge persists small per-model state (the last
|
|
69
|
+
# reasoning effort per model) next to the profile's own files.
|
|
70
|
+
if [ -z "${DSH_ACP_PROFILE_DIR:-}" ]; then
|
|
71
|
+
ACP_LAUNCHER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -L)"
|
|
72
|
+
export DSH_ACP_PROFILE_DIR="$(cd "${ACP_LAUNCHER_DIR}/../../.." && pwd -L)"
|
|
73
|
+
fi
|
|
74
|
+
|
|
64
75
|
exec "${DASH_BIN}" --profile acp-enhanced "$@"
|