magic-agent 1.0.2 → 1.0.46
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/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +260 -8
- package/internal/agent/custom_models_test.go +204 -7
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +78 -2
- package/internal/agent/env_test.go +37 -0
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/repair_models_test.go +194 -0
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +282 -51
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/login.go +61 -4
- package/internal/cli/login_test.go +99 -0
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/repair.go +60 -0
- package/internal/cli/repair_test.go +51 -0
- package/internal/cli/root.go +195 -32
- package/internal/cli/session.go +183 -29
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +78 -10
- package/npm/bin/magic-client.js +100 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
package/internal/agent/dsh.go
CHANGED
|
@@ -31,12 +31,14 @@ package agent
|
|
|
31
31
|
// → 与 trae/claude 同路,用子进程 cwd 落地(WorkspaceSupportOf = "cwd")。
|
|
32
32
|
// SDK 通道同理:initialize 的 cwd 就是「每个 SDK 建的会话 header 上记的目录」,
|
|
33
33
|
// 同时也仍用子进程 cwd 兜底(工具执行的相对路径跟着它走)。
|
|
34
|
-
// -
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
34
|
+
// - 权限:档位走**子进程环境变量** `DSH_PERMISSION_MODE`(dsh 是 profile 启动器,
|
|
35
|
+
// 没有逐调用的权限 flag)。shipped 的 sdk profile 里 sandbox-policy.mode 与
|
|
36
|
+
// user-approval.policy 都直接读它,取值即 SandboxMode 词表:
|
|
37
|
+
// read-only / workspace-write / danger-full-access(最后一条 = 「沙箱关闭 + 无审批」,
|
|
38
|
+
// 正是四档里的第 4 档)。四档映射与两处已知差异(dsh 无 LLM 分类器、ask 需要应答者
|
|
39
|
+
// 而 SDK 通道拿不到审批请求)见 permission.go::dshPermissionMode。
|
|
40
|
+
// ⚠️ 2026-10-14 修正:本文件原先写「两个预设无法表达第 4 档 → 不接线(none)」,
|
|
41
|
+
// 那是 dsh 0.1.x 的情况;0.2.0-rc.2 的 `--dump-config` 里已有 danger-full-access 预设。
|
|
40
42
|
// - 工具:DSH 自带 agent 工具循环(base bundle 的 read/write/edit + Bash 等),
|
|
41
43
|
// 增删工具靠 profile bundle / cordis.patch.yml 配置,**无命令行级逐工具开关**
|
|
42
44
|
// → Request.Tools 整体忽略(与 openclaw 的「内嵌 agent」同构,见矩阵注 ③)。
|
|
@@ -48,21 +50,22 @@ package agent
|
|
|
48
50
|
//
|
|
49
51
|
// Model SDK:支持(initialize 的 provider/model,取 `route/model` 形态)
|
|
50
52
|
// headless 回退:不支持(静默忽略 + 一行告警:换模型要改配置层)
|
|
51
|
-
// SessionID
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// Continue 同上(无「最近一次会话」概念)→ 显式报错
|
|
53
|
+
// SessionID 走 headless 的 `--session-id <id>` 做**真续接**(dsh 自己持久化会话;
|
|
54
|
+
// SDK 通道没有 resume 方法)→ 续接轮由 headless + --json 承载,
|
|
55
|
+
// 上下文由 dsh 接回来,工具/正文/推理与 SDK 通道同构上报(见 dsh_resume.go)
|
|
56
|
+
// Continue 同上:先按会话登记表找**同一 workspace** 下最近一条 dsh 会话,再走 --session-id
|
|
56
57
|
// MaxTokens SDK:支持(initialize 的 maxTokens,官方语义「限制 SDK 创建的 agent
|
|
57
58
|
// 及其进程内后代的每次对话模型输出」);headless 回退:静默忽略
|
|
58
59
|
// Temperature 不支持(无对应键)→ 静默忽略
|
|
59
60
|
// Tools 内嵌 agent(忽略,见上)
|
|
60
|
-
// Permission
|
|
61
|
+
// Permission 环境变量 DSH_PERMISSION_MODE(四档 → read-only / workspace-write /
|
|
62
|
+
// danger-full-access,见 permission.go::dshPermissionMode)
|
|
61
63
|
// SystemPrompt 无独立 system 注入 flag → 展平进 prompt 头部(与 trae/openclaw 同路)
|
|
62
64
|
// Timeout 进程级超时(CLI 无 flag)
|
|
63
65
|
// JSONSchema 无原生约束 → 走与 llm/openclaw/arkclaw 同一条「输出后处理抽 JSON」
|
|
64
66
|
// Workspace 子进程 cwd(SDK 通道另把 cwd 交给 initialize)
|
|
65
|
-
// Attachments
|
|
67
|
+
// Attachments SDK 通道:图片走协议原生 image 块(base64 inline,真进模型上下文);
|
|
68
|
+
// 非图片附件与 headless 回退 → 绝对路径写进提示词(与 trae/openclaw 同路)
|
|
66
69
|
//
|
|
67
70
|
// 流式:实现了 Streamer,两通道能力不同(如实接线,不假装):
|
|
68
71
|
//
|
|
@@ -104,9 +107,9 @@ func (e *DshEngine) Name() string { return "dsh" }
|
|
|
104
107
|
// DefaultDshTimeout 单次尝试默认超时。
|
|
105
108
|
//
|
|
106
109
|
// dsh 自带 agent 工具循环(读文件 / 跑命令),一轮任务常常是分钟级;
|
|
107
|
-
//
|
|
108
|
-
// ctx 施加(超时会杀掉 dsh 及其 node worker 的整个进程组)。
|
|
109
|
-
const DefaultDshTimeout =
|
|
110
|
+
// 2026-10-10 统一上调到 1 小时(与 CLI 的 -t 默认一致)。CLI 侧没有 --timeout,超时完全
|
|
111
|
+
// 由外层 ctx 施加(超时会杀掉 dsh 及其 node worker 的整个进程组)。
|
|
112
|
+
const DefaultDshTimeout = time.Hour
|
|
110
113
|
|
|
111
114
|
// dshDefaultProfile headless 是一次性(非交互)入口。
|
|
112
115
|
const dshDefaultProfile = "headless"
|
|
@@ -158,14 +161,18 @@ func (e *DshEngine) Detect() (bool, string) {
|
|
|
158
161
|
return true, p
|
|
159
162
|
}
|
|
160
163
|
|
|
161
|
-
// ListModels 实现 ModelLister:返回
|
|
164
|
+
// ListModels 实现 ModelLister:返回 dsh **已配置**的 `route/model` 清单。
|
|
162
165
|
//
|
|
163
166
|
// 为什么不查 CLI:dsh 启动器只有 profile / plugin / config-dump 三种模式,没有
|
|
164
167
|
// `dsh models` 之类的只读子命令(headless 也没有 --model 参数)。
|
|
165
168
|
//
|
|
166
|
-
// 为什么不查网关的 `GET {baseURL}/models
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
+
// 为什么不查网关的 `GET {baseURL}/models`:官方明确「配置层才是决定一条路由服务
|
|
170
|
+
// 什么的东西」—— 网关列出的上百个模型并不都能用,只有配置里声明过的才会被路由接受。
|
|
171
|
+
// 故**配置层才是权威**,也顺带避免了每次探测都发网络请求 + 需要解密凭据。
|
|
172
|
+
//
|
|
173
|
+
// 配置位置随 dsh 版本变过(见 dshConfigDocs):0.1.7 起是各 profile 的
|
|
174
|
+
// cordis.patch.yml,之前是 $DSH_HOME/settings.yaml。两处都读、取并集 ——
|
|
175
|
+
// 前者是 SDK 通道,后者是 headless 回退通道,magic-agent 两条都会用到。
|
|
169
176
|
//
|
|
170
177
|
// 输出形态 `provider/model`(与 openclaw 的 `provider/model` 同构):dsh 的模型只在
|
|
171
178
|
// 所属路由内有意义,同名模型可能出现在两条路由上,裸 id 会歧义。
|
|
@@ -174,21 +181,30 @@ func (e *DshEngine) Detect() (bool, string) {
|
|
|
174
181
|
// provider/model 两个必填参数,见 dshResolveRoute)。回退 headless 后 `-m` 无作用
|
|
175
182
|
// (无 --model 参数),引擎会打一行告警说明该改配置层。
|
|
176
183
|
func (e *DshEngine) ListModels(_ context.Context) ([]string, error) {
|
|
184
|
+
docs := dshConfigDocs()
|
|
185
|
+
var models []string
|
|
186
|
+
for _, d := range docs {
|
|
187
|
+
models = append(models, parseDshModelRoutes(d.legacyText())...)
|
|
188
|
+
}
|
|
189
|
+
if models = dedupeModels(models); len(models) > 0 {
|
|
190
|
+
return models, nil
|
|
191
|
+
}
|
|
177
192
|
home := DshHome()
|
|
178
193
|
if home == "" {
|
|
179
194
|
return nil, fmt.Errorf("%w: 无法定位 $DSH_HOME(设置 DSH_HOME 或检查 HOME)", ErrNoModelSource)
|
|
180
195
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
ErrNoModelSource, filepath.Join(home, "settings.yaml"), err)
|
|
196
|
+
paths := make([]string, 0, len(docs))
|
|
197
|
+
for _, d := range docs {
|
|
198
|
+
paths = append(paths, d.Path)
|
|
185
199
|
}
|
|
186
|
-
|
|
187
|
-
if len(
|
|
188
|
-
|
|
189
|
-
ErrNoModelSource, filepath.Join(home, "settings.yaml"))
|
|
200
|
+
tried := "(没有候选文件)"
|
|
201
|
+
if len(paths) > 0 {
|
|
202
|
+
tried = strings.Join(paths, "、")
|
|
190
203
|
}
|
|
191
|
-
return
|
|
204
|
+
return nil, fmt.Errorf("%w: 没有读到已配置的模型;找过 %s。"+
|
|
205
|
+
"dsh 0.1.7 起 provider 声明在 profile 的 cordis.patch.yml(可用 dsh 的「设置 → 模型」写入),"+
|
|
206
|
+
"更早的版本在 settings.yaml 的 llm-pi-ai.providers.<id>.models 段",
|
|
207
|
+
ErrNoModelSource, tried)
|
|
192
208
|
}
|
|
193
209
|
|
|
194
210
|
// sdkEnabled 是否走 SDK 通道(effectiveProfile == "sdk")。
|
|
@@ -198,17 +214,6 @@ func (e *DshEngine) sdkEnabled() bool {
|
|
|
198
214
|
return e.effectiveProfile() == dshSDKProfile
|
|
199
215
|
}
|
|
200
216
|
|
|
201
|
-
// errDshNoSessionResume 会话续接在 dsh 上不可用(两条通道都不行,理由不同)。
|
|
202
|
-
//
|
|
203
|
-
// 明确报错而不是静默开新会话 —— 后者会让调用方以为上一轮上下文还在。
|
|
204
|
-
var errDshNoSessionResume = errors.New(
|
|
205
|
-
"dsh: 不支持会话续接(-s/--session)—— headless 每次调用都是全新会话;" +
|
|
206
|
-
"SDK 通道的 sessionId 只在**同一个 runtime 进程内**可续(官方 Python SDK 文档:" +
|
|
207
|
-
"reuse a harness, home, and id),换进程拿旧 id 会被 -32603 `session already exists` 拒掉," +
|
|
208
|
-
"协议也没有 resume 方法。要真正的多轮上下文请用常驻会话:" +
|
|
209
|
-
"`magic-agent -e dsh --stream --keep-alive \"首轮\"`,再用 " +
|
|
210
|
-
"`magic-agent --append <run_id|session_id> -p \"追加\"` 接着问")
|
|
211
|
-
|
|
212
217
|
// dshModelLabel 本轮实际用的模型标签(`provider/model`,与 ListModels 同形态)。
|
|
213
218
|
func dshModelLabel(res dshSDKTurnResult) string {
|
|
214
219
|
if res.Provider == "" || res.Model == "" {
|
|
@@ -221,7 +226,7 @@ func dshModelLabel(res dshSDKTurnResult) string {
|
|
|
221
226
|
// 该 profile 不可用时回退 `dsh --profile headless "<prompt>"`。
|
|
222
227
|
//
|
|
223
228
|
// 执行流程:
|
|
224
|
-
// 1.
|
|
229
|
+
// 1. 续接参数 → **回放式续接**(dsh 协议无 resume,见 dsh_resume.go)
|
|
225
230
|
// 2. 展平 prompt(无独立 system 通道)+ 附件路径兜底
|
|
226
231
|
// 3. SDK 通道:initialize + session/prompt,收 turn/end 时的正文
|
|
227
232
|
// (通道不可用 → 回退;服务端明确拒绝 → 直接报错,不吞)
|
|
@@ -234,23 +239,42 @@ func (e *DshEngine) Complete(ctx context.Context, req Request) (Response, error)
|
|
|
234
239
|
return Response{}, fmt.Errorf("dsh CLI not found; set MAGIC_AGENT_DSH_BIN")
|
|
235
240
|
}
|
|
236
241
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
242
|
+
// 工作区规范化:会话是按 cwd 字符串记的,续接时 dsh 逐字比对(见 dshCanonicalWorkspace)。
|
|
243
|
+
// 必须放在 SDK 分支之前 —— 首轮的 initialize.cwd 也是记录值的一部分。
|
|
244
|
+
req.Workspace = dshCanonicalWorkspace(req.Workspace)
|
|
240
245
|
|
|
241
246
|
// 无独立 system 注入 flag:与 trae / openclaw 同路,展平进 prompt 头部。
|
|
242
247
|
// 这里不追加 noToolSuffix —— dsh 是自带工具循环的 agent harness,
|
|
243
248
|
// Request.Tools 在它上面没有落地通道(见文件头矩阵),后缀只会是噪音。
|
|
244
|
-
|
|
245
|
-
if
|
|
249
|
+
basePrompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
|
|
250
|
+
if basePrompt == "" {
|
|
246
251
|
return Response{}, fmt.Errorf("dsh: empty prompt")
|
|
247
252
|
}
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
253
|
+
// 附件分两条路(见 dshSplitAttachments):
|
|
254
|
+
// SDK 通道 图片作为原生 image 块随 contentBlocks 发出去(真的进模型上下文)
|
|
255
|
+
// headless 没有附件输入参数,只能把**绝对路径**写进提示词(与 trae/openclaw 同一兜底)
|
|
256
|
+
// 非图片附件(PDF/文本…)协议里没有内联类型,两条路都退化为路径。
|
|
257
|
+
_, byPath := dshSplitAttachments(req.Attachments)
|
|
258
|
+
sdkPrompt := appendAttachmentSection(basePrompt, byPath)
|
|
259
|
+
headlessPrompt := appendAttachmentSection(basePrompt, req.Attachments)
|
|
260
|
+
|
|
261
|
+
// 续接轮:dsh 的**真续接**(headless + --session-id)只在 headless 通道上,
|
|
262
|
+
// SDK 通道没有 resume 方法 → 这种轮次不走 SDK(见 dsh_resume.go)。
|
|
263
|
+
// 常驻会话(req.Append)是例外:它必须待在 SDK 通道(追加靠同一进程),
|
|
264
|
+
// 而且它本身就带着上下文,不需要 --session-id。
|
|
265
|
+
resumeID := ""
|
|
266
|
+
if req.Append == nil {
|
|
267
|
+
resumeID = dshResumeSessionID(req)
|
|
268
|
+
}
|
|
269
|
+
if resumeID != "" {
|
|
270
|
+
if !dshHeadlessCapabilities(ctx, bin).SessionID {
|
|
271
|
+
return Response{}, errors.New(dshResumeUnsupportedHint(bin))
|
|
272
|
+
}
|
|
273
|
+
return e.completeHeadless(ctx, req, bin, resumeID, headlessPrompt, start)
|
|
274
|
+
}
|
|
251
275
|
|
|
252
276
|
if e.sdkEnabled() {
|
|
253
|
-
res, sdkErr := dshSDKTurn(ctx, bin, req.Workspace, req,
|
|
277
|
+
res, sdkErr := dshSDKTurn(ctx, bin, req.Workspace, req, sdkPrompt, nil)
|
|
254
278
|
if sdkErr == nil {
|
|
255
279
|
resp := Response{
|
|
256
280
|
Engine: e.Name(),
|
|
@@ -283,25 +307,63 @@ func (e *DshEngine) Complete(ctx context.Context, req Request) (Response, error)
|
|
|
283
307
|
fmt.Fprintf(stderr, " ⚠ dsh: SDK 通道不可用,本轮回退 headless(拿不到工具调用、正文不流式):%v\n", sdkErr)
|
|
284
308
|
}
|
|
285
309
|
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
310
|
+
// 走到这里就是 SDK 不可用、要用 headless 执行了(回退路径,无续接)。
|
|
311
|
+
return e.completeHeadless(ctx, req, bin, "", headlessPrompt, start)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// dshSplitAttachments 把附件分成「走 SDK 原生 image 块」与「只能写路径」两拨。
|
|
315
|
+
//
|
|
316
|
+
// 原生那拨 MIME 是 dsh SDK 内联图片认的四种(png/jpeg/webp/gif,见 dshNativeImageMIME)
|
|
317
|
+
// 路径那拨 其余(PDF / bmp / 文本 / 未知类型)—— 协议里没有通用文件内联类型
|
|
318
|
+
//
|
|
319
|
+
// 路径那拨与 headless 回退一样,由调用方把绝对路径写进提示词末尾的「【附件】」清单。
|
|
320
|
+
func dshSplitAttachments(atts []Attachment) (native, byPath []Attachment) {
|
|
321
|
+
for _, a := range atts {
|
|
322
|
+
if dshNativeImageMIME(a.MIME) {
|
|
323
|
+
native = append(native, a)
|
|
324
|
+
} else {
|
|
325
|
+
byPath = append(byPath, a)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return native, byPath
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// dshNativeImageMIME dsh SDK 的 SdkEncodedImageBlock.mimeType 只接受这四种(协议原文)。
|
|
332
|
+
// bmp / pdf 即便被嗅探成图片也不是合法内联类型,照旧走路径兜底。
|
|
333
|
+
func dshNativeImageMIME(mime string) bool {
|
|
334
|
+
switch mime {
|
|
335
|
+
case "image/png", "image/jpeg", "image/webp", "image/gif":
|
|
336
|
+
return true
|
|
337
|
+
}
|
|
338
|
+
return false
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// completeHeadless headless 通道:回退路径与**续接轮**共用。
|
|
342
|
+
//
|
|
343
|
+
// 形态:`dsh --profile headless [--session-id <id>] "<prompt>"`,stdout 即最终正文。
|
|
344
|
+
// --session-id 是 dsh 自己的续接入口(见 dsh_resume.go),只在续接轮带上。
|
|
345
|
+
func (e *DshEngine) completeHeadless(ctx context.Context, req Request, bin, sessionID, prompt string, start time.Time) (Response, error) {
|
|
346
|
+
// headless 没有 --model 参数:-m 在这里「不生效」,告警一次(与 arkclaw 一致),
|
|
347
|
+
// 让用户知道该去改 dsh 自己的配置。显式 Profile: "headless" 也走这里,同样告警。
|
|
348
|
+
// ⚠️ **续接轮不告警**:那种轮的模型由那条会话自己决定,-m 本来就不该有作用
|
|
349
|
+
//(客户端每轮都会带 -m,不区分的话每次追问都会刷一行无意义的告警)。
|
|
350
|
+
if req.Model != "" && sessionID == "" {
|
|
290
351
|
fmt.Fprintf(stderr, " ⚠ dsh: headless 通道下 --model %s 不生效(无 --model 参数);"+
|
|
291
352
|
"换模型请改 $DSH_HOME/settings.yaml 的 agent-default-model 段,或用 dsh 的 Settings → Models\n", req.Model)
|
|
292
353
|
}
|
|
293
354
|
|
|
294
|
-
args := e.
|
|
355
|
+
args := dshHeadlessArgs(e.profile(), sessionID, prompt, false)
|
|
295
356
|
|
|
296
357
|
// workspace:dsh 的「调用时所在目录即默认 workspace 根」正是子进程 cwd 的语义。
|
|
297
|
-
|
|
358
|
+
// 档位同 SDK 通道:走 DSH_PERMISSION_MODE(dsh 没有逐调用的权限 flag)。
|
|
359
|
+
stdout, stderrText, runErr := runCLIEnvIn(ctx, req.Workspace, dshPermissionEnv(req.Permission), bin, args...)
|
|
298
360
|
|
|
299
361
|
resp := Response{
|
|
300
362
|
Engine: e.Name(),
|
|
301
|
-
// headless 的 stdout
|
|
363
|
+
// headless 的 stdout 就是最终助手正文(推理增量走 stderr,不混入)。
|
|
302
364
|
Text: strings.TrimSpace(stdout),
|
|
303
365
|
// 模型名尽力而为:读 dsh 配置里的默认模型(读不到给 "config-default")。
|
|
304
|
-
Model:
|
|
366
|
+
Model: dshHeadlessModel(req, sessionID),
|
|
305
367
|
Latency: time.Since(start),
|
|
306
368
|
}
|
|
307
369
|
if resp.Model == "" {
|
|
@@ -315,7 +377,7 @@ func (e *DshEngine) Complete(ctx context.Context, req Request) (Response, error)
|
|
|
315
377
|
}
|
|
316
378
|
if runErr != nil {
|
|
317
379
|
// 非零退出 = turn/end 不是 completed;stderr 末尾通常就是终止原因,
|
|
318
|
-
// wrapCliError
|
|
380
|
+
// wrapCliError 会把它摘成可读摘要(未知 session-id 也走这条,报 does not exist)。
|
|
319
381
|
return resp, wrapCliError("dsh", stdout, stderrText, runErr)
|
|
320
382
|
}
|
|
321
383
|
if resp.Text == "" {
|
|
@@ -324,33 +386,50 @@ func (e *DshEngine) Complete(ctx context.Context, req Request) (Response, error)
|
|
|
324
386
|
return resp, nil
|
|
325
387
|
}
|
|
326
388
|
|
|
327
|
-
//
|
|
389
|
+
// dshHeadlessModel headless 轮次对外报的模型标签。
|
|
328
390
|
//
|
|
329
|
-
//
|
|
391
|
+
// 普通 headless 轮:读 $DSH_HOME 配置里的 agent-default-model(读不到返回空串,
|
|
392
|
+
// 调用方回落 "config-default")—— 这轮真的用它。
|
|
393
|
+
// 续接轮: dsh 不回会话的模型,而配置层默认值**很可能不是**这条会话的模型
|
|
394
|
+
// (实测:会话是 modelverse/… 建的,配置默认是 deepseek-v4-flash)。
|
|
395
|
+
// 调用方每轮都会带同一个 -m(客户端 1 任务 = 1 会话 = 1 模型),
|
|
396
|
+
// 所以拿它当**最佳近似**比报配置默认值更接近事实。
|
|
397
|
+
func dshHeadlessModel(req Request, sessionID string) string {
|
|
398
|
+
if sessionID != "" {
|
|
399
|
+
if m := strings.TrimSpace(req.Model); m != "" {
|
|
400
|
+
return m
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return DshConfiguredModel()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// dshHeadlessArgs 构造 headless 的参数:**启动器 flag 在前,app 参数在后**。
|
|
330
407
|
//
|
|
331
|
-
//
|
|
408
|
+
// ["--profile", <profile>] [+ "--session-id", <id>] [+ "--json"] + ["<prompt>"]
|
|
332
409
|
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
410
|
+
// ⚠️ --session-id / --json 是 **headless 自己的 flag**(不是启动器 flag,见 dsh --help),
|
|
411
|
+
// 必须排在 --profile 之后、任务文本之前 —— 启动器在第一个它不认识的 token 处结束解析。
|
|
412
|
+
// prompt 走位置参数(headless 唯一的 app 参数,无 --prompt / stdin 通道),
|
|
413
|
+
// 超长 prompt 会撞 ARG_MAX:需要喂超长文本时先落成文件,让 dsh 用读文件工具看。
|
|
336
414
|
//
|
|
337
|
-
// ⚠️
|
|
415
|
+
// ⚠️ 只有 headless 用得到它:SDK 通道的 prompt 走 session/prompt 的 contentBlocks
|
|
338
416
|
// (见 dsh_sdk.go),既不占命令行也不受 ARG_MAX 限制。
|
|
339
|
-
func (
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
417
|
+
func dshHeadlessArgs(profile, sessionID, prompt string, jsonEvents bool) []string {
|
|
418
|
+
args := []string{"--profile", profile}
|
|
419
|
+
if sessionID != "" {
|
|
420
|
+
args = append(args, "--session-id", sessionID)
|
|
421
|
+
}
|
|
422
|
+
if jsonEvents {
|
|
423
|
+
args = append(args, "--json")
|
|
424
|
+
}
|
|
425
|
+
return append(args, prompt)
|
|
347
426
|
}
|
|
348
427
|
|
|
349
428
|
// ── 流式(--stream)────────────────────────────────────────────
|
|
350
429
|
//
|
|
351
|
-
// dsh
|
|
430
|
+
// dsh 的流式形态与通道有关,如实接线:
|
|
352
431
|
//
|
|
353
|
-
// SDK
|
|
432
|
+
// SDK 通道(普通轮默认,见 dsh_sdk.go)
|
|
354
433
|
// session.event 实时推送:
|
|
355
434
|
// assistant/message 的 reasoning 块 → 逐条 KindThinking
|
|
356
435
|
// assistant/message 的 text 块 → 逐 step KindText(真流式,不等 turn 结束)
|
|
@@ -358,45 +437,63 @@ func (e *DshEngine) buildArgs(req Request, prompt string) []string {
|
|
|
358
437
|
// tool/result → KindToolResult(按 callId 关联)
|
|
359
438
|
// turn/end → 收尾一条 KindTurnEnd
|
|
360
439
|
//
|
|
361
|
-
// headless
|
|
362
|
-
//
|
|
363
|
-
//
|
|
440
|
+
// headless + --json(**续接轮**与 SDK 不可用时的回退路径,见 dsh_resume.go)
|
|
441
|
+
// stdout 是 NDJSON 事件流:session / status(turn/step) / thinking / text /
|
|
442
|
+
// tool_call / tool_result / final
|
|
443
|
+
// → 推理 / 正文 / 工具调用与 SDK 通道**同构**地上报(0.1.7+ 的官方 run 投影)
|
|
444
|
+
//
|
|
445
|
+
// headless(老版本没有 --json)
|
|
446
|
+
// stderr:`dsh: reasoning:` 标题行之后,推理增量逐段到达;stdout:收尾正文一次性到达
|
|
364
447
|
// → 推理 → 逐条 KindThinking;正文 → 一条 KindText(收尾时)
|
|
365
|
-
// ⚠️
|
|
448
|
+
// ⚠️ 这条路上**没有任何工具调用通道**(官方早期 headless 的定位就是「stream reasoning
|
|
366
449
|
// to stderr, print the final assistant message, and exit」)—— 不是解析缺陷。
|
|
367
450
|
|
|
368
|
-
// dshReasoningPrefix stderr
|
|
451
|
+
// dshReasoningPrefix stderr 上推理增量的起始标记(老版 headless 的固定前缀)。
|
|
369
452
|
const dshReasoningPrefix = "dsh: reasoning:"
|
|
370
453
|
|
|
371
|
-
// Stream 实现 Streamer
|
|
372
|
-
// SDK
|
|
454
|
+
// Stream 实现 Streamer:普通轮默认走 SDK(推理/正文/工具调用全流式),
|
|
455
|
+
// 续接轮与 SDK 不可用时走 headless(--json 事件流,能力见本节顶部)。
|
|
373
456
|
func (e *DshEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
374
457
|
start := time.Now()
|
|
375
458
|
bin := e.bin()
|
|
376
459
|
if bin == "" {
|
|
377
460
|
return StreamResult{}, fmt.Errorf("dsh CLI not found; set MAGIC_AGENT_DSH_BIN")
|
|
378
461
|
}
|
|
379
|
-
// 与 Complete 同一条契约:dsh 两条通道都续不上,明确报错而不是静默开新会话。
|
|
380
|
-
if req.SessionID != "" || req.Continue {
|
|
381
|
-
return StreamResult{}, errDshNoSessionResume
|
|
382
|
-
}
|
|
383
462
|
|
|
384
|
-
|
|
385
|
-
|
|
463
|
+
// 工作区规范化:与 Complete 同一条理由(见 dshCanonicalWorkspace),必须在 SDK 分支之前。
|
|
464
|
+
req.Workspace = dshCanonicalWorkspace(req.Workspace)
|
|
465
|
+
|
|
466
|
+
basePrompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
|
|
467
|
+
if basePrompt == "" {
|
|
386
468
|
return StreamResult{}, fmt.Errorf("dsh: empty prompt")
|
|
387
469
|
}
|
|
388
|
-
|
|
470
|
+
// 同 Complete:SDK 通道图片走原生 image 块,非图片附件/headless 走提示词路径。
|
|
471
|
+
_, byPath := dshSplitAttachments(req.Attachments)
|
|
472
|
+
sdkPrompt := appendAttachmentSection(basePrompt, byPath)
|
|
473
|
+
headlessPrompt := appendAttachmentSection(basePrompt, req.Attachments)
|
|
474
|
+
|
|
475
|
+
// 与 Complete 同一条契约:续接轮走 headless + --session-id(真续接,见 dsh_resume.go)。
|
|
476
|
+
resumeID := ""
|
|
477
|
+
if req.Append == nil {
|
|
478
|
+
resumeID = dshResumeSessionID(req)
|
|
479
|
+
}
|
|
480
|
+
if resumeID != "" {
|
|
481
|
+
if !dshHeadlessCapabilities(ctx, bin).SessionID {
|
|
482
|
+
return StreamResult{}, errors.New(dshResumeUnsupportedHint(bin))
|
|
483
|
+
}
|
|
484
|
+
return e.streamHeadless(ctx, req, bin, resumeID, headlessPrompt, onEvent, start)
|
|
485
|
+
}
|
|
389
486
|
|
|
390
487
|
if e.sdkEnabled() {
|
|
391
|
-
res, sdkErr := dshSDKTurn(ctx, bin, req.Workspace, req,
|
|
488
|
+
res, sdkErr := dshSDKTurn(ctx, bin, req.Workspace, req, sdkPrompt, onEvent)
|
|
392
489
|
if sdkErr == nil {
|
|
393
490
|
resp := Response{
|
|
394
491
|
Engine: e.Name(),
|
|
395
492
|
Text: strings.TrimSpace(res.Text),
|
|
396
493
|
Model: dshModelLabel(res),
|
|
397
494
|
// 报出本轮会话 id:常驻会话里 `--append <session_id>` 能用它定位
|
|
398
|
-
//(会话登记表按 run_id 或 session_id
|
|
399
|
-
|
|
495
|
+
//(会话登记表按 run_id 或 session_id 查找),下游 `-s` 也靠它找回上文
|
|
496
|
+
//(回放式续接,见 dsh_resume.go)。
|
|
400
497
|
SessionID: res.SessionID,
|
|
401
498
|
Latency: time.Since(start),
|
|
402
499
|
}
|
|
@@ -426,38 +523,123 @@ func (e *DshEngine) Stream(ctx context.Context, req Request, onEvent func(Stream
|
|
|
426
523
|
fmt.Fprintf(stderr, " ⚠ dsh: SDK 通道不可用,本轮回退 headless(拿不到工具调用、正文不流式):%v\n", sdkErr)
|
|
427
524
|
}
|
|
428
525
|
|
|
429
|
-
//
|
|
430
|
-
|
|
526
|
+
// SDK 不可用 → headless(有 --json 就是事件流),无续接。
|
|
527
|
+
return e.streamHeadless(ctx, req, bin, "", headlessPrompt, onEvent, start)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// streamHeadless headless 通道的流式实现:**续接轮**与回退路径共用。
|
|
531
|
+
//
|
|
532
|
+
// 有 --json 时按事件流逐条上报(推理 / 正文 / 工具调用,能力与 SDK 通道同构);
|
|
533
|
+
// 老版本没有 --json 就退回「stderr 推理增量 + stdout 收尾正文」,工具调用拿不到 —— 如实说明。
|
|
534
|
+
func (e *DshEngine) streamHeadless(ctx context.Context, req Request, bin, sessionID, prompt string, onEvent func(StreamEvent), start time.Time) (StreamResult, error) {
|
|
535
|
+
// 同 completeHeadless:headless 没有 --model;续接轮的模型由会话决定,不告警。
|
|
536
|
+
if req.Model != "" && sessionID == "" {
|
|
431
537
|
fmt.Fprintf(stderr, " ⚠ dsh: headless 通道下 --model %s 不生效(无 --model 参数);"+
|
|
432
538
|
"换模型请改 $DSH_HOME/settings.yaml 的 agent-default-model 段,或用 dsh 的 Settings → Models\n", req.Model)
|
|
433
539
|
}
|
|
434
540
|
|
|
435
|
-
|
|
541
|
+
caps := dshHeadlessCapabilities(ctx, bin)
|
|
542
|
+
args := dshHeadlessArgs(e.profile(), sessionID, prompt, caps.JSON)
|
|
436
543
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// 标题行本身不是内容;它出现即进入推理区段。
|
|
441
|
-
if strings.HasPrefix(line, dshReasoningPrefix) {
|
|
442
|
-
inReasoning = true
|
|
443
|
-
return nil
|
|
544
|
+
emit := func(ev StreamEvent) {
|
|
545
|
+
if onEvent != nil {
|
|
546
|
+
onEvent(ev)
|
|
444
547
|
}
|
|
445
|
-
|
|
446
|
-
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
var (
|
|
551
|
+
thinking strings.Builder
|
|
552
|
+
tools []ToolCall
|
|
553
|
+
callIdx = map[string]int{}
|
|
554
|
+
final string // `--json` 的 final 事件(整轮正文),权威
|
|
555
|
+
learnedID string // 会话抬头里的 id(老接口另用:本轮的会话 id)
|
|
556
|
+
streamed bool // 是否已逐条发过正文
|
|
557
|
+
)
|
|
558
|
+
onLine := func(line string) error {
|
|
559
|
+
ev, ok := parseDshJSONEvent(line)
|
|
560
|
+
if !ok {
|
|
561
|
+
return nil // 非事件行(启动日志 / 坏行)忽略
|
|
447
562
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
563
|
+
switch ev.Type {
|
|
564
|
+
case "session":
|
|
565
|
+
// 会话抬头:headless 回的是**同一条会话**的 id,回报给「看历史」持久化。
|
|
566
|
+
learnedID = ev.SessionID
|
|
567
|
+
if learnedID != "" && req.OnSessionID != nil {
|
|
568
|
+
req.OnSessionID(learnedID)
|
|
569
|
+
}
|
|
570
|
+
case "thinking":
|
|
571
|
+
if ev.Text == "" {
|
|
572
|
+
return nil
|
|
573
|
+
}
|
|
574
|
+
thinking.WriteString(ev.Text)
|
|
575
|
+
emit(StreamEvent{Kind: KindThinking, Text: ev.Text})
|
|
576
|
+
case "text":
|
|
577
|
+
if ev.Text == "" {
|
|
578
|
+
return nil
|
|
579
|
+
}
|
|
580
|
+
streamed = true
|
|
581
|
+
emit(StreamEvent{Kind: KindText, Text: ev.Text})
|
|
582
|
+
case "tool_call":
|
|
583
|
+
callArgs := dshJSONArgs(ev.Input)
|
|
584
|
+
emit(StreamEvent{Kind: KindToolUse, Text: callArgs, Name: ev.Tool, ID: ev.CallID})
|
|
585
|
+
callIdx[ev.CallID] = len(tools)
|
|
586
|
+
tools = append(tools, ToolCall{Name: ev.Tool, ID: ev.CallID, Args: callArgs})
|
|
587
|
+
case "tool_result":
|
|
588
|
+
emit(StreamEvent{Kind: KindToolResult, Text: ev.Result, ID: ev.CallID})
|
|
589
|
+
if i, ok := callIdx[ev.CallID]; ok && tools[i].Result == "" {
|
|
590
|
+
tools[i].Result = ev.Result
|
|
591
|
+
}
|
|
592
|
+
case "final":
|
|
593
|
+
// 这里**不发**事件:final 出现在 status(turn_end) 之后(实测顺序
|
|
594
|
+
// status(turn_end) → final),逐条正文已由 text 事件发过;
|
|
595
|
+
// 它留作本轮正文的权威取值。
|
|
596
|
+
final = ev.Text
|
|
452
597
|
}
|
|
453
598
|
return nil
|
|
454
|
-
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
var (
|
|
602
|
+
stdout, stderrText string
|
|
603
|
+
runErr error
|
|
604
|
+
)
|
|
605
|
+
if caps.JSON {
|
|
606
|
+
stdout, stderrText, runErr = runStreamLinesIn(ctx, req.Workspace, dshPermissionEnv(req.Permission), bin, args, true, onLine)
|
|
607
|
+
} else {
|
|
608
|
+
// 老版本:推理增量在 stderr("dsh: reasoning:" 起头),正文在 stdout 收尾时一次给出。
|
|
609
|
+
inReasoning := false
|
|
610
|
+
stdout, stderrText, runErr = runStreamLinesIn(ctx, req.Workspace, dshPermissionEnv(req.Permission), bin, args, false, func(line string) error {
|
|
611
|
+
// 标题行本身不是内容;它出现即进入推理区段。
|
|
612
|
+
if strings.HasPrefix(line, dshReasoningPrefix) {
|
|
613
|
+
inReasoning = true
|
|
614
|
+
return nil
|
|
615
|
+
}
|
|
616
|
+
if !inReasoning {
|
|
617
|
+
return nil // 非推理行(例如启动日志)不当作增量
|
|
618
|
+
}
|
|
619
|
+
thinking.WriteString(line)
|
|
620
|
+
thinking.WriteByte('\n')
|
|
621
|
+
emit(StreamEvent{Kind: KindThinking, Text: line + "\n"})
|
|
622
|
+
return nil
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 正文:final 事件最权威;没有(老版本 / 事件缺失)就用 stdout 全文。
|
|
627
|
+
text := strings.TrimSpace(final)
|
|
628
|
+
if text == "" {
|
|
629
|
+
text = strings.TrimSpace(stdout)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 会话 id:续接轮回原请求的 id;新会话用会话抬头学到的那个(下一轮据此续接)。
|
|
633
|
+
sid := sessionID
|
|
634
|
+
if sid == "" {
|
|
635
|
+
sid = learnedID
|
|
636
|
+
}
|
|
455
637
|
|
|
456
638
|
resp := Response{
|
|
457
639
|
Engine: e.Name(),
|
|
458
|
-
Text:
|
|
459
|
-
Model:
|
|
460
|
-
SessionID:
|
|
640
|
+
Text: text,
|
|
641
|
+
Model: dshHeadlessModel(req, sessionID),
|
|
642
|
+
SessionID: sid,
|
|
461
643
|
Latency: time.Since(start),
|
|
462
644
|
}
|
|
463
645
|
if resp.Model == "" {
|
|
@@ -469,21 +651,26 @@ func (e *DshEngine) Stream(ctx context.Context, req Request, onEvent func(Stream
|
|
|
469
651
|
}
|
|
470
652
|
}
|
|
471
653
|
if runErr != nil {
|
|
472
|
-
return StreamResult{Response: resp, Thinking: thinking.String()},
|
|
654
|
+
return StreamResult{Response: resp, Thinking: thinking.String(), Tools: tools},
|
|
473
655
|
wrapCliError("dsh", stdout, stderrText, runErr)
|
|
474
656
|
}
|
|
475
657
|
if resp.Text == "" {
|
|
476
|
-
return StreamResult{Response: resp, Thinking: thinking.String()},
|
|
658
|
+
return StreamResult{Response: resp, Thinking: thinking.String(), Tools: tools},
|
|
477
659
|
fmt.Errorf("dsh CLI returned empty result")
|
|
478
660
|
}
|
|
479
661
|
|
|
480
|
-
//
|
|
481
|
-
if
|
|
482
|
-
|
|
483
|
-
// 同样补一轮结束事件:回退路径下宿主也该知道「本轮做完了」。
|
|
484
|
-
onEvent(StreamEvent{Kind: KindTurnEnd, Text: resp.Text})
|
|
662
|
+
// 正文兜底:`--json` 没给 text 事件(例如只有 final)时补一条,别让宿主一片空白。
|
|
663
|
+
if !streamed {
|
|
664
|
+
emit(StreamEvent{Kind: KindText, Text: resp.Text})
|
|
485
665
|
}
|
|
486
|
-
|
|
666
|
+
// 收尾一条 turn_end:headless 一次调用就是一轮,宿主据此知道「本轮做完了」。
|
|
667
|
+
// ⚠️ 必须带上了会话 id(与 SDK 通道的 roundDone 一致):客户端**只从 turn_end /
|
|
668
|
+
// turn_failed 学会话锚点**(见 client-ui/shared/conversation.js 的 render),
|
|
669
|
+
// 少了它这条通道就是「客户端拿到正文、却不知道这条是哪段会话」——
|
|
670
|
+
// 下一步追问自然就发成了新会话(真实报障:dsh 会话的追问变成了新任务)。
|
|
671
|
+
// 这条通道承载的正是**续接轮**(-s/-c)与 SDK 不可用时的回退轮,命中面不小。
|
|
672
|
+
emit(StreamEvent{Kind: KindTurnEnd, Text: resp.Text, SessionID: resp.SessionID})
|
|
673
|
+
return StreamResult{Response: resp, Thinking: thinking.String(), Tools: tools}, nil
|
|
487
674
|
}
|
|
488
675
|
|
|
489
676
|
// ── 配置层:DSH_HOME、模型清单与默认模型(尽力而为)────────────────
|
|
@@ -501,22 +688,169 @@ func DshHome() string {
|
|
|
501
688
|
return filepath.Join(home, ".dsh")
|
|
502
689
|
}
|
|
503
690
|
|
|
504
|
-
//
|
|
505
|
-
// ($DSH_HOME/settings.yaml 的 agent-default-model 段)。
|
|
691
|
+
// ── 配置文档:跨 dsh 版本定位 providers / agent-default-model ─────────
|
|
506
692
|
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
693
|
+
// dsh 存「已配置的 provider」的位置随版本变过:
|
|
694
|
+
//
|
|
695
|
+
// ≤ 0.1.6 $DSH_HOME/settings.yaml(顶层段 agent-default-model / llm-pi-ai)
|
|
696
|
+
// ≥ 0.1.7 各 profile 的 $DSH_HOME/profiles/<name>/cordis.patch.yml
|
|
697
|
+
// (settings 体系重构后,providers 由「设置 → 模型」页面写进这份 patch)
|
|
698
|
+
//
|
|
699
|
+
// ⚠️ 0.1.7 首次启动会把旧的 settings.yaml 改名为 settings.yaml.imported
|
|
700
|
+
//(官方 dsh-settings:改名先于首次写入,被组合拒绝的 section 只留在改名后的文件里)。
|
|
701
|
+
// 该文件**已经不是 dsh 读取的配置**,所以这里刻意不把它当来源 —— 一旦采信它,
|
|
702
|
+
// 用户就会在清单里看到运行时其实拒绝的 route(initialize 回
|
|
703
|
+
// `no adapter registered for provider "..."`),正是它要防的误导。
|
|
704
|
+
|
|
705
|
+
// dshConfigDoc 一份 dsh 配置候选:路径 + 是否 profile patch 形态 + 内容。
|
|
706
|
+
type dshConfigDoc struct {
|
|
707
|
+
Path string
|
|
708
|
+
Patch bool // cordis.patch.yml(条目形态,需归一化)
|
|
709
|
+
Text string // 读不到时为空串;仍留在候选列表里,便于说明「找过哪些」
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// legacyText 返回可喂给「settings.yaml 那套解析器」的文本:
|
|
713
|
+
// profile patch 先归一化成顶层键形态,其余原样。
|
|
714
|
+
func (d dshConfigDoc) legacyText() string {
|
|
715
|
+
if d.Patch {
|
|
716
|
+
return dshPatchAsLegacy(d.Text)
|
|
717
|
+
}
|
|
718
|
+
return d.Text
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// dshConfigDocs 按优先级返回 dsh 配置候选文档(含读不到的空壳):
|
|
722
|
+
//
|
|
723
|
+
// ① 生效 profile 的 cordis.patch.yml —— SDK 通道,magic-agent 默认走 sdk
|
|
724
|
+
// (MAGIC_AGENT_DSH_PROFILE 可改;headless 回退通道见 ②)
|
|
725
|
+
// ② headless profile 的 cordis.patch.yml —— SDK 不可用时的回退通道,
|
|
726
|
+
// 列模型时不能漏(两条通道 magic-agent 都会用到)
|
|
727
|
+
// ③ $DSH_HOME/settings.yaml —— 0.1.7 之前的位置,兼容未升级的装机
|
|
728
|
+
func dshConfigDocs() []dshConfigDoc {
|
|
511
729
|
home := DshHome()
|
|
512
730
|
if home == "" {
|
|
513
|
-
return
|
|
731
|
+
return nil
|
|
732
|
+
}
|
|
733
|
+
names := []string{dshProfileName()}
|
|
734
|
+
if names[0] != dshDefaultProfile {
|
|
735
|
+
names = append(names, dshDefaultProfile)
|
|
514
736
|
}
|
|
515
|
-
|
|
737
|
+
docs := make([]dshConfigDoc, 0, len(names)+1)
|
|
738
|
+
for _, name := range names {
|
|
739
|
+
p := filepath.Join(home, "profiles", name, "cordis.patch.yml")
|
|
740
|
+
docs = append(docs, dshConfigDoc{Path: p, Patch: true, Text: readFileOrEmpty(p)})
|
|
741
|
+
}
|
|
742
|
+
p := filepath.Join(home, "settings.yaml")
|
|
743
|
+
return append(docs, dshConfigDoc{Path: p, Text: readFileOrEmpty(p)})
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// dshProfileName 生效 profile 名:MAGIC_AGENT_DSH_PROFILE → 默认 sdk。
|
|
747
|
+
//
|
|
748
|
+
// 与 DshEngine.effectiveProfile 同规则;这里是包级函数(配置层读取没有引擎实例),
|
|
749
|
+
// 故只看环境变量 —— 与 DshConfiguredModel / DshConfiguredRoute 一贯的行为一致。
|
|
750
|
+
func dshProfileName() string {
|
|
751
|
+
if p := strings.TrimSpace(os.Getenv(DshProfileEnv)); p != "" {
|
|
752
|
+
return p
|
|
753
|
+
}
|
|
754
|
+
return dshSDKProfile
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// readFileOrEmpty 读文件;读不到(不存在 / 无权限)返回空串 ——
|
|
758
|
+
// 候选文件缺失是正常状态,不是错误。
|
|
759
|
+
func readFileOrEmpty(path string) string {
|
|
760
|
+
data, err := os.ReadFile(path)
|
|
516
761
|
if err != nil {
|
|
517
762
|
return ""
|
|
518
763
|
}
|
|
519
|
-
return
|
|
764
|
+
return string(data)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// dshPatchAsLegacy 把 profile patch(cordis.patch.yml)的**条目形态**改写成
|
|
768
|
+
// settings.yaml 的**顶层键形态**,好让同一套轻量解析器同时吃两种文档。
|
|
769
|
+
//
|
|
770
|
+
// 规则一句话:条目 id 变成顶层键,`config:` 之下的内容整体上提一层。
|
|
771
|
+
// 例如 `- id: llm-pi-ai` + `config:` + `providers:` 变成顶层 `llm-pi-ai:` + `providers:`;
|
|
772
|
+
// `- id: agent-default-model` + `config: {provider, model}` 变成顶层 `agent-default-model:` 段。
|
|
773
|
+
//
|
|
774
|
+
// 只搬运 config: 之下的内容(同层的 disabled: 之类与旧形态无关,不搬);
|
|
775
|
+
// 没有 config 的条目跳过。归一化是**纯文本变换**,故解析器与它们的单测都不用改。
|
|
776
|
+
func dshPatchAsLegacy(text string) string {
|
|
777
|
+
if text == "" {
|
|
778
|
+
return ""
|
|
779
|
+
}
|
|
780
|
+
lines := strings.Split(text, "\n")
|
|
781
|
+
// 顶层条目 = 行首无缩进的 "- id: xxx";缩进更深的 "- id: xxx"
|
|
782
|
+
// 是条目内部(如 models 列表)的项,不能当边界。
|
|
783
|
+
entryStart := func(ln string) bool {
|
|
784
|
+
return ln == strings.TrimSpace(ln) && strings.HasPrefix(ln, "- id:")
|
|
785
|
+
}
|
|
786
|
+
var out strings.Builder
|
|
787
|
+
for i := 0; i < len(lines); {
|
|
788
|
+
if !entryStart(strings.TrimLeft(lines[i], " \t")) {
|
|
789
|
+
i++
|
|
790
|
+
continue
|
|
791
|
+
}
|
|
792
|
+
id := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[i]), "- id:"))
|
|
793
|
+
j := i + 1
|
|
794
|
+
for j < len(lines) && !entryStart(lines[j]) {
|
|
795
|
+
j++
|
|
796
|
+
}
|
|
797
|
+
block := lines[i+1 : j]
|
|
798
|
+
i = j
|
|
799
|
+
if id == "" {
|
|
800
|
+
continue
|
|
801
|
+
}
|
|
802
|
+
// 找 config:(取块内第一处),其后缩进更深的行即内容。
|
|
803
|
+
cfgK, cfgIndent := -1, 0
|
|
804
|
+
for k, ln := range block {
|
|
805
|
+
if strings.TrimSpace(stripYAMLComment(ln)) != "config:" {
|
|
806
|
+
continue
|
|
807
|
+
}
|
|
808
|
+
cfgK = k
|
|
809
|
+
cfgIndent = len(ln) - len(strings.TrimLeft(ln, " \t"))
|
|
810
|
+
break
|
|
811
|
+
}
|
|
812
|
+
if cfgK < 0 {
|
|
813
|
+
continue
|
|
814
|
+
}
|
|
815
|
+
var body []string
|
|
816
|
+
minIndent := -1
|
|
817
|
+
for _, ln := range block[cfgK+1:] {
|
|
818
|
+
if strings.TrimSpace(ln) == "" {
|
|
819
|
+
continue
|
|
820
|
+
}
|
|
821
|
+
indent := len(ln) - len(strings.TrimLeft(ln, " \t"))
|
|
822
|
+
if indent <= cfgIndent {
|
|
823
|
+
break
|
|
824
|
+
}
|
|
825
|
+
if minIndent < 0 || indent < minIndent {
|
|
826
|
+
minIndent = indent
|
|
827
|
+
}
|
|
828
|
+
body = append(body, ln)
|
|
829
|
+
}
|
|
830
|
+
if len(body) == 0 {
|
|
831
|
+
continue
|
|
832
|
+
}
|
|
833
|
+
out.WriteString(id + ":\n")
|
|
834
|
+
for _, ln := range body {
|
|
835
|
+
out.WriteString(" " + ln[minIndent:] + "\n")
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return out.String()
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// DshConfiguredModel 尽力而为地报出 dsh 当前配置的默认模型
|
|
842
|
+
// (agent-default-model 段;位置随版本变,见 dshConfigDocs)。
|
|
843
|
+
//
|
|
844
|
+
// 为什么需要它:headless 不回模型名(stdout 只有正文),而调用方(如观物台)
|
|
845
|
+
// 通常要展示「这一轮是谁答的」。读不到就返回空串(调用方回落 "config-default")——
|
|
846
|
+
// 与 TraeDefaultModel 的「文件缺失 / 格式变化返回空串」同一约定,绝不因此阻断调用。
|
|
847
|
+
func DshConfiguredModel() string {
|
|
848
|
+
for _, d := range dshConfigDocs() {
|
|
849
|
+
if m := parseDshDefaultModel(d.legacyText()); m != "" {
|
|
850
|
+
return m
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return ""
|
|
520
854
|
}
|
|
521
855
|
|
|
522
856
|
// parseDshDefaultModel 轻量扫描 settings.yaml(不引入 yaml 依赖),取
|