magic-agent 1.0.3 → 1.0.49

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.
Files changed (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -31,12 +31,14 @@ package agent
31
31
  // → 与 trae/claude 同路,用子进程 cwd 落地(WorkspaceSupportOf = "cwd")。
32
32
  // SDK 通道同理:initialize 的 cwd 就是「每个 SDK 建的会话 header 上记的目录」,
33
33
  // 同时也仍用子进程 cwd 兜底(工具执行的相对路径跟着它走)。
34
- // - 权限:新会话默认 workspace-write 预设(写入限制在工作区 + 平台临时目录),
35
- // read-only 为另一预设,进程回退模式由环境变量 DSH_PERMISSION_MODE 决定。
36
- // **没有命令行级 approval flag**,且这两个预设无法表达本项目四档模型里的
37
- // 「沙箱关闭 / 无审批」(第 4 档)→ 四档模型在 dsh 上**不接线**
38
- // (PermissionSupportOf = "none"),显式传 --permission 会在 CLI 层 exit 2
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
- // (headless 每次全新会话;SDK 的 sessionId 是「未知 id 惰性建会话」,
53
- // 已存在的 id 会被 -32603 `session already exists` 拒掉,协议也没有
54
- // resume/load 方法 → 对「每次调用都是新进程」的 magic-agent 一样续不上)
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 未接线(none)→ 显式传则 CLI 层 exit 2
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 无原生输入通道 → 绝对路径写进提示词(与 trae/openclaw 同路)
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
- // 取 10 分钟与 trae/openclaw 对齐。CLI 侧没有 --timeout,超时完全由外层
108
- // ctx 施加(超时会杀掉 dsh 及其 node worker 的整个进程组)。
109
- const DefaultDshTimeout = 10 * time.Minute
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:返回 `$DSH_HOME/settings.yaml` 里**已配置**的模型。
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`:官方明确「settings.yaml 才是决定一条路由
167
- // 服务什么的东西」—— 网关列出的上百个模型并不都能用,只有 `models:` 里声明过的才会被
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
- data, err := os.ReadFile(filepath.Join(home, "settings.yaml"))
182
- if err != nil {
183
- return nil, fmt.Errorf("%w: 读不到 %s(模型由该文件的 agent-default-model / llm-pi-ai 段决定): %v",
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
- models := parseDshModelRoutes(string(data))
187
- if len(models) == 0 {
188
- return nil, fmt.Errorf("%w: %s 里没有已配置的模型(在 llm-pi-ai.providers.<id>.models 下列出,并设 agent-default-model)",
189
- ErrNoModelSource, filepath.Join(home, "settings.yaml"))
200
+ tried := "(没有候选文件)"
201
+ if len(paths) > 0 {
202
+ tried = strings.Join(paths, "、")
190
203
  }
191
- return models, nil
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
- if req.SessionID != "" || req.Continue {
238
- return Response{}, errDshNoSessionResume
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
- prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
245
- if prompt == "" {
249
+ basePrompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
250
+ if basePrompt == "" {
246
251
  return Response{}, fmt.Errorf("dsh: empty prompt")
247
252
  }
248
- // 附件:dsh 没有附件输入参数,只能把绝对路径写进提示词
249
- //(与 trae/openclaw 同一兜底;需要 dsh 侧的读文件工具能看到它)。
250
- prompt = appendAttachmentSection(prompt, req.Attachments)
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, prompt, nil)
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
- // 走到这里就是要用 headless 执行了 —— 它没有 --model 参数,模型由配置层决定。
287
- // -m 在 headless 上不是透传是「无作用」:告警一次(与 arkclaw 一致),让用户知道
288
- // 该去改 dsh 自己的配置。显式 Profile: "headless" 也走这里,同样告警。
289
- if req.Model != "" {
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.buildArgs(req, prompt)
355
+ args := dshHeadlessArgs(e.profile(), sessionID, prompt, false)
295
356
 
296
357
  // workspace:dsh 的「调用时所在目录即默认 workspace 根」正是子进程 cwd 的语义。
297
- stdout, stderrText, runErr := runCLIIn(ctx, req.Workspace, bin, args...)
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 就是最终助手正文(推理增量在 stderr,不混入)。
363
+ // headless 的 stdout 就是最终助手正文(推理增量走 stderr,不混入)。
302
364
  Text: strings.TrimSpace(stdout),
303
365
  // 模型名尽力而为:读 dsh 配置里的默认模型(读不到给 "config-default")。
304
- Model: DshConfiguredModel(),
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
- // buildArgs 构造 dsh 回退路径(headless / 自定义 profile)的参数。
389
+ // dshHeadlessModel headless 轮次对外报的模型标签。
328
390
  //
329
- // 形态(启动器 flags 在前,app 参数在后):
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
- // dsh --profile headless "<prompt>"
408
+ // ["--profile", <profile>] [+ "--session-id", <id>] [+ "--json"] + ["<prompt>"]
332
409
  //
333
- // prompt 走**位置参数**(headless 唯一的 app 参数,无 --prompt / stdin 通道)。
334
- // 代价是超长 prompt 会撞 ARG_MAX —— 与 trae 的 -p 同一个已知边界,
335
- // 需要喂超长文本时请先落成文件、让 dsh 用自己的读文件工具看(配合 -a 路径兜底同理)。
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
- // ⚠️ 只有回退路径用得到它:SDK 通道的 prompt 走 session/prompt 的 contentBlocks
415
+ // ⚠️ 只有 headless 用得到它:SDK 通道的 prompt 走 session/prompt 的 contentBlocks
338
416
  // (见 dsh_sdk.go),既不占命令行也不受 ARG_MAX 限制。
339
- func (e *DshEngine) buildArgs(req Request, prompt string) []string {
340
- // 统一参数矩阵:MaxTokens / Temperature 在 headless 上无对应 flag(静默忽略);
341
- // Tools 由 dsh 自己的工具循环决定(忽略);Timeout 由外层 ctx 施加(忽略)。
342
- _ = req.MaxTokens
343
- _ = req.Temperature
344
- _ = req.Tools
345
- _ = req.Timeout
346
- return []string{"--profile", e.profile(), prompt}
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 通道(默认,见 dsh_sdk.go)
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 回退(实测 2026-09-21,dsh 0.1.5-rc.2)
362
- // stderr:`dsh: reasoning:` 标题行之后,推理增量**逐段到达**(约 300ms 一批)
363
- // stdout:最终助手正文在 turn 结束时**一次性**到达
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
- // ⚠️ 这条路上**没有任何工具调用通道**(官方 headless 的定位就是「stream reasoning
448
+ // ⚠️ 这条路上**没有任何工具调用通道**(官方早期 headless 的定位就是「stream reasoning
366
449
  // to stderr, print the final assistant message, and exit」)—— 不是解析缺陷。
367
450
 
368
- // dshReasoningPrefix stderr 上推理增量的起始标记(官方 headless 的固定前缀)。
451
+ // dshReasoningPrefix stderr 上推理增量的起始标记(老版 headless 的固定前缀)。
369
452
  const dshReasoningPrefix = "dsh: reasoning:"
370
453
 
371
- // Stream 实现 Streamer:默认走 SDK(推理/正文/工具调用全流式),
372
- // SDK 不可用时回退 headless(只有推理流式 + 收尾正文)。
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
- prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
385
- if prompt == "" {
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
- prompt = appendAttachmentSection(prompt, req.Attachments)
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, prompt, onEvent)
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 查找)。注意它**不能**当 -s 用 ——
399
- // 跨进程续接拿不到(见 errDshNoSessionResume)。
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
- // 同 Complete:headless 通道没有 --model 参数,-m 在这里是「无作用」,告警一次。
430
- if req.Model != "" {
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
- args := e.buildArgs(req, prompt)
541
+ caps := dshHeadlessCapabilities(ctx, bin)
542
+ args := dshHeadlessArgs(e.profile(), sessionID, prompt, caps.JSON)
436
543
 
437
- var thinking strings.Builder
438
- inReasoning := false
439
- stdout, stderrText, runErr := runStreamStderrIn(ctx, req.Workspace, bin, args, func(line string) error {
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
- if !inReasoning {
446
- return nil // 非推理行(例如启动日志)不当作增量
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
- thinking.WriteString(line)
449
- thinking.WriteByte('\n')
450
- if onEvent != nil {
451
- onEvent(StreamEvent{Kind: KindThinking, Text: line + "\n"})
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: strings.TrimSpace(stdout),
459
- Model: DshConfiguredModel(),
460
- SessionID: "", // dsh 不回可续接的会话 id(见 errDshNoSessionResume)
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
- // 正文一次性给出(headless 不流式正文,见本节顶部说明)。
481
- if onEvent != nil {
482
- onEvent(StreamEvent{Kind: KindText, Text: resp.Text})
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
- return StreamResult{Response: resp, Thinking: thinking.String()}, nil
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
- // DshConfiguredModel 尽力而为地报出 dsh 当前配置的默认模型
505
- // ($DSH_HOME/settings.yaml 的 agent-default-model 段)。
691
+ // ── 配置文档:跨 dsh 版本定位 providers / agent-default-model ─────────
506
692
  //
507
- // 为什么需要它:headless 不回模型名(stdout 只有正文),而调用方(如观物台)
508
- // 通常要展示「这一轮是谁答的」。读不到就返回空串(调用方回落 "config-default")——
509
- // 与 TraeDefaultModel 的「文件缺失 / 格式变化返回空串」同一约定,绝不因此阻断调用。
510
- func DshConfiguredModel() string {
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
- data, err := os.ReadFile(filepath.Join(home, "settings.yaml"))
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 parseDshDefaultModel(string(data))
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 依赖),取