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,587 @@
1
+ package agent
2
+
3
+ // arkclaw.go - ArkClaw 引擎(A2A JSON-RPC 网关,HTTP 而非本机 CLI)。
4
+ //
5
+ // 与其余引擎最大的不同:arkclaw 不在本机,而是一个 A2A(Agent-to-Agent)
6
+ // 网关。magic-agent 把 prompt 组成一个 JSON-RPC `message/send` 请求 POST
7
+ // 过去,网关侧跑它自己的 agent,再把结果包成 A2A Task 返回来。
8
+ //
9
+ // 端点与凭据(url / key / claw_id)不写死在代码里,走本地配置文件
10
+ // ~/.config/magic-agent/config.json 的 "arkclaw" 节(见 internal/config);
11
+ // 环境变量 MAGIC_AGENT_ARKCLAW_URL / _KEY / _CLAW_ID 可覆盖文件值,
12
+ // 引擎结构体上的显式字段又可覆盖前两者(测试注入用)。
13
+ //
14
+ // 请求(HTTP POST,凭据走 query 参数):
15
+ //
16
+ // POST <url>?apikey=<key>&clawId=<clawId>
17
+ // content-type: application/json
18
+ //
19
+ // {"jsonrpc":"2.0","id":"<uuid>","method":"message/send","params":{
20
+ // "message":{"kind":"message","role":"user","messageId":"<uuid>",
21
+ // "contextId":"<可选:续接上一轮>",
22
+ // "parts":[{"kind":"text","text":"<prompt>"}]}}}
23
+ //
24
+ // 响应(HTTP 200 + JSON-RPC 2.0 envelope):
25
+ //
26
+ // {"jsonrpc":"2.0","id":"<同上>","result":{
27
+ // "kind":"task","id":"<task-id>","contextId":"<上下文 id>",
28
+ // "status":{"state":"completed",
29
+ // "message":{"parts":[{"kind":"text","text":"<正文>"}]}},
30
+ // "history":[...],
31
+ // "artifacts":[{"artifactId":"...","parts":[{"kind":"text","text":"..."}]}]}}
32
+ //
33
+ // 取文优先级:result.status.message.parts[].text(任务的最终答复)→
34
+ // result.artifacts[].parts[].text(网关把产物挂在产物区时兜底)。
35
+ //
36
+ // 续接(实测结论,2026-09-16):
37
+ // contextId 必须放在 **message 对象内部**。放到 params.contextId(外层)
38
+ // 会被网关忽略并另开一个上下文 —— 表现为 agent 完全记不起上一轮。
39
+ // 因此:
40
+ // - Request.SessionID 非空 → 回填到 message.contextId(续接)
41
+ // - Response.SessionID ← result.contextId(留给下一轮)
42
+ // - Request.Continue 不支持:A2A 没有「查询最近上下文」的接口,
43
+ // 显式报错,引导调用方改用 --session <contextId>。
44
+ //
45
+ // 鉴权失败:HTTP 401 + text/plain 正文 "External authentication failed."
46
+ // 注意这不是 JSON —— 解析必须先看状态码再看 body,不能直接 json.Unmarshal
47
+ // 然后报含糊的 "invalid character"。
48
+ //
49
+ // 参数映射(统一参数矩阵的 arkclaw 列):
50
+ // - Model:实测 4 种透传方式(query ?model= / params.message.metadata.model /
51
+ // params.message.metadata.model.name / params.configuration.model /
52
+ // X-Model header)都被网关忽略 → 固定路由到 kimi-k2.6。模型是 clawId 绑定的,
53
+ // 切换必须改 config.json 的 claw_id(不是 -m)。-m 在 arkclaw 上静默忽略。
54
+ // - MaxTokens / Temperature:协议无对应字段,claw 侧自带控制,-t 之外
55
+ // 的采样参数透传无意义 → 静默忽略(与矩阵注 ② trae / codex / openclaw 一致)
56
+ // - Tools:claw 自带工具循环,-y / 白名单都无法逐工具控 → 静默忽略
57
+ // - SystemPrompt:协议无独立 system 角色,展平进 message 头部
58
+ // - JSONSchema:走 llm 引擎同一条「输出后处理抽 JSON」路径
59
+ // (extractJSONObjectStrict)
60
+ // - SessionID:写入 message.contextId(续接;实测放外层 params.contextId 无效)
61
+ // - Continue:A2A 无「查询最近上下文」接口 → 显式拒绝(不放外层那种「静默开新会话」的坑)
62
+ //
63
+ // 流式(--stream):走 A2A 官方的 `message/stream`(SSE 事件流),见 arkclaw_stream.go。
64
+ // 本文件只负责非流式的 `message/send` 单次请求-响应。⚠️ 实测该网关的流式**不是逐字增量**
65
+ //(只有「受理帧 + 整段正文」两帧),别把 --stream 当成打字机效果 —— 详见那边的文件头。
66
+
67
+ import (
68
+ "bytes"
69
+ "context"
70
+ "crypto/rand"
71
+ "encoding/hex"
72
+ "encoding/json"
73
+ "fmt"
74
+ "io"
75
+ "net/http"
76
+ "net/url"
77
+ "strings"
78
+ "time"
79
+
80
+ "github.com/darren/magic-agent/internal/config"
81
+ )
82
+
83
+ // DefaultArkClawTimeout 单次请求默认超时。
84
+ //
85
+ // 2026-09-24 由 3 分钟提到 10 分钟:3 分钟是按「单轮往返 8~23s」定的,但**带工具循环的
86
+ // 任务会跑很久** —— 实测「生成周报」在网关侧要 304s 才回(期间每 15s 一个 working 心跳),
87
+ // 3 分钟必被砍、正文一个字都拿不到。10 分钟与 CLI 的 -t 默认(600s)对齐,也让流式与非流式
88
+ // 两条路的默认一致(非流式走 Runner,拿的就是 -t)。
89
+ const DefaultArkClawTimeout = 10 * time.Minute
90
+
91
+ // maxArkClawBody 单次响应体读取上限,防止异常响应把内存吃满。
92
+ const maxArkClawBody = 8 << 20 // 8 MiB
93
+
94
+ // A2A 方法名:非流式 message/send(本文件)/ 流式 message/stream(arkclaw_stream.go)。
95
+ // 两者共用同一套请求构造(arkClawBuildRequest)与响应解析(arkClawParseResponse)。
96
+ const (
97
+ arkClawMethodSend = "message/send"
98
+ arkClawMethodStream = "message/stream"
99
+ )
100
+
101
+ // ArkClawEngine 通过 A2A JSON-RPC 网关实现 Engine。
102
+ type ArkClawEngine struct {
103
+ /* DisplayName 自定义引擎名(空 = "arkclaw")。
104
+ *
105
+ * 用途:配置里 `agents` 数组的**具名 agent**(见 internal/config::AgentConfig)——
106
+ * 每个条目注册成一个独立引擎,`-e MagicAI` 与 `-e arkclaw` 可以并列存在。
107
+ * ⚠️ 名字只影响 `Name()` / `--engines` 的展示与 `-e` 的查找,协议行为一字不改
108
+ * (仍是 A2A JSON-RPC,仍由 claw_id 绑模型)。 */
109
+ DisplayName string
110
+
111
+ // URL / Key / ClawID 显式覆盖配置文件与环境变量。三者同时给定时以此为准;
112
+ // 生产路径留空、走配置文件。测试注入用。
113
+ URL string
114
+ Key string
115
+ ClawID string
116
+
117
+ // ConfigPath 显式配置文件路径(空 = config.Path() 默认探测链)。测试注入用。
118
+ ConfigPath string
119
+
120
+ // HTTPClient 显式 HTTP 客户端(空 = 每请求带 ctx 超时的默认客户端)。测试注入用。
121
+ HTTPClient *http.Client
122
+
123
+ // Timeout 单次 HTTP 超时(0 = DefaultArkClawTimeout)。
124
+ Timeout time.Duration
125
+ }
126
+
127
+ // Name 实现 Engine。有 DisplayName 时用它(具名 agent),否则用默认名 "arkclaw"。
128
+ func (e *ArkClawEngine) Name() string {
129
+ if n := strings.TrimSpace(e.DisplayName); n != "" {
130
+ return n
131
+ }
132
+ return "arkclaw"
133
+ }
134
+
135
+ // arkClawURLHasCreds 报告端点 URL 的 query 里是否**已经**带了 apikey / clawId。
136
+ //
137
+ // 为什么需要它(2026-09-23):配置里允许把凭据直接内嵌在 URL 里
138
+ // (`…/a2a/jsonrpc?apikey=…&clawId=…`,见 config.AgentConfig 的两种写法)——
139
+ // 这种形态下 `key` / `claw_id` 字段本就是空的,若还按「三项都要非空」判,
140
+ // 明明配齐了的 agent 会被判成「未配置」。拼接那一步(arkClawEndpointURL)用的是
141
+ // `q.Set`,会保留既有 query,所以「URL 里已带」与「字段里另给」完全等价。
142
+ func arkClawURLHasCreds(endpoint string) (hasKey, hasClawID bool) {
143
+ u, err := url.Parse(strings.TrimSpace(endpoint))
144
+ if err != nil {
145
+ return false, false
146
+ }
147
+ q := u.Query()
148
+ return strings.TrimSpace(q.Get("apikey")) != "", strings.TrimSpace(q.Get("clawId")) != ""
149
+ }
150
+
151
+ // endpoint 解析生效的 (url, key, clawID)。
152
+ //
153
+ // 优先级:结构体显式字段 → 环境变量 → 配置文件。
154
+ // (环境变量覆盖文件值这一步由 config.LoadFrom 内部完成。)
155
+ func (e *ArkClawEngine) endpoint() (string, string, string, error) {
156
+ path := e.ConfigPath
157
+ if strings.TrimSpace(path) == "" {
158
+ path = config.Path()
159
+ }
160
+ cfg, err := config.LoadFrom(path)
161
+ if err != nil {
162
+ return "", "", "", err
163
+ }
164
+ return firstNonEmpty(e.URL, cfg.ArkClaw.URL),
165
+ firstNonEmpty(e.Key, cfg.ArkClaw.Key),
166
+ firstNonEmpty(e.ClawID, cfg.ArkClaw.ClawID),
167
+ nil
168
+ }
169
+
170
+ // configHint 返回报错时提示的配置文件路径。
171
+ // 具名 agent(DisplayName 非空)额外点明「是 agents 数组里的条目」——
172
+ // 否则用户会去翻 arkclaw 那一节,那儿根本没有它的配置。
173
+ func (e *ArkClawEngine) configHint() string {
174
+ p := strings.TrimSpace(e.ConfigPath)
175
+ if p == "" {
176
+ p = config.Path()
177
+ }
178
+ if p == "" {
179
+ p = "(配置文件路径未知)"
180
+ }
181
+ if strings.TrimSpace(e.DisplayName) != "" {
182
+ return p + " 的 agents 数组里 name=\"" + e.Name() + "\" 那一条"
183
+ }
184
+ return p
185
+ }
186
+
187
+ // Detect 实现 Engine。
188
+ //
189
+ // HTTP 引擎没有可执行文件,可用性 = 端点与凭据齐备。返回的说明信息用端点
190
+ // URL(与 CLI 引擎返回二进制路径同构,供 --engines 展示)。
191
+ // ⚠️ 凭据允许**内嵌在 URL 的 query 里**(`?apikey=…&clawId=…`)—— 那种形态下
192
+ // key/claw_id 字段本来就是空的,不能再按「字段必须非空」判(见 arkClawURLHasCreds)。
193
+ func (e *ArkClawEngine) Detect() (bool, string) {
194
+ u, k, c, err := e.endpoint()
195
+ if err != nil {
196
+ return false, fmt.Sprintf("%s config error: %v", e.Name(), err)
197
+ }
198
+ urlHasKey, urlHasClaw := arkClawURLHasCreds(u)
199
+ var missing []string
200
+ if u == "" {
201
+ missing = append(missing, "url")
202
+ }
203
+ if k == "" && !urlHasKey {
204
+ missing = append(missing, "key")
205
+ }
206
+ if c == "" && !urlHasClaw {
207
+ missing = append(missing, "claw_id")
208
+ }
209
+ if len(missing) > 0 {
210
+ return false, fmt.Sprintf("%s not configured: missing %s in %s (or set %s / %s / %s)",
211
+ e.Name(), strings.Join(missing, ", "), e.configHint(),
212
+ config.EnvArkClawURL, config.EnvArkClawKey, config.EnvArkClawClawID)
213
+ }
214
+ return true, u
215
+ }
216
+
217
+ // arkClawPart 是 A2A 消息/产物里的内容块。
218
+ //
219
+ // 取值(A2A 规范):text(文本)/ file(文件/图片)—— 本项目两者都用:
220
+ // 提示词走 text,附件走 file(inline base64,见 arkClawFileContent)。
221
+ type arkClawPart struct {
222
+ Kind string `json:"kind"`
223
+ Text string `json:"text,omitempty"`
224
+ File *arkClawFileContent `json:"file,omitempty"`
225
+ }
226
+
227
+ // arkClawFileContent 是 A2A file part 的内容载体。
228
+ //
229
+ // 两种形态二选一:bytes(inline base64,本机文件用它)或 uri(远端 URL)。
230
+ // 附件是本机截图 → 只能 inline。
231
+ type arkClawFileContent struct {
232
+ Name string `json:"name,omitempty"`
233
+ MimeType string `json:"mimeType,omitempty"`
234
+ Bytes string `json:"bytes,omitempty"`
235
+ URI string `json:"uri,omitempty"`
236
+ }
237
+
238
+ // arkClawMessage 是 A2A 的 message 结构(请求与响应共用)。
239
+ type arkClawMessage struct {
240
+ Kind string `json:"kind"`
241
+ Role string `json:"role,omitempty"`
242
+ MessageID string `json:"messageId,omitempty"`
243
+ ContextID string `json:"contextId,omitempty"`
244
+ Parts []arkClawPart `json:"parts,omitempty"`
245
+ }
246
+
247
+ // arkClawRequest 是 message/send 的 JSON-RPC 2.0 请求体。
248
+ type arkClawRequest struct {
249
+ JSONRPC string `json:"jsonrpc"`
250
+ ID string `json:"id"`
251
+ Method string `json:"method"`
252
+ Params struct {
253
+ Message arkClawMessage `json:"message"`
254
+ } `json:"params"`
255
+ }
256
+
257
+ // arkClawTaskStatus 对应 result.status。
258
+ type arkClawTaskStatus struct {
259
+ State string `json:"state"`
260
+ Message *arkClawMessage `json:"message"`
261
+ }
262
+
263
+ // arkClawArtifact 对应 result.artifacts[] 的一项。
264
+ type arkClawArtifact struct {
265
+ ArtifactID string `json:"artifactId"`
266
+ Name string `json:"name"`
267
+ Parts []arkClawPart `json:"parts"`
268
+ }
269
+
270
+ // arkClawResult 对应 JSON-RPC envelope 的 result。
271
+ type arkClawResult struct {
272
+ Kind string `json:"kind"`
273
+ ID string `json:"id"`
274
+ ContextID string `json:"contextId"`
275
+ Status arkClawTaskStatus `json:"status"`
276
+ Artifacts []arkClawArtifact `json:"artifacts"`
277
+ }
278
+
279
+ // arkClawRPCError 对应 JSON-RPC envelope 的 error。
280
+ type arkClawRPCError struct {
281
+ Code int `json:"code"`
282
+ Message string `json:"message"`
283
+ Data json.RawMessage `json:"data"`
284
+ }
285
+
286
+ // arkClawEnvelope 是网关顶层响应。
287
+ type arkClawEnvelope struct {
288
+ JSONRPC string `json:"jsonrpc"`
289
+ ID string `json:"id"`
290
+ Result *arkClawResult `json:"result"`
291
+ Error *arkClawRPCError `json:"error"`
292
+ }
293
+
294
+ // ListModels 实现 ModelLister:A2A 网关**不提供**模型清单。
295
+ //
296
+ // 模型由网关按 claw_id 绑定(实测 -m 不生效,网关固定用 clawId 对应的模型),
297
+ // 因此这里不编造列表,只返回 ErrNoModelSource + 原因,由 --engines 写进
298
+ // models_note 告知调用方"换模型得改 claw_id"。
299
+ func (e *ArkClawEngine) ListModels(_ context.Context) ([]string, error) {
300
+ return nil, fmt.Errorf("%w: arkclaw binds the model by claw_id at the gateway (change claw_id to switch)", ErrNoModelSource)
301
+ }
302
+
303
+ // Complete 实现 Engine:单次 message/send 往返。
304
+ //
305
+ // 执行流程:
306
+ // 1. 解析端点与凭据(配置文件 / 环境变量 / 显式字段)
307
+ // 2. 展平 prompt,组成 JSON-RPC 请求(续接 id 放 message.contextId)
308
+ // 3. POST(超时由 ctx 控制)
309
+ // 4. 先看状态码、再解 envelope,取 status.message.parts[].text
310
+ // 5. JSONSchema 模式下再做严格抽取替换 Text
311
+ func (e *ArkClawEngine) Complete(ctx context.Context, req Request) (Response, error) {
312
+ start := time.Now()
313
+
314
+ endpoint, key, clawID, err := e.endpoint()
315
+ if err != nil {
316
+ return Response{}, fmt.Errorf("%s: %w", e.Name(), err)
317
+ }
318
+ urlHasKey, urlHasClaw := arkClawURLHasCreds(endpoint)
319
+ if endpoint == "" || (key == "" && !urlHasKey) || (clawID == "" && !urlHasClaw) {
320
+ return Response{}, fmt.Errorf("%s: 未配置(需要 url / key / claw_id);请在 %s 补齐,或设置 %s / %s / %s",
321
+ e.Name(), e.configHint(), config.EnvArkClawURL, config.EnvArkClawKey, config.EnvArkClawClawID)
322
+ }
323
+
324
+ // A2A 只提供「按 contextId 续接」,没有「查询最近上下文」的接口,
325
+ // 因此 Continue 无法实现,直接报错而不是静默新开会话。
326
+ if req.Continue && req.SessionID == "" {
327
+ return Response{}, fmt.Errorf("%s: 不支持 continue(A2A 无「查询最近上下文」接口),请用 --session <context_id> 显式续接", e.Name())
328
+ }
329
+
330
+ // 实测:网关拒绝 4 种 model 透传(query ?model= / params.message.metadata.model /
331
+ // params.message.metadata.model.name / params.configuration.model / X-Model header),
332
+ // 模型由 clawId 绑定。-m 在 arkclaw 上不是透传是「无作用」:告警一次,
333
+ // 让用户知道「-m 没用,想换模型得改配置的 claw_id」。
334
+ // max-tokens / temperature / tools 与 trae/codex/openclaw 一致静默忽略
335
+ //(矩阵注 ②/③ 已有先例,不在 arkclaw 上做差异化告警)。
336
+ if req.Model != "" {
337
+ fmt.Fprintf(stderr, " ⚠ %s: --model %s 不生效(实测网关按 clawId 绑死模型),要换模型请改配置的 claw_id\n", e.Name(), req.Model)
338
+ }
339
+
340
+ // 协议没有独立 system 角色:与 trae / openclaw 同路,展平进 message 头部。
341
+ prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
342
+ if prompt == "" {
343
+ return Response{}, fmt.Errorf("%s: empty prompt", e.Name())
344
+ }
345
+
346
+ target, err := arkClawEndpointURL(endpoint, key, clawID)
347
+ if err != nil {
348
+ return Response{}, fmt.Errorf("%s: 端点无效: %w", e.Name(), err)
349
+ }
350
+
351
+ body, err := func() ([]byte, error) {
352
+ reqObj, berr := arkClawBuildRequest(arkClawMethodSend, prompt, req.SessionID, req.Attachments)
353
+ if berr != nil {
354
+ return nil, berr
355
+ }
356
+ return json.Marshal(reqObj)
357
+ }()
358
+ if err != nil {
359
+ return Response{}, fmt.Errorf("arkclaw: 构造请求失败: %w", err)
360
+ }
361
+
362
+ timeout := req.Timeout
363
+ if timeout <= 0 {
364
+ timeout = e.Timeout
365
+ }
366
+ if timeout <= 0 {
367
+ timeout = DefaultArkClawTimeout
368
+ }
369
+ httpCtx, cancel := context.WithTimeout(ctx, timeout)
370
+ defer cancel()
371
+
372
+ httpReq, err := http.NewRequestWithContext(httpCtx, http.MethodPost, target, bytes.NewReader(body))
373
+ if err != nil {
374
+ return Response{}, fmt.Errorf("arkclaw: 构造 HTTP 请求失败: %w", err)
375
+ }
376
+ httpReq.Header.Set("content-type", "application/json")
377
+ httpReq.Header.Set("accept", "application/json")
378
+
379
+ client := e.HTTPClient
380
+ if client == nil {
381
+ client = &http.Client{}
382
+ }
383
+ httpResp, err := client.Do(httpReq)
384
+ if err != nil {
385
+ return Response{}, fmt.Errorf("arkclaw: %w", err)
386
+ }
387
+ defer httpResp.Body.Close()
388
+
389
+ raw, err := io.ReadAll(io.LimitReader(httpResp.Body, maxArkClawBody))
390
+ if err != nil {
391
+ return Response{}, fmt.Errorf("arkclaw: 读取响应失败: %w", err)
392
+ }
393
+
394
+ resp, perr := arkClawParseResponse(httpResp.StatusCode, raw)
395
+ resp.Latency = time.Since(start)
396
+ resp.Engine = e.Name()
397
+ // 网关没回 contextId 时沿用请求里的 id,保证调用方拿到的 session 可用。
398
+ if resp.SessionID == "" {
399
+ resp.SessionID = req.SessionID
400
+ }
401
+ // JSONSchema 后处理(与 llm / openclaw 同路径)。
402
+ if req.JSONSchema != nil {
403
+ if extracted, ok := extractJSONObjectStrict(resp.Text, req.JSONSchema); ok {
404
+ resp.Text = extracted
405
+ }
406
+ }
407
+ if perr != nil {
408
+ return resp, perr
409
+ }
410
+ if strings.TrimSpace(resp.Text) == "" {
411
+ return resp, fmt.Errorf("arkclaw: 网关返回空正文")
412
+ }
413
+ return resp, nil
414
+ }
415
+
416
+ // arkClawBuildRequest 组装 A2A JSON-RPC 请求体(method 决定非流式 message/send 还是
417
+ // 流式 message/stream,两者请求体形状完全一致,见 arkclaw_stream.go)。
418
+ //
419
+ // contextID 非空时写入 message.contextId(续接;放外层 params.contextId 无效)。
420
+ func arkClawBuildRequest(method, prompt, contextID string, atts []Attachment) (arkClawRequest, error) {
421
+ r := arkClawRequest{
422
+ JSONRPC: "2.0",
423
+ ID: arkClawRandomID(),
424
+ Method: method,
425
+ }
426
+ // 提示词走 text part;附件走 A2A 原生 file part(inline base64)。
427
+ // 远端 agent 看不到本机路径,所以**必须**inline —— 传路径等于没传。
428
+ parts := []arkClawPart{{Kind: "text", Text: prompt}}
429
+ for _, a := range atts {
430
+ data, err := readAttachmentBase64(a)
431
+ if err != nil {
432
+ return arkClawRequest{}, err
433
+ }
434
+ mime := a.MIME
435
+ if mime == "" {
436
+ mime = "application/octet-stream"
437
+ }
438
+ parts = append(parts, arkClawPart{
439
+ Kind: "file",
440
+ File: &arkClawFileContent{Name: a.Name(), MimeType: mime, Bytes: data},
441
+ })
442
+ }
443
+ r.Params.Message = arkClawMessage{
444
+ Kind: "message",
445
+ Role: "user",
446
+ MessageID: arkClawRandomID(),
447
+ ContextID: strings.TrimSpace(contextID),
448
+ Parts: parts,
449
+ }
450
+ return r, nil
451
+ }
452
+
453
+ // arkClawParseResponse 解析网关响应。
454
+ //
455
+ // 状态码优先:鉴权失败时网关回 text/plain 纯文本(非 JSON),必须把状态码
456
+ // 与 body 一起当错误上报,而不是让 json.Unmarshal 报含糊的语法错。
457
+ func arkClawParseResponse(status int, raw []byte) (Response, error) {
458
+ body := strings.TrimSpace(string(raw))
459
+
460
+ if status < 200 || status >= 300 {
461
+ if body == "" {
462
+ return Response{}, fmt.Errorf("arkclaw: HTTP %d(空响应体)", status)
463
+ }
464
+ return Response{}, fmt.Errorf("arkclaw: HTTP %d: %s", status, truncateStr(arkClawCollapse(body), 300))
465
+ }
466
+ if body == "" {
467
+ return Response{}, fmt.Errorf("arkclaw: 网关返回空响应体(HTTP %d)", status)
468
+ }
469
+
470
+ env := arkClawEnvelope{}
471
+ if err := json.Unmarshal(raw, &env); err != nil {
472
+ // 少量网关会在 JSON 前混入日志行;退回括号计数法抽首个对象。
473
+ obj, ok := firstJSONObject(body)
474
+ if !ok {
475
+ return Response{}, fmt.Errorf("arkclaw: 响应不是 JSON: %s", truncateStr(arkClawCollapse(body), 300))
476
+ }
477
+ if jerr := json.Unmarshal(mustMarshal(obj), &env); jerr != nil {
478
+ return Response{}, fmt.Errorf("arkclaw: 解析响应失败: %w", jerr)
479
+ }
480
+ }
481
+
482
+ if env.Error != nil {
483
+ return Response{}, fmt.Errorf("arkclaw: JSON-RPC error %d: %s", env.Error.Code, arkClawCollapse(env.Error.Message))
484
+ }
485
+ if env.Result == nil {
486
+ return Response{}, fmt.Errorf("arkclaw: 响应缺少 result 字段: %s", truncateStr(arkClawCollapse(body), 300))
487
+ }
488
+
489
+ res := *env.Result
490
+ state := strings.ToLower(strings.TrimSpace(res.Status.State))
491
+ switch state {
492
+ case "failed", "canceled", "cancelled", "rejected", "unknown":
493
+ detail := arkClawPickText(res)
494
+ if detail == "" {
495
+ detail = "(网关未给出正文)"
496
+ }
497
+ return Response{SessionID: res.ContextID},
498
+ fmt.Errorf("arkclaw: 任务终止(status.state=%s): %s", res.Status.State, truncateStr(arkClawCollapse(detail), 300))
499
+ }
500
+
501
+ return Response{
502
+ Text: arkClawPickText(res),
503
+ SessionID: res.ContextID,
504
+ }, nil
505
+ }
506
+
507
+ // arkClawPickText 按优先级取正文。
508
+ // status.message.parts[].text 是 A2A 里任务的最终答复;
509
+ // artifacts[].parts[].text 是网关把产物挂在产物区时的兜底。
510
+ func arkClawPickText(res arkClawResult) string {
511
+ if res.Status.Message != nil {
512
+ if s := arkClawJoinParts(res.Status.Message.Parts); s != "" {
513
+ return s
514
+ }
515
+ }
516
+ for _, a := range res.Artifacts {
517
+ if s := arkClawJoinParts(a.Parts); s != "" {
518
+ return s
519
+ }
520
+ }
521
+ return ""
522
+ }
523
+
524
+ // arkClawJoinParts 把多个文本块拼成一段正文(换行分隔,去首尾空白)。
525
+ func arkClawJoinParts(parts []arkClawPart) string {
526
+ var sb strings.Builder
527
+ for _, p := range parts {
528
+ if strings.TrimSpace(p.Text) == "" {
529
+ continue
530
+ }
531
+ if sb.Len() > 0 {
532
+ sb.WriteString("\n")
533
+ }
534
+ sb.WriteString(p.Text)
535
+ }
536
+ return strings.TrimSpace(sb.String())
537
+ }
538
+
539
+ // arkClawEndpointURL 把凭据拼进 URL query(apikey / clawId)。
540
+ // 原 URL 已带的 query 参数保留;同名参数以配置值为准。
541
+ func arkClawEndpointURL(endpoint, key, clawID string) (string, error) {
542
+ u, err := url.Parse(strings.TrimSpace(endpoint))
543
+ if err != nil {
544
+ return "", err
545
+ }
546
+ if u.Scheme == "" || u.Host == "" {
547
+ return "", fmt.Errorf("必须是绝对 URL(含 scheme 与 host):%q", endpoint)
548
+ }
549
+ q := u.Query()
550
+ if key != "" {
551
+ q.Set("apikey", key)
552
+ }
553
+ if clawID != "" {
554
+ q.Set("clawId", clawID)
555
+ }
556
+ u.RawQuery = q.Encode()
557
+ return u.String(), nil
558
+ }
559
+
560
+ // arkClawRandomID 生成 JSON-RPC id / messageId 用的随机标识(UUIDv4 形态,
561
+ // 仅用标准库;网关只需要「每轮唯一」)。
562
+ func arkClawRandomID() string {
563
+ var b [16]byte
564
+ if _, err := rand.Read(b[:]); err != nil {
565
+ // 极端情况下 crypto/rand 不可用:时间戳兜底(同进程内仍唯一)。
566
+ return fmt.Sprintf("magic-agent-%d", time.Now().UnixNano())
567
+ }
568
+ b[6] = (b[6] & 0x0f) | 0x40 // version 4
569
+ b[8] = (b[8] & 0x3f) | 0x80 // variant 10
570
+ h := hex.EncodeToString(b[:])
571
+ return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
572
+ }
573
+
574
+ // arkClawCollapse 把连续空白压成单空格(错误摘要保持单行)。
575
+ func arkClawCollapse(s string) string {
576
+ return strings.Join(strings.Fields(s), " ")
577
+ }
578
+
579
+ // firstNonEmpty 返回第一个去空白后非空的实参。
580
+ func firstNonEmpty(vals ...string) string {
581
+ for _, v := range vals {
582
+ if strings.TrimSpace(v) != "" {
583
+ return strings.TrimSpace(v)
584
+ }
585
+ }
586
+ return ""
587
+ }