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.
- 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 +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- 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 +69 -1
- 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/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 +260 -33
- 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/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- 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 +101 -10
- package/npm/bin/magic-client.js +118 -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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// dsh_resume.go - dsh 的会话续接:**真续接**(headless `--session-id`)+ `--json` 事件解析。
|
|
4
|
+
//
|
|
5
|
+
// 结论先行(2026-10-09 实测,dsh 0.1.7-rc.2 与 0.2.0-rc.2 都如此):
|
|
6
|
+
//
|
|
7
|
+
// dsh 的会话由 **dsh 自己**持久化在 $DSH_HOME/sessions/<cwd-key>/<sessionId>/;
|
|
8
|
+
// headless profile 提供 `--session-id <id>`(官方 help 原文:
|
|
9
|
+
// "adopt the persisted Session with this id; an unknown id is an error")
|
|
10
|
+
// → **跨进程续接是支持的**,未知 id 会明确报错、exit 1,绝不静默开新会话。
|
|
11
|
+
//
|
|
12
|
+
// 但只有 headless 通道支持它:SDK 通道(initialize / session/prompt / shutdown)
|
|
13
|
+
// 没有 resume 方法。所以本引擎的分工是:
|
|
14
|
+
//
|
|
15
|
+
// 续接轮(-s <id> / -c) → headless + --session-id(上下文由 dsh 自己接回来,真续接)
|
|
16
|
+
// 普通轮 → 仍走 SDK 通道(默认:逐 step 流式 + 工具事件)
|
|
17
|
+
//
|
|
18
|
+
// 实测验收:拿 magic-agent 之前**经 SDK 通道**建出的会话 id 直接跑
|
|
19
|
+
//
|
|
20
|
+
// dsh --profile headless --session-id magic-agent-… "我让你记的数是多少?"
|
|
21
|
+
//
|
|
22
|
+
// 答出了上一轮让它记住的数字,且 `--json` 里的 turn 计数接着往下走
|
|
23
|
+
// (同一条会话,不是副本)—— 说明两条通道共用同一份持久化会话。
|
|
24
|
+
//
|
|
25
|
+
// ⚠️ 会话按 **cwd 分桶**:$DSH_HOME/sessions 下的目录名就是工作区路径的转义。
|
|
26
|
+
// 换工作区就找不到那条会话,所以续接**必须**与上一轮在同一个 workspace 发起 —— 与
|
|
27
|
+
// magic-agent 的 -w 语义一致。找不到时 dsh 会报 `session "…" does not exist`,照实透出。
|
|
28
|
+
//
|
|
29
|
+
// ⚠️ 曾经的「回放式续接」(把 magic-agent 的会话日志前置进 prompt)**已删除**:
|
|
30
|
+
// 那是「以为 dsh 不能按 id 续接」时的兜底,既然真续接可用,就不该再用假续接。
|
|
31
|
+
|
|
32
|
+
import (
|
|
33
|
+
"context"
|
|
34
|
+
"encoding/json"
|
|
35
|
+
"fmt"
|
|
36
|
+
"os"
|
|
37
|
+
"path/filepath"
|
|
38
|
+
"strings"
|
|
39
|
+
"sync"
|
|
40
|
+
"time"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// ── 续接目标:-s / -c → 会话 id ────────────────────────────────
|
|
44
|
+
|
|
45
|
+
// dshCanonicalWorkspace 把工作区路径规范化成 dsh 自己会看到的那一份。
|
|
46
|
+
//
|
|
47
|
+
// 为什么必须做:dsh 记会话时用的是**我们交给它的 cwd 字符串**(SDK 通道的
|
|
48
|
+
// initialize.cwd),而续接时 headless 拿自己的 getcwd 去**逐字比对**,只要字符串不同就
|
|
49
|
+
// 拒接 —— macOS 实测:`-w /tmp` 时会话被记成 "/tmp",续接进程的 getcwd 是
|
|
50
|
+
// "/private/tmp",于是报
|
|
51
|
+
//
|
|
52
|
+
// dsh: session "…" was recorded in "/tmp", not "/private/tmp"
|
|
53
|
+
//
|
|
54
|
+
// 两条通道都先用**解析掉符号链接**的路径,记录值与被比对值才是同一份。
|
|
55
|
+
// 解析失败(路径不存在等)就用原值 —— 不做额外猜测。
|
|
56
|
+
func dshCanonicalWorkspace(dir string) string {
|
|
57
|
+
dir = strings.TrimSpace(dir)
|
|
58
|
+
if dir == "" {
|
|
59
|
+
return ""
|
|
60
|
+
}
|
|
61
|
+
if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != "" {
|
|
62
|
+
return resolved
|
|
63
|
+
}
|
|
64
|
+
return dir
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// dshResumeSessionID 本轮要续接的会话 id(空 = 新会话)。
|
|
68
|
+
//
|
|
69
|
+
// -s/--session <id> 原样交给 dsh(存在性由 dsh 自己校验,未知 id 明确报错)
|
|
70
|
+
// -c/--continue 从 magic-agent 的会话登记表里挑**同一 workspace** 下最近一条
|
|
71
|
+
// dsh 会话。为什么不读 dsh 自己的会话目录:那个目录名是 cwd 的
|
|
72
|
+
// 内部转义(`/private/tmp` → `--private-tmp--`),解析它是猜实现;
|
|
73
|
+
// 登记表里 engine / session_id / workspace / 时间都是明文字段。
|
|
74
|
+
func dshResumeSessionID(req Request) string {
|
|
75
|
+
if id := strings.TrimSpace(req.SessionID); id != "" {
|
|
76
|
+
return id
|
|
77
|
+
}
|
|
78
|
+
if !req.Continue {
|
|
79
|
+
return ""
|
|
80
|
+
}
|
|
81
|
+
return dshLatestSessionID(strings.TrimSpace(req.Workspace))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// dshLatestSessionID 最近一条 dsh 会话的 session_id(可限定 workspace)。
|
|
85
|
+
func dshLatestSessionID(workspace string) string {
|
|
86
|
+
dir := dshSessionDir()
|
|
87
|
+
if dir == "" {
|
|
88
|
+
return ""
|
|
89
|
+
}
|
|
90
|
+
entries, err := os.ReadDir(dir)
|
|
91
|
+
if err != nil {
|
|
92
|
+
return ""
|
|
93
|
+
}
|
|
94
|
+
var (
|
|
95
|
+
bestID string
|
|
96
|
+
bestAt time.Time
|
|
97
|
+
fallback string // 没有同 workspace 记录时的兜底(同为 dsh 的最近一条)
|
|
98
|
+
fallbackAt time.Time
|
|
99
|
+
)
|
|
100
|
+
for _, e := range entries {
|
|
101
|
+
name := e.Name()
|
|
102
|
+
if e.IsDir() || !strings.HasPrefix(name, "run-") || !strings.HasSuffix(name, ".json") {
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
data, err := os.ReadFile(filepath.Join(dir, name))
|
|
106
|
+
if err != nil {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
var rec struct {
|
|
110
|
+
SessionID string `json:"session_id"`
|
|
111
|
+
Engine string `json:"engine"`
|
|
112
|
+
Workspace string `json:"workspace"`
|
|
113
|
+
UpdatedAt time.Time `json:"updated_at"`
|
|
114
|
+
}
|
|
115
|
+
if json.Unmarshal(data, &rec) != nil {
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if rec.Engine != "dsh" || strings.TrimSpace(rec.SessionID) == "" {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if fallback == "" || rec.UpdatedAt.After(fallbackAt) {
|
|
122
|
+
fallback, fallbackAt = rec.SessionID, rec.UpdatedAt
|
|
123
|
+
}
|
|
124
|
+
if workspace != "" && strings.TrimSpace(rec.Workspace) != workspace {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if bestID == "" || rec.UpdatedAt.After(bestAt) {
|
|
128
|
+
bestID, bestAt = rec.SessionID, rec.UpdatedAt
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if bestID != "" {
|
|
132
|
+
return bestID
|
|
133
|
+
}
|
|
134
|
+
return fallback
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// dshSessionsEnv 覆盖会话目录的环境变量(与 session.EnvDir 同名同义)。
|
|
138
|
+
const dshSessionsEnv = "MAGIC_AGENT_SESSIONS"
|
|
139
|
+
|
|
140
|
+
// dshSessionDir 会话日志 / 登记表目录(口径同 session.Dir())。
|
|
141
|
+
//
|
|
142
|
+
// ⚠️ 不直接调 internal/session:那个包**反向依赖** internal/agent
|
|
143
|
+
// (session.go 用了 agent 的类型),agent 再 import 它就成了环。
|
|
144
|
+
// 路径口径必须与 session.Dir() 保持一致,改一处要改另一处。
|
|
145
|
+
func dshSessionDir() string {
|
|
146
|
+
if p := strings.TrimSpace(os.Getenv(dshSessionsEnv)); p != "" {
|
|
147
|
+
return p
|
|
148
|
+
}
|
|
149
|
+
home, err := os.UserHomeDir()
|
|
150
|
+
if err != nil || home == "" {
|
|
151
|
+
return ""
|
|
152
|
+
}
|
|
153
|
+
return filepath.Join(home, ".magic-agent", "sessions")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── 能力探测:这台 dsh 的 headless 认不认 --session-id / --json ──
|
|
157
|
+
|
|
158
|
+
// dshHeadlessCaps headless 支持的两个 flag。
|
|
159
|
+
type dshHeadlessCaps struct {
|
|
160
|
+
SessionID bool // --session-id(0.1.7+ 才有;没有就当不了真续接)
|
|
161
|
+
JSON bool // --json(事件流;没有就走旧版 stderr 推理增量)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
var dshCapsCache sync.Map // bin → dshHeadlessCaps
|
|
165
|
+
|
|
166
|
+
// dshHeadlessCapabilities 探测这台 dsh 支持哪些 headless flag(按 bin 路径缓存)。
|
|
167
|
+
//
|
|
168
|
+
// 为什么必须探测:dsh 启动器在**第一个它不认识的 token 处结束**解析,剩下的都当
|
|
169
|
+
// app 参数交给 headless —— 老版本不认这俩 flag 时,它们会被拼进**任务文本**
|
|
170
|
+
// (静默改写提示词),比直接报错难查得多。探测代价约一次 `--help`,一进程一次。
|
|
171
|
+
//
|
|
172
|
+
// ⚠️ 只有在输出**确实是 headless 的 help**(同时含 "Usage:" 与 "--profile")时才据它判断;
|
|
173
|
+
// 否则(--help 跑不起来、被包装脚本吞掉、输出被截断)一律按「都支持」处理 ——
|
|
174
|
+
// 「探测不到」不等于「不支持」,宁可让现代 dsh 正常工作,也不因一次探测抖动把续接判死。
|
|
175
|
+
func dshHeadlessCapabilities(ctx context.Context, bin string) dshHeadlessCaps {
|
|
176
|
+
if v, ok := dshCapsCache.Load(bin); ok {
|
|
177
|
+
return v.(dshHeadlessCaps)
|
|
178
|
+
}
|
|
179
|
+
caps := dshHeadlessCaps{SessionID: true, JSON: true}
|
|
180
|
+
if stdout, stderr, err := runCLI(ctx, bin, "--profile", "headless", "--help"); err == nil {
|
|
181
|
+
help := stdout + stderr
|
|
182
|
+
if strings.Contains(help, "Usage:") && strings.Contains(help, "--profile") {
|
|
183
|
+
caps.SessionID = strings.Contains(help, "--session-id")
|
|
184
|
+
caps.JSON = strings.Contains(help, "--json")
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
dshCapsCache.Store(bin, caps)
|
|
188
|
+
return caps
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── `--json` 事件流(dsh-headless 的 run 投影)────────────────────
|
|
192
|
+
|
|
193
|
+
// dshJSONEvent `--json` 事件流的一行。
|
|
194
|
+
//
|
|
195
|
+
// 字段与 @deepseek-ai/dsh-headless 的 json-stream 投影逐一对齐(2026-10-09 读源码 +
|
|
196
|
+
// 实测样本核对),只声明本实现用到的部分:
|
|
197
|
+
//
|
|
198
|
+
// {"type":"session","sessionId":…,"cwd":…} 会话抬头(id 就在这)
|
|
199
|
+
// {"type":"status","phase":"turn_start|step_start|step_end|turn_end","turn":N,…}
|
|
200
|
+
// {"type":"thinking","text":…} 推理块(已提交)
|
|
201
|
+
// {"type":"text","text":…} 正文块(已提交,按 step)
|
|
202
|
+
// {"type":"tool_call","callId":…,"tool":…,"input":…} 工具调用(input 是**解析后**的 JSON)
|
|
203
|
+
// {"type":"tool_result","callId":…,"status":…,"result":…} 工具结果
|
|
204
|
+
// {"type":"final","text":…} 收尾正文(整轮汇总)
|
|
205
|
+
//
|
|
206
|
+
// 文本与推理都来自**已提交的 assistant/message**,不会是随后被重试/丢弃的尝试内容。
|
|
207
|
+
type dshJSONEvent struct {
|
|
208
|
+
Type string `json:"type"`
|
|
209
|
+
Text string `json:"text"`
|
|
210
|
+
Phase string `json:"phase"`
|
|
211
|
+
Turn int `json:"turn"`
|
|
212
|
+
Step int `json:"step"`
|
|
213
|
+
SessionID string `json:"sessionId"`
|
|
214
|
+
Cwd string `json:"cwd"`
|
|
215
|
+
CallID string `json:"callId"`
|
|
216
|
+
Tool string `json:"tool"`
|
|
217
|
+
Input json.RawMessage `json:"input"`
|
|
218
|
+
Status string `json:"status"`
|
|
219
|
+
Result string `json:"result"`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// parseDshJSONEvent 解析一行事件;空行 / 非 JSON / 认不出(无 type)都不算事件。
|
|
223
|
+
func parseDshJSONEvent(line string) (dshJSONEvent, bool) {
|
|
224
|
+
line = strings.TrimSpace(line)
|
|
225
|
+
if line == "" || line[0] != '{' {
|
|
226
|
+
return dshJSONEvent{}, false
|
|
227
|
+
}
|
|
228
|
+
var ev dshJSONEvent
|
|
229
|
+
if json.Unmarshal([]byte(line), &ev) != nil || ev.Type == "" {
|
|
230
|
+
return dshJSONEvent{}, false
|
|
231
|
+
}
|
|
232
|
+
return ev, true
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// dshJSONArgs 把 tool_call 的 `input` 变成可放进 StreamEvent.Text 的字符串。
|
|
236
|
+
//
|
|
237
|
+
// dsh 给的是**解析后的** JSON(对象或原始字符串),而 SDK 通道给的是原始字符串 ——
|
|
238
|
+
// 两条通道最终都要「一段文本」,所以这里:JSON 字符串取内容,其余保留其 JSON 文本。
|
|
239
|
+
func dshJSONArgs(raw json.RawMessage) string {
|
|
240
|
+
s := strings.TrimSpace(string(raw))
|
|
241
|
+
if s == "" || s == "null" {
|
|
242
|
+
return ""
|
|
243
|
+
}
|
|
244
|
+
if s[0] == '"' {
|
|
245
|
+
var unquoted string
|
|
246
|
+
if json.Unmarshal(raw, &unquoted) == nil {
|
|
247
|
+
return unquoted
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return s
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// dshResumeUnsupportedHint 给「这台 dsh 不支持 --session-id」时的可执行结论。
|
|
254
|
+
func dshResumeUnsupportedHint(bin string) string {
|
|
255
|
+
return fmt.Sprintf("dsh: 这台 dsh 的 headless 不认 --session-id,无法按 id 续接(%s)。"+
|
|
256
|
+
"升级 dsh(`npm i -g @deepseek-ai/dsh`)后重试;需要多轮上下文也可以改用常驻会话:"+
|
|
257
|
+
"`magic-agent -e dsh --stream --keep-alive \"首轮\"` 再 `magic-agent --append <run_id|session_id> -p \"追加\"`", bin)
|
|
258
|
+
}
|
|
@@ -61,7 +61,6 @@ import (
|
|
|
61
61
|
"fmt"
|
|
62
62
|
"io"
|
|
63
63
|
"os"
|
|
64
|
-
"path/filepath"
|
|
65
64
|
"strings"
|
|
66
65
|
"sync"
|
|
67
66
|
"time"
|
|
@@ -176,9 +175,43 @@ type dshSDKTextBlock struct {
|
|
|
176
175
|
Text string `json:"text"`
|
|
177
176
|
}
|
|
178
177
|
|
|
178
|
+
// dshSDKImageBlock = SdkEncodedImageBlock:prompt 里内联的图片(官方 d.ts 具名结构)。
|
|
179
|
+
//
|
|
180
|
+
// mimeType 只接受下面四种(协议原文),故调用方先按 MIME 过滤(见 dshNativeImageMIME):
|
|
181
|
+
// 图片以 base64 inline 进运行时自己的附件存储,模型真的能看到,不再依赖读文件工具。
|
|
182
|
+
type dshSDKImageBlock struct {
|
|
183
|
+
Type string `json:"type"` // 恒为 "image"
|
|
184
|
+
Data string `json:"data"` // 规范 base64 编码的位图字节
|
|
185
|
+
MimeType string `json:"mimeType"` // image/png | image/jpeg | image/webp | image/gif
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// dshSDKPromptParams = SessionPromptParams。contentBlocks 允许混排
|
|
189
|
+
// 普通 ContentBlock(text 等)与待收纳的内联图片块 → 用 []any 承载。
|
|
179
190
|
type dshSDKPromptParams struct {
|
|
180
|
-
SessionID string
|
|
181
|
-
ContentBlocks []
|
|
191
|
+
SessionID string `json:"sessionId"`
|
|
192
|
+
ContentBlocks []any `json:"contentBlocks"`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// dshPromptBlocks 组装一条 prompt 的 contentBlocks:正文 text 块 + 图片附件的原生 image 块。
|
|
196
|
+
//
|
|
197
|
+
// 为什么图片不再走「路径写进提示词」:能读图的模型(如 deepseek-v4.1-flash)配好模态后,
|
|
198
|
+
// SDK 的 `session/prompt` 直接接受 SdkEncodedImageBlock,图片以 base64 inline 进模型上下文
|
|
199
|
+
// —— 真识图。非图片附件(PDF / bmp / 文本…)协议里没有内联类型,仍由调用方写进提示词。
|
|
200
|
+
//
|
|
201
|
+
// 读盘失败返回错误(附件是调用方点名要发的东西,静默丢掉比失败更糟)。
|
|
202
|
+
func dshPromptBlocks(text string, atts []Attachment) ([]any, error) {
|
|
203
|
+
blocks := []any{dshSDKTextBlock{Type: "text", Text: text}}
|
|
204
|
+
for _, a := range atts {
|
|
205
|
+
if !dshNativeImageMIME(a.MIME) {
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
data, err := readAttachmentBase64(a)
|
|
209
|
+
if err != nil {
|
|
210
|
+
return nil, err
|
|
211
|
+
}
|
|
212
|
+
blocks = append(blocks, dshSDKImageBlock{Type: "image", Data: data, MimeType: a.MIME})
|
|
213
|
+
}
|
|
214
|
+
return blocks, nil
|
|
182
215
|
}
|
|
183
216
|
|
|
184
217
|
// dshSDKSessionEvent = SessionEventNotification。
|
|
@@ -259,6 +292,10 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
|
|
|
259
292
|
}
|
|
260
293
|
|
|
261
294
|
cmd := newStreamCmdIn(workspace, bin, []string{"--profile", dshSDKProfile})
|
|
295
|
+
/* 权限档位:dsh 没有逐调用的权限 flag,档位走子进程环境变量 —— shipped 的 sdk
|
|
296
|
+
profile 里 sandbox-policy.mode 与 user-approval.policy 都直接读它(见
|
|
297
|
+
permission.go::dshPermissionMode 的核实来源)。空档位 = 不注入,用 dsh 自己的默认。 */
|
|
298
|
+
cmd.Env = append(cmd.Env, dshPermissionEnv(req.Permission)...)
|
|
262
299
|
|
|
263
300
|
/* 三条管道都自建(不直挂 bytes.Buffer / StdinPipe 的 stdout 侧):
|
|
264
301
|
沿用 runcmd.go / stream.go 的同一条经验 —— Go 只在目标是 *os.File 时才不做内部
|
|
@@ -406,6 +443,13 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
|
|
|
406
443
|
// agent on it」),但 SDK 协议只有 initialize / session/prompt / shutdown 三个方法,
|
|
407
444
|
// 没有暴露它 —— 只有 Web/TUI 那条 host API 用得上 resume。
|
|
408
445
|
sessionID := newDshSessionID()
|
|
446
|
+
// 会话 id 由 magic-agent 生成 → **立刻**回报给调用方(「看历史」持久化靠它)。
|
|
447
|
+
// ⚠️ 必须在 session/prompt 之前:core CLI 的 sessionWriter 拿到 id 才会开文件,
|
|
448
|
+
// 报晚了本轮前几条事件就没有落点(历史里会缺开头)。dsh 是唯一一个
|
|
449
|
+
// id 由我们自造、而不是从 CLI 流里读出来的引擎(见 engine.go 的 OnSessionID)。
|
|
450
|
+
if req.OnSessionID != nil {
|
|
451
|
+
req.OnSessionID(sessionID)
|
|
452
|
+
}
|
|
409
453
|
out := dshSDKTurnResult{Provider: provider, Model: model, SessionID: sessionID}
|
|
410
454
|
callIndex := map[string]int{}
|
|
411
455
|
promptIDs := map[int]bool{} // 已发出的 prompt 请求 id(用来把它的错误响应挑出来)
|
|
@@ -423,10 +467,10 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
|
|
|
423
467
|
onEvent(ev)
|
|
424
468
|
}
|
|
425
469
|
}
|
|
426
|
-
sendPrompt := func(
|
|
470
|
+
sendPrompt := func(blocks []any) error {
|
|
427
471
|
id, serr := send("session/prompt", dshSDKPromptParams{
|
|
428
472
|
SessionID: sessionID,
|
|
429
|
-
ContentBlocks:
|
|
473
|
+
ContentBlocks: blocks,
|
|
430
474
|
})
|
|
431
475
|
if serr != nil {
|
|
432
476
|
return serr
|
|
@@ -436,6 +480,10 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
|
|
|
436
480
|
roundEnded = false
|
|
437
481
|
return nil
|
|
438
482
|
}
|
|
483
|
+
// textBlocks 单文本块(追加轮用:附件只随首轮发)。
|
|
484
|
+
textBlocks := func(text string) []any {
|
|
485
|
+
return []any{dshSDKTextBlock{Type: "text", Text: text}}
|
|
486
|
+
}
|
|
439
487
|
// finish 收尾:先让读线程转入「只读不投递」,再发 shutdown(协议唯一的「结束」
|
|
440
488
|
// 手段,没有 cancel 方法)。幂等 —— turn/end、session.status:idle、追加通道关闭
|
|
441
489
|
// 三条收尾路径都可能走到。
|
|
@@ -466,7 +514,12 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
|
|
|
466
514
|
}
|
|
467
515
|
return dshSDKTurnResult{}, false, nil
|
|
468
516
|
}
|
|
469
|
-
|
|
517
|
+
// 首轮带附件:图片走协议原生 image 块(非图片附件已由调用方写进 prompt 正文)。
|
|
518
|
+
initialBlocks, berr := dshPromptBlocks(prompt, req.Attachments)
|
|
519
|
+
if berr != nil {
|
|
520
|
+
return dshSDKTurnResult{}, fmt.Errorf("dsh sdk: 读附件: %v", berr)
|
|
521
|
+
}
|
|
522
|
+
if err := sendPrompt(initialBlocks); err != nil {
|
|
470
523
|
return dshSDKTurnResult{}, fmt.Errorf("dsh sdk: 写 session/prompt: %v", err)
|
|
471
524
|
}
|
|
472
525
|
|
|
@@ -494,7 +547,7 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
|
|
|
494
547
|
if strings.TrimSpace(text) == "" {
|
|
495
548
|
continue
|
|
496
549
|
}
|
|
497
|
-
if err := sendPrompt(text); err != nil {
|
|
550
|
+
if err := sendPrompt(textBlocks(text)); err != nil {
|
|
498
551
|
return out, fmt.Errorf("dsh sdk: 写 session/prompt(追加): %v", err)
|
|
499
552
|
}
|
|
500
553
|
case msg, ok := <-inbound:
|
|
@@ -656,18 +709,16 @@ func dshStderrTail(s string) string {
|
|
|
656
709
|
return ": " + strings.TrimSpace(lines[len(lines)-1])
|
|
657
710
|
}
|
|
658
711
|
|
|
659
|
-
// DshConfiguredRoute 读
|
|
660
|
-
//
|
|
712
|
+
// DshConfiguredRoute 读 dsh 配置里 agent-default-model 的 (provider, model)
|
|
713
|
+
// (位置随版本变:0.1.7+ 在 profile 的 cordis.patch.yml,更早在 settings.yaml,
|
|
714
|
+
// 见 dshConfigDocs)。SDK 的 initialize 两者都是必填;读不到就只能回退 headless。
|
|
661
715
|
func DshConfiguredRoute() (string, string) {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
data, err := os.ReadFile(filepath.Join(home, "settings.yaml"))
|
|
667
|
-
if err != nil {
|
|
668
|
-
return "", ""
|
|
716
|
+
for _, d := range dshConfigDocs() {
|
|
717
|
+
if p, m := parseDshDefaultModelRoute(d.legacyText()); p != "" || m != "" {
|
|
718
|
+
return p, m
|
|
719
|
+
}
|
|
669
720
|
}
|
|
670
|
-
return
|
|
721
|
+
return "", ""
|
|
671
722
|
}
|
|
672
723
|
|
|
673
724
|
// newDshSessionID 生成 SDK 侧会话 id。
|
|
@@ -19,6 +19,8 @@ package agent
|
|
|
19
19
|
|
|
20
20
|
import (
|
|
21
21
|
"context"
|
|
22
|
+
"encoding/base64"
|
|
23
|
+
"encoding/json"
|
|
22
24
|
"errors"
|
|
23
25
|
"os"
|
|
24
26
|
"path/filepath"
|
|
@@ -137,9 +139,9 @@ func TestDshSDKIsDefaultTransport(t *testing.T) {
|
|
|
137
139
|
if resp.Model != "modelverse/deepseek-v4.1-flash" {
|
|
138
140
|
t.Errorf("Model = %q want modelverse/deepseek-v4.1-flash", resp.Model)
|
|
139
141
|
}
|
|
140
|
-
//
|
|
142
|
+
// Complete 只回正文,不回会话 id(Stream 才回,见下一条用例)。
|
|
141
143
|
if resp.SessionID != "" {
|
|
142
|
-
t.Errorf("SessionID = %q want
|
|
144
|
+
t.Errorf("SessionID = %q want 空", resp.SessionID)
|
|
143
145
|
}
|
|
144
146
|
args := readFileString(t, argsLog)
|
|
145
147
|
if !strings.Contains(args, "--profile sdk") {
|
|
@@ -150,6 +152,26 @@ func TestDshSDKIsDefaultTransport(t *testing.T) {
|
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
// 会话 id 由 magic-agent 自造 → 必须回报给调用方(「看历史」持久化靠它;
|
|
156
|
+
// 早先 dsh 从不调 OnSessionID,导致 dsh 会话根本不落盘)。
|
|
157
|
+
func TestDshSDKReportsSessionID(t *testing.T) {
|
|
158
|
+
dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
|
|
159
|
+
dir := t.TempDir()
|
|
160
|
+
bin, _, _ := dshSDKStub(t, dir, dshSDKStubOpts{})
|
|
161
|
+
|
|
162
|
+
var got []string
|
|
163
|
+
_, err := (&DshEngine{BinPath: bin}).Stream(context.Background(), Request{
|
|
164
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
165
|
+
OnSessionID: func(id string) { got = append(got, id) },
|
|
166
|
+
}, nil)
|
|
167
|
+
if err != nil {
|
|
168
|
+
t.Fatalf("Stream: %v", err)
|
|
169
|
+
}
|
|
170
|
+
if len(got) == 0 || !strings.HasPrefix(got[0], "magic-agent-") {
|
|
171
|
+
t.Errorf("OnSessionID 应收到自造的会话 id, got %v", got)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
153
175
|
// 流式:推理 / 工具调用 / 工具结果 / 正文 / turn 收尾,全部按顺序上报。
|
|
154
176
|
func TestDshSDKStreamEmitsAllEventKinds(t *testing.T) {
|
|
155
177
|
dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
|
|
@@ -706,6 +728,84 @@ func TestDshSDKAppendSkipsBlank(t *testing.T) {
|
|
|
706
728
|
}
|
|
707
729
|
}
|
|
708
730
|
|
|
731
|
+
// 图片附件走协议原生 image 块(SdkEncodedImageBlock):base64 inline 进
|
|
732
|
+
// session/prompt 的 contentBlocks(真进模型上下文);非图片附件仍写进提示词正文
|
|
733
|
+
// (协议里没有通用文件内联类型,只能走路径,靠 dsh 自己的读文件工具看)。
|
|
734
|
+
func TestDshSDKImageAttachmentIsNativeBlock(t *testing.T) {
|
|
735
|
+
dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
|
|
736
|
+
dir := t.TempDir()
|
|
737
|
+
bin, _, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
|
|
738
|
+
|
|
739
|
+
img := writeTestPNG(t, "shot.png")
|
|
740
|
+
txt := writeTestFile(t, "notes.txt", "文件里的说明")
|
|
741
|
+
|
|
742
|
+
if _, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
|
|
743
|
+
Messages: []Message{{Role: "user", Content: "这张图什么颜色"}},
|
|
744
|
+
Attachments: []Attachment{NewAttachment(img), NewAttachment(txt)},
|
|
745
|
+
}); err != nil {
|
|
746
|
+
t.Fatalf("Complete: %v", err)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
type block struct {
|
|
750
|
+
Type string `json:"type"`
|
|
751
|
+
Text string `json:"text"`
|
|
752
|
+
Data string `json:"data"`
|
|
753
|
+
MimeType string `json:"mimeType"`
|
|
754
|
+
}
|
|
755
|
+
type frame struct {
|
|
756
|
+
Method string `json:"method"`
|
|
757
|
+
Params struct {
|
|
758
|
+
ContentBlocks []block `json:"contentBlocks"`
|
|
759
|
+
} `json:"params"`
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
var blocks []block
|
|
763
|
+
var sawPrompt bool
|
|
764
|
+
for _, ln := range strings.Split(readFileString(t, stdinLog), "\n") {
|
|
765
|
+
var f frame
|
|
766
|
+
if err := json.Unmarshal([]byte(ln), &f); err != nil {
|
|
767
|
+
continue
|
|
768
|
+
}
|
|
769
|
+
if f.Method == "session/prompt" {
|
|
770
|
+
blocks = append(blocks, f.Params.ContentBlocks...)
|
|
771
|
+
sawPrompt = true
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if !sawPrompt {
|
|
775
|
+
t.Fatal("没有发出 session/prompt 请求")
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
var textBlock string
|
|
779
|
+
var images []string
|
|
780
|
+
for _, b := range blocks {
|
|
781
|
+
switch b.Type {
|
|
782
|
+
case "text":
|
|
783
|
+
textBlock = b.Text
|
|
784
|
+
case "image":
|
|
785
|
+
if b.MimeType != "image/png" {
|
|
786
|
+
t.Errorf("image 块 mimeType = %q want image/png", b.MimeType)
|
|
787
|
+
}
|
|
788
|
+
images = append(images, b.Data)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if len(images) != 1 {
|
|
792
|
+
t.Fatalf("image 块数 = %d want 1(只有那张 png 走原生)", len(images))
|
|
793
|
+
}
|
|
794
|
+
want, err := os.ReadFile(img)
|
|
795
|
+
if err != nil {
|
|
796
|
+
t.Fatal(err)
|
|
797
|
+
}
|
|
798
|
+
if images[0] != base64.StdEncoding.EncodeToString(want) {
|
|
799
|
+
t.Errorf("image 块 data 应是图片字节的 base64")
|
|
800
|
+
}
|
|
801
|
+
if strings.Contains(textBlock, "shot.png") {
|
|
802
|
+
t.Errorf("图片走原生块后不该再以路径写进正文: %q", textBlock)
|
|
803
|
+
}
|
|
804
|
+
if !strings.Contains(textBlock, "notes.txt") {
|
|
805
|
+
t.Errorf("非图片附件应写进提示词路径清单: %q", textBlock)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
709
809
|
// ── 小工具 ────────────────────────────────────────────────────
|
|
710
810
|
|
|
711
811
|
func readFileString(t *testing.T, p string) string {
|