magic-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,543 @@
1
+ package agent
2
+
3
+ // openclaw.go - OpenClaw CLI 引擎。
4
+ //
5
+ // 后端:openclaw(OpenClaw 2026.6.11,e085fa1,本机 /opt/homebrew/bin/openclaw)。
6
+ //
7
+ // 推荐集成模式:`--local` 嵌入式(embedded agent,无需 Gateway RPC,
8
+ // 调用方负责把 provider API key 装进 shell 环境;本包继承父进程 env 即满足)。
9
+ //
10
+ // 非交互命令:
11
+ //
12
+ // openclaw agent --local --agent <id> --message <text> --json --timeout <sec>
13
+ // openclaw agent --local --agent <id> --message-file <path> --json --timeout <sec>
14
+ // openclaw agent --local --agent <id> --message <text> --session-id <id> --json
15
+ // openclaw agent --local --agent <id> --message <text> --model <provider/model> --json
16
+ //
17
+ // 响应 envelope(顶层,不嵌套在 data 字段里):
18
+ //
19
+ // {
20
+ // "payloads": [{"text": "<助手正文>", "mediaUrl": null}],
21
+ // "meta": {
22
+ // "durationMs": 2658,
23
+ // "agentMeta": {
24
+ // "sessionId": "<uuid>",
25
+ // "provider": "papergames",
26
+ // "model": "deepseek-v4-pro",
27
+ // "usage": {"input":2974,"output":4,"cacheRead":49152,"total":52130},
28
+ // "lastCallUsage": {...}
29
+ // },
30
+ // "aborted": false
31
+ // }
32
+ // }
33
+ //
34
+ // 注意:CLI 在 envelope 之前会输出一堆 plugin 日志到 stdout("openviking: ..." 之类),
35
+ // 这些不是 NDJSON,混入 envelope。 解析策略:先在 stdout 全文里扫描 `{` 开头的
36
+ // 第一个 balance 平衡的 JSON 对象,再 unmarshal —— 与 firstJSONObject 同源。
37
+ //
38
+ // 续接:
39
+ // - SessionID 非空 → `agent --session-id <id>` 直接续接
40
+ // - Continue 真 → `openclaw sessions --json --limit 1` 拉最近一次会话的 sessionId
41
+ // 再当 SessionID 传
42
+ // - 新会话 → 拿 Response.SessionID 留给下一轮
43
+ //
44
+ // SystemPrompt 注入:openclaw agent 没有 --system-prompt flag;把 system prompt
45
+ // 拼到 message 头(与 trae 相同路径)。
46
+ //
47
+ // MaxTokens / Temperature / JSONSchema:
48
+ // - MaxTokens / Temperature: openclaw agent 无对应 flag,静默忽略(矩阵 ②)
49
+ // - JSONSchema: 走和 llm 引擎一样的"输出后处理抽 JSON"路径(extractJSONObjectStrict)
50
+ //
51
+ // Tools 模式:openclaw 是"内嵌 agent"(自带工具循环),没有逐工具白名单/禁用能力。
52
+ // 整体忽略 Request.Tools 字段,由 openclaw 自身决定是否调用工具。
53
+ //
54
+ // CLI 路径解析顺序:显式 BinPath → MAGIC_AGENT_OPENCLAW_BIN → 常见安装位 → PATH
55
+ // (探测链统一收敛在 engine_base.go 的 cliBase)。
56
+ //
57
+ // 流式(Stream)走**另一条通道**:`openclaw acp`(ACP server over stdio,背后接本地 Gateway),
58
+ // 能拿到逐字正文增量与 tool_call/tool_call_update(思考流协议侧不支持)。
59
+ // 见 openclaw_acp.go —— 那里还把「桥不可用时回退到本文件的 Complete(内嵌一次性调用)」
60
+ // 一并收口,所以两条路可以共存:嵌录取正文,ACP 取增量。
61
+
62
+ import (
63
+ "bufio"
64
+ "context"
65
+ "encoding/json"
66
+ "fmt"
67
+ "os"
68
+ "path/filepath"
69
+ "strings"
70
+ "time"
71
+ )
72
+
73
+ /* isOpenClawAuthFailure 判「这是不是一条凭据类失败」(2026-09-23 加)。
74
+ *
75
+ * 为什么需要它:`--local` 嵌入式路径的 provider key **取自 shell 环境变量**
76
+ * (openclaw 官方 help 原文:Run the embedded agent locally (requires model provider
77
+ * API keys in your shell)),**openclaw.json 的 `models.providers.*.apiKey` 在这条路上不被采用**。
78
+ * 于是「key 只配在 openclaw.json、环境里没有对应变量」时必然 401 —— 实测本机:
79
+ * `openclaw agent --local --agent main --json --model minimax/MiniMax-M3` → exit 1
80
+ * + `401 invalid api key (2049)`;同一把 key 直连 api.minimaxi.com 是 200,
81
+ * 走 Gateway 路径(去掉 --local)也正常出正文。
82
+ * 判据只认「凭据类」的特征词 —— 别把超时 / 目录 / 会话接不上那类失败也拖进来重试
83
+ * (那些换条路也一样失败,白多一次往返)。
84
+ * ⚠️ 不用正则:`401` 单独出现时可能是 token 计数等无关数字,所以要求它带上下文
85
+ * (`http 401` / `(401)` / `401 invalid`)或直接命中那几句固定文案。 */
86
+ func isOpenClawAuthFailure(stdout, stderr string) bool {
87
+ s := strings.ToLower(stderr + "\n" + stdout)
88
+ for _, marker := range []string{
89
+ "invalid api key",
90
+ "authentication failed",
91
+ "re-authenticate",
92
+ "unauthorized",
93
+ "http 401",
94
+ "(401)",
95
+ "401 invalid",
96
+ "(2049)", // MiniMax 的「key 无效」错误码
97
+ } {
98
+ if strings.Contains(s, marker) {
99
+ return true
100
+ }
101
+ }
102
+ return false
103
+ }
104
+
105
+ // OpenClawEngine 通过 OpenClaw CLI --local 模式实现 Engine。
106
+ type OpenClawEngine struct {
107
+ // BinPath 显式指定 CLI 路径(空 = 自动探测)。测试注入用。
108
+ BinPath string
109
+
110
+ // Agent 显式 agent id(默认 "main")。Request 不承载 agent 字段,
111
+ // 引擎构造时注入为集群级默认值。
112
+ Agent string
113
+
114
+ // Model 显式模型(与 Request.Model 效果一致;引擎构造时显式覆盖优先)。
115
+ Model string
116
+ }
117
+
118
+ // Name 实现 Engine。
119
+ func (e *OpenClawEngine) Name() string { return "openclaw" }
120
+
121
+ // DefaultOpenClawTimeout 单次尝试默认超时(与 openclaw 自身 --timeout 默认 600s 对齐)。
122
+ const DefaultOpenClawTimeout = 10 * time.Minute
123
+
124
+ // bin 探测 openclaw CLI 路径(委托 cliBase 统一探测链)。
125
+ func (e *OpenClawEngine) bin() string {
126
+ return openclawBase.resolve(e.BinPath)
127
+ }
128
+
129
+ // Detect 实现 Engine。
130
+ func (e *OpenClawEngine) Detect() (bool, string) {
131
+ p := openclawBase.resolve(e.BinPath)
132
+ if p == "" {
133
+ return false, openclawBase.notFound
134
+ }
135
+ return true, p
136
+ }
137
+
138
+ // openclawAgentMeta 对应 meta.agentMeta(顶层 envelope 的 meta 嵌套)。
139
+ type openclawAgentMeta struct {
140
+ SessionID string `json:"sessionId"`
141
+ Provider string `json:"provider"`
142
+ Model string `json:"model"`
143
+ Usage openclawUsage `json:"usage"`
144
+ LastCallUsage openclawUsage `json:"lastCallUsage"`
145
+ }
146
+
147
+ // openclawUsage 对应 agentMeta.usage / lastCallUsage。
148
+ type openclawUsage struct {
149
+ Input int `json:"input"`
150
+ Output int `json:"output"`
151
+ CacheRead int `json:"cacheRead"`
152
+ CacheWrite int `json:"cacheWrite"`
153
+ Total int `json:"total"`
154
+ }
155
+
156
+ // openclawEnvelope 对应 --json 顶层响应。
157
+ type openclawEnvelope struct {
158
+ Payloads []struct {
159
+ Text string `json:"text"`
160
+ MediaURL *string `json:"mediaUrl"`
161
+ } `json:"payloads"`
162
+ Meta struct {
163
+ DurationMs int `json:"durationMs"`
164
+ AgentMeta openclawAgentMeta `json:"agentMeta"`
165
+ Aborted bool `json:"aborted"`
166
+ } `json:"meta"`
167
+ /* ⚠️ **Gateway 路径多包一层 `result`**(2026-09-23 补):两条路的 envelope 形状不同 ——
168
+ · `--local`(嵌入式):`{payloads:[…], meta:{agentMeta:{…}}}`
169
+ · Gateway(不带 --local):`{runId, status, summary, result:{payloads:[…], meta:{…}}}`
170
+ 实测(本机 openclaw 2026.6.11):Gateway 那次 stdout 近 50KB 的 pretty JSON,
171
+ 正文在 `result.payloads[0].text`,而老解析器只认顶层 `payloads` → 明明拿到了正文,
172
+ 却报「未在 stdout 中找到有效 JSON envelope」。回退 Gateway 就是靠这条才真正跑通。 */
173
+ Result *struct {
174
+ Payloads []struct {
175
+ Text string `json:"text"`
176
+ MediaURL *string `json:"mediaUrl"`
177
+ } `json:"payloads"`
178
+ Meta struct {
179
+ DurationMs int `json:"durationMs"`
180
+ AgentMeta openclawAgentMeta `json:"agentMeta"`
181
+ Aborted bool `json:"aborted"`
182
+ } `json:"meta"`
183
+ } `json:"result"`
184
+ // Gateway 独有的外层字段(失败判定用:status=ok 才是成功)
185
+ Status string `json:"status"`
186
+ Summary string `json:"summary"`
187
+ }
188
+
189
+ // unwrap 把两种形状归一成「顶层 payloads/meta」:Gateway 的 result 层被提上来。
190
+ // ⚠️ 只在顶层真的空(payloads 为空)时才下沉到 result —— 万一将来某个版本两层都有,
191
+ // 顶层那份才是 `--local` 的原生形状,优先它。
192
+ func (e openclawEnvelope) unwrap() openclawEnvelope {
193
+ if len(e.Payloads) > 0 || e.Result == nil {
194
+ return e
195
+ }
196
+ out := openclawEnvelope{Status: e.Status, Summary: e.Summary}
197
+ out.Payloads = e.Result.Payloads
198
+ out.Meta = e.Result.Meta
199
+ return out
200
+ }
201
+
202
+ // openclawSession 对应 openclaw sessions --json 单条会话。
203
+ type openclawSession struct {
204
+ SessionID string `json:"sessionId"`
205
+ Key string `json:"key"`
206
+ Model string `json:"model"`
207
+ UpdatedAt int64 `json:"updatedAt"`
208
+ }
209
+
210
+ // openclawSessionsEnvelope 对应 openclaw sessions --json 顶层。
211
+ type openclawSessionsEnvelope struct {
212
+ Sessions []openclawSession `json:"sessions"`
213
+ }
214
+
215
+ // ListModels 实现 ModelLister:`openclaw models list --json` 列出本机
216
+ // openclaw 当前配置可用的模型,取 key("<provider>/<model>",
217
+ // 即 --model 接受的形态)。
218
+ func (e *OpenClawEngine) ListModels(ctx context.Context) ([]string, error) {
219
+ stdout, err := probeCLI(ctx, "openclaw", e.bin(), "models", "list", "--json")
220
+ if err != nil {
221
+ return nil, err
222
+ }
223
+ objs, err := jsonModelsArray(stdout)
224
+ if err != nil {
225
+ return nil, err
226
+ }
227
+ models := modelNamesFromObjects(objs, "key", "name")
228
+ if len(models) == 0 {
229
+ return nil, fmt.Errorf("%w: openclaw models list returned no entries", ErrNoModelSource)
230
+ }
231
+ return models, nil
232
+ }
233
+
234
+ // Complete 实现 Engine:单次调用 openclaw agent。
235
+ //
236
+ // 执行流程:
237
+ // 1. 解析续接 id(SessionID 或 Continue → sessions 查最新)
238
+ // 2. 构造参数(openclaw agent --local --agent <id> ... --json --timeout <sec>)
239
+ // 3. runCLI 拉 stdout/stderr,stdout 含 plugin 日志 + 末尾 JSON envelope
240
+ // 4. firstJSONObject 抽取 envelope → unmarshal → 提取 payloads[0].text / agentMeta
241
+ // 5. JSONSchema 模式下再做严格抽取替换 Text
242
+ func (e *OpenClawEngine) Complete(ctx context.Context, req Request) (Response, error) {
243
+ start := time.Now()
244
+ bin := e.bin()
245
+ if bin == "" {
246
+ return Response{}, fmt.Errorf("openclaw CLI not found; set MAGIC_AGENT_OPENCLAW_BIN")
247
+ }
248
+
249
+ // 续接解析:SessionID 优先;否则 Continue → sessions --limit 1 查最新。
250
+ sessionID := req.SessionID
251
+ if sessionID == "" && req.Continue {
252
+ id, lerr := e.lookupLatestSessionID(ctx, bin)
253
+ if lerr != nil {
254
+ return Response{}, fmt.Errorf("openclaw: continue 解析最近会话失败: %w", lerr)
255
+ }
256
+ if id == "" {
257
+ return Response{}, fmt.Errorf("openclaw: continue 模式未找到任何已存在会话")
258
+ }
259
+ sessionID = id
260
+ }
261
+
262
+ // 把 system prompt 拼到 message 头(openclaw agent 无 --system-prompt flag)
263
+ prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
264
+ if prompt == "" {
265
+ return Response{}, fmt.Errorf("openclaw: empty prompt")
266
+ }
267
+ // 附件:openclaw 无 per-call 附件参数,只能把绝对路径写进提示词
268
+ //(与 trae 同一兜底;需要 openclaw 侧有读文件能力)。
269
+ prompt = appendAttachmentSection(prompt, req.Attachments)
270
+
271
+ // prompt 较长时走 --message-file 临时文件,避免命令行长度越界
272
+ tmpDir, terr := os.MkdirTemp("", "magic-agent-openclaw-")
273
+ if terr != nil {
274
+ return Response{}, fmt.Errorf("openclaw: mktemp: %w", terr)
275
+ }
276
+ defer os.RemoveAll(tmpDir)
277
+ msgArgs, cleanup, err := writeMessageArg(tmpDir, prompt)
278
+ if err != nil {
279
+ return Response{}, fmt.Errorf("openclaw: write message: %w", err)
280
+ }
281
+ if cleanup != nil {
282
+ defer cleanup()
283
+ }
284
+
285
+ args := e.buildArgs(req, sessionID, msgArgs)
286
+
287
+ // workspace:openclaw 无 per-call 工作目录 flag(workspace 由 openclaw agents 绑定),
288
+ // 子进程 cwd 是唯一可用的方式
289
+ stdout, stderr, runErr := runCLIIn(ctx, req.Workspace, bin, args...)
290
+
291
+ /* ⚠️ 嵌入式凭据失败 → 回退 Gateway 路径(2026-09-23 修,见 isOpenClawAuthFailure)。
292
+ *
293
+ * 为什么要这条兜底:`--local` 的 provider key 只从 **shell 环境变量**取,openclaw.json 里
294
+ * 配好的 key 在这条路上用不上 → 「模型只配在 openclaw.json」的用户必然 401,
295
+ * 而 Gateway 路径(去掉 `--local`)会正常读配置里的 key。两条路的差异是上游事实,
296
+ * 不是本项目的选择,所以**换一条能跑通的路**才是对用户有用的动作。
297
+ * 代价与边界(刻意收窄):
298
+ * · 只在**凭据类失败**上重试(`isOpenClawAuthFailure`)—— 超时 / 会话接不上换条路也一样失败;
299
+ * · **只重试一次**,且 Gateway 也失败时**保留原始失败**(只往 stderr 补一句说明)——
300
+ * 不能让「Gateway 没起」把原本清楚的 401 覆盖成一句连接错误;
301
+ * · 走这条路时 stderr 留一行痕迹(不静默换路)。 */
302
+ if runErr != nil && isOpenClawAuthFailure(stdout, stderr) {
303
+ fmt.Fprintln(os.Stderr, "magic-agent: openclaw 嵌入式路径(--local)认证失败"+
304
+ "(该路径的 provider key 取自 shell 环境变量,openclaw.json 里配的用不上)→ 改用 Gateway 路径重试一次")
305
+ gwOut, gwErr, gwRunErr := runCLIIn(ctx, req.Workspace, bin, e.buildArgsMode(req, sessionID, msgArgs, false)...)
306
+ if gwRunErr == nil {
307
+ stdout, stderr, runErr = gwOut, gwErr, nil
308
+ } else {
309
+ stderr = strings.TrimRight(stderr, "\n") + "\n(magic-agent: 已改走 Gateway 路径,同样失败:" +
310
+ lastNonEmptyLine(gwErr) + ")"
311
+ }
312
+ }
313
+
314
+ // 即便非零退出也尝试解析 envelope(plugin 错误后仍可能输出有效 JSON)。
315
+ resp, perr := e.parseStdout(stdout)
316
+ if resp.Latency == 0 {
317
+ resp.Latency = time.Since(start)
318
+ }
319
+ resp.Engine = e.Name()
320
+ // SessionID: 优先 envelope.agentMeta.sessionId;为空时回传请求里的 id
321
+ // (Continue 路径我们已经查到了 sessionId,Envelope 通常也会回;以 envelope 为准)
322
+ if resp.SessionID == "" {
323
+ resp.SessionID = sessionID
324
+ }
325
+ // JSONSchema 后处理
326
+ if req.JSONSchema != nil {
327
+ if m, ok := extractJSONObjectStrict(resp.Text, req.JSONSchema); ok {
328
+ if b, jerr := json.Marshal(m); jerr == nil {
329
+ resp.Text = string(b)
330
+ }
331
+ }
332
+ }
333
+ if runErr != nil {
334
+ return resp, wrapCliError("openclaw", stdout, stderr, runErr)
335
+ }
336
+ if perr != nil {
337
+ return resp, perr
338
+ }
339
+ if strings.TrimSpace(resp.Text) == "" {
340
+ return resp, fmt.Errorf("openclaw CLI returned empty result")
341
+ }
342
+ return resp, nil
343
+ }
344
+
345
+ // parseStdout 从 stdout(可能含 plugin 日志)中抽出顶层 JSON envelope 并解析。
346
+ // 找不到 envelope 时返回 ("", false)。
347
+ // ⚠️ 两种形状都认(见 openclawEnvelope.unwrap):`--local` 顶层 payloads、Gateway 的 result 层。
348
+ func (e *OpenClawEngine) parseStdout(stdout string) (Response, error) {
349
+ env := openclawEnvelope{}
350
+ // 方案 A:整段就是 JSON(短输出)
351
+ if err := json.Unmarshal([]byte(stdout), &env); err == nil && len(env.unwrap().Payloads) > 0 {
352
+ return envelopeToResponse(env), nil
353
+ }
354
+ // 方案 B:stdout 含 plugin 日志,逐行扫描
355
+ sc := bufio.NewScanner(strings.NewReader(stdout))
356
+ sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
357
+ var collected strings.Builder
358
+ /* Gateway 明确宣告失败时(status != ok)把它的 summary 带出去 —— 否则调用方只会看到
359
+ 「未找到有效 JSON envelope」这种与真原因无关的话(用户报障的观感就是这么来的)。 */
360
+ lastStatus, lastSummary := "", ""
361
+ // 我们不知道 JSON 跨多少行,先按整段重试
362
+ for sc.Scan() {
363
+ collected.WriteString(sc.Text())
364
+ collected.WriteByte('\n')
365
+ obj, ok := firstJSONObject(collected.String())
366
+ if !ok {
367
+ continue
368
+ }
369
+ // 拿到第一个顶层 JSON 对象后再 unmarshal(map → json → envelope)
370
+ b, _ := json.Marshal(obj)
371
+ var env2 openclawEnvelope
372
+ if err := json.Unmarshal(b, &env2); err == nil {
373
+ if env2.Status != "" {
374
+ lastStatus, lastSummary = env2.Status, env2.Summary
375
+ }
376
+ if len(env2.unwrap().Payloads) > 0 {
377
+ return envelopeToResponse(env2), nil
378
+ }
379
+ }
380
+ }
381
+ if lastStatus != "" && lastStatus != "ok" {
382
+ detail := strings.TrimSpace(lastSummary)
383
+ if detail != "" {
384
+ detail = "(" + detail + ")"
385
+ }
386
+ return Response{}, fmt.Errorf("openclaw Gateway 本轮未成功:status=%s%s", lastStatus, detail)
387
+ }
388
+ return Response{}, fmt.Errorf("openclaw: 未在 stdout 中找到有效 JSON envelope")
389
+ }
390
+
391
+ // envelopeToResponse 从 envelope 构造 Response(两种形状都先归一到顶层 payloads/meta)。
392
+ func envelopeToResponse(env openclawEnvelope) Response {
393
+ env = env.unwrap()
394
+ text := ""
395
+ if len(env.Payloads) > 0 {
396
+ text = env.Payloads[0].Text
397
+ }
398
+ meta := env.Meta.AgentMeta
399
+ return Response{
400
+ Text: text,
401
+ Model: meta.Model,
402
+ SessionID: meta.SessionID,
403
+ InputTokens: meta.Usage.Input + meta.Usage.CacheRead,
404
+ OutputTokens: meta.Usage.Output,
405
+ TotalTokens: meta.Usage.Total,
406
+ }
407
+ }
408
+
409
+ // lookupLatestSessionID 调用 `openclaw sessions --json --limit 1` 解析最近一条会话 id。
410
+ // 失败/无会话时返回 ("", err)。
411
+ func (e *OpenClawEngine) lookupLatestSessionID(ctx context.Context, bin string) (string, error) {
412
+ agent := e.agentID()
413
+ args := []string{"sessions", "--json", "--limit", "1"}
414
+ if agent != "" {
415
+ args = append(args, "--agent", agent)
416
+ }
417
+ stdout, _, err := runCLI(ctx, bin, args...)
418
+ if err != nil {
419
+ return "", err
420
+ }
421
+ var env openclawSessionsEnvelope
422
+ if jerr := json.Unmarshal([]byte(stdout), &env); jerr == nil {
423
+ if len(env.Sessions) > 0 && env.Sessions[0].SessionID != "" {
424
+ return env.Sessions[0].SessionID, nil
425
+ }
426
+ }
427
+ // 兜底:同样走 firstJSONObject 抗 log 混入
428
+ obj, ok := firstJSONObject(stdout)
429
+ if !ok {
430
+ return "", fmt.Errorf("openclaw sessions: 未找到 JSON envelope")
431
+ }
432
+ if jerr := json.Unmarshal(mustMarshal(obj), &env); jerr == nil && len(env.Sessions) > 0 {
433
+ return env.Sessions[0].SessionID, nil
434
+ }
435
+ return "", nil
436
+ }
437
+
438
+ // mustMarshal 是 json.Marshal 的零值兜底(marshal map[string]any 不会失败)。
439
+ func mustMarshal(v any) []byte {
440
+ b, _ := json.Marshal(v)
441
+ return b
442
+ }
443
+
444
+ // lastNonEmptyLine 取多行文本的最后一条非空行(stderr 摘要用)。
445
+ //
446
+ // ⚠️ 为什么是**最后**一条而不是第一条:CLI 的 stderr 前面常是环境噪声
447
+ // (`[state-migrations] Legacy state migration warnings:`、plugin 加载日志…),
448
+ // 真原因几乎总在末尾。与本仓 ask-envelope.cjs 的 `reasonFromStderr` 同一约定 ——
449
+ // 两处都取尾行,同一次失败才不会在 CLI 与界面里写成两句不同的话。
450
+ func lastNonEmptyLine(s string) string {
451
+ lines := strings.Split(s, "\n")
452
+ for i := len(lines) - 1; i >= 0; i-- {
453
+ if t := strings.TrimSpace(lines[i]); t != "" {
454
+ return t
455
+ }
456
+ }
457
+ return ""
458
+ }
459
+
460
+ // agentID 返回构造时显式注入的 agent id;为空则用 "main" 作默认。
461
+ func (e *OpenClawEngine) agentID() string {
462
+ if e.Agent != "" {
463
+ return e.Agent
464
+ }
465
+ return "main"
466
+ }
467
+
468
+ // buildArgs 构造 `openclaw agent --local ...` 参数(嵌入式路径 = 默认路径)。
469
+ //
470
+ // 形态:
471
+ //
472
+ // openclaw agent --local --agent <id> --json
473
+ // [--model <provider/model>] [--session-id <id>]
474
+ // [--message <text> | --message-file <path>]
475
+ // [--timeout <sec>]
476
+ //
477
+ // ⚠️ 认证失败时由 Complete 改走 buildArgsMode(..., local=false)(Gateway 路径)——
478
+ // 那条路会读 openclaw.json 里配的 provider key,见 isOpenClawAuthFailure。
479
+ func (e *OpenClawEngine) buildArgs(req Request, sessionID string, msgArgs []string) []string {
480
+ return e.buildArgsMode(req, sessionID, msgArgs, true)
481
+ }
482
+
483
+ // buildArgsMode 与 buildArgs 同形,`local` 决定加不加 `--local`:
484
+ //
485
+ // true → 嵌入式(embedded agent,不需要 Gateway 在跑;provider key 取自 shell 环境变量)
486
+ // false → Gateway 路径(需要本地 Gateway;provider key 由 openclaw.json 的 providers 解析)
487
+ //
488
+ // 两条路的**其余参数完全一致**(--agent / --json / --model / --session-id / 消息 / --timeout),
489
+ // 所以回退时只差一个 flag,不必再拼一遍。
490
+ func (e *OpenClawEngine) buildArgsMode(req Request, sessionID string, msgArgs []string, local bool) []string {
491
+ args := []string{"agent"}
492
+ if local {
493
+ args = append(args, "--local")
494
+ }
495
+ args = append(args, "--agent", e.agentID(), "--json")
496
+ if m := stripModelPrefix(req.Model); m != "" {
497
+ args = append(args, "--model", m)
498
+ } else if e.Model != "" {
499
+ args = append(args, "--model", stripModelPrefix(e.Model))
500
+ }
501
+ if sessionID != "" {
502
+ args = append(args, "--session-id", sessionID)
503
+ }
504
+ args = append(args, msgArgs...)
505
+ // 超时(秒):外层 Request.Timeout 是 Duration;openclaw --timeout 是 int 秒数
506
+ if req.Timeout > 0 {
507
+ secs := int(req.Timeout / time.Second)
508
+ if secs < 1 {
509
+ secs = 1
510
+ }
511
+ args = append(args, "--timeout", fmt.Sprintf("%d", secs))
512
+ } else {
513
+ args = append(args, "--timeout", "600")
514
+ }
515
+ // 统一参数矩阵:MaxTokens / Temperature / Tools / JSONSchema 静默忽略(JSONSchema 走
516
+ // 输出后处理,Tools 是 openclaw 内嵌能力,调用方无法逐工具控)
517
+ _ = req.MaxTokens
518
+ _ = req.Temperature
519
+ _ = req.Tools
520
+ _ = req.JSONSchema
521
+ return args
522
+ }
523
+
524
+ // writeMessageArg 决定如何把 prompt 传给 openclaw agent。
525
+ //
526
+ // 策略:统一走 --message-file <tmpfile>(避免长 prompt 撞 ARG_MAX,
527
+ // 也避免 shell 引号转义问题)。 仅在 prompt 极短(<=4KB)时走 --message
528
+ // 内联(节省一次落盘)。
529
+ //
530
+ // 返回 []string 为待 append 的 args 段(已含 flag 自身);cleanup 非 nil
531
+ // 时调用方需 defer 执行(只在走 file 分支时使用)。
532
+ func writeMessageArg(tmpDir, prompt string) ([]string, func(), error) {
533
+ const inlineMax = 4 * 1024
534
+ if len(prompt) <= inlineMax {
535
+ return []string{"--message", prompt}, nil, nil
536
+ }
537
+ fp := filepath.Join(tmpDir, "message.txt")
538
+ if err := os.WriteFile(fp, []byte(prompt), 0o600); err != nil {
539
+ return nil, nil, err
540
+ }
541
+ cleanup := func() { _ = os.Remove(fp) }
542
+ return []string{"--message-file", fp}, cleanup, nil
543
+ }