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.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// openclaw_acp.go - openclaw 的**流式**通道:以 ACP client 驱动 `openclaw acp`。
|
|
4
|
+
//
|
|
5
|
+
// 为什么不是 `agent --json`:那条是一次性 envelope —— 正文整段给出,既没有逐字增量,
|
|
6
|
+
// 也没有工具调用/思考的事件(见 openclaw.go 文件头)。openclaw 自带的官方流式出口是
|
|
7
|
+
// **ACP server**(`openclaw acp`:stdio + JSON-RPC 2.0,背后接本地 Gateway)。
|
|
8
|
+
// 它的兼容矩阵(openclaw 2026.6.11 文档 cli/acp.md)写着:
|
|
9
|
+
//
|
|
10
|
+
// 正文输出 + Tool streaming | Partial | tool_call / tool_call_update 事件带原始输入输出与文本
|
|
11
|
+
// 思考流(thought) | Unsupported | bridge 只发 output text 与 tool status
|
|
12
|
+
//
|
|
13
|
+
// 所以这一版能拿到:**逐字正文增量 + 工具卡**;思考增量拿不到(协议侧就没有,不是解析缺陷)。
|
|
14
|
+
//
|
|
15
|
+
// 事件映射(session/update 通知 → 本包的 StreamEvent):
|
|
16
|
+
//
|
|
17
|
+
// agent_message_chunk → KindText (content.text,逐字)
|
|
18
|
+
// agent_thought_chunk → KindThinking (桥当前不发;留着,将来支持时零改动接上)
|
|
19
|
+
// tool_call → KindToolUse (name=title/kind,id=toolCallId,args=rawInput)
|
|
20
|
+
// tool_call_update → KindToolResult (status=completed/failed 时;text=内容或 rawOutput)
|
|
21
|
+
// 其余(plan / usage_update / session_info_update …)→ 忽略
|
|
22
|
+
//
|
|
23
|
+
// 会话续接:锚点是 Gateway 的 **session key**(不是 ACP 的 sessionId —— 那个由桥每次随机生成,
|
|
24
|
+
// 出了进程就没意义)。做法:`session/new` 的 `_meta.sessionKey` 传我们自己的 key,
|
|
25
|
+
// 桥的 resolveSessionKey 逻辑(dist/acp-cli)见到 `meta.sessionKey` 就原样采用。
|
|
26
|
+
// - 新会话:`agent:<agentID>:acp-<uuid>`(与桥的 fallback `acp-bridge:<uuid>` 同一形态)
|
|
27
|
+
// - 续接 :把上次返回的 key 原样再传一次 → 同一个 Gateway 会话,上下文延续
|
|
28
|
+
// - ⚠️ 老版本返回的是 Gateway **session id**(裸 uuid,非流式路径给的)→ 不能当 key 用,
|
|
29
|
+
// 这里用 `openclaw sessions --json` 反查它对应的 key 再续接;查不到就新开并在 stderr 说明。
|
|
30
|
+
//
|
|
31
|
+
// 回退(很重要):流式通道多一个「Gateway 必须在跑 + ACP 桥的 scope 已批」的外部依赖。
|
|
32
|
+
// 桥起不来时**自动回退到内嵌一次性调用**(= 原来的 Complete 行为),并在 stderr 留一行说明 ——
|
|
33
|
+
// 「装了 openclaw 就能说话」不该被流式通道的依赖打断;代价只是那一轮没有逐字增量。
|
|
34
|
+
//
|
|
35
|
+
// ⚠️ 两条**主动改走嵌套路**(不流式)的情形,理由都是「宁可少一个增量,也不要静默改变语义」:
|
|
36
|
+
// 1. 本轮**指定了模型**(`-m`):ACP 不暴露模型选择(见下),嵌套路才能忠实生效;
|
|
37
|
+
// 2. 桥不可用(Gateway 没起 / scope 未批)—— 见上。
|
|
38
|
+
//
|
|
39
|
+
// 模型:ACP 桥当前**不支持按轮指定模型**(兼容矩阵:Model selection 尚未作为 ACP config 暴露),
|
|
40
|
+
// 会话用的是 Gateway 侧的默认/钉住模型。所以流式那一轮 `result.model` 只能是**主选兜底**(可能为空),
|
|
41
|
+
// 真正用的模型以会话侧为准 —— 这件事在 README 里也点明,别让界面把一个猜的值当真。
|
|
42
|
+
//
|
|
43
|
+
// exec 审批(session/request_permission):桥会把「需要用户拍板」的请求转给 ACP client。
|
|
44
|
+
// 默认取**保守**策略(见 acpPermissionDecision):只放行读/搜/取/思考类,改动与执行类一律拒绝
|
|
45
|
+
// 并在 stderr 说明;要全放行用 MAGIC_AGENT_OPENCLAW_ACP_APPROVE=all。
|
|
46
|
+
// ⚠️ openclaw 自己的 exec-policy / allowlist 仍是第一道闸门,这里只是 ACP 这一层的答复。
|
|
47
|
+
|
|
48
|
+
import (
|
|
49
|
+
"bufio"
|
|
50
|
+
"bytes"
|
|
51
|
+
"context"
|
|
52
|
+
"crypto/rand"
|
|
53
|
+
"encoding/base64"
|
|
54
|
+
"encoding/hex"
|
|
55
|
+
"encoding/json"
|
|
56
|
+
"fmt"
|
|
57
|
+
"io"
|
|
58
|
+
"os"
|
|
59
|
+
"path/filepath"
|
|
60
|
+
"strings"
|
|
61
|
+
"sync"
|
|
62
|
+
"time"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
/* ────────────── ACP 线格式(JSON-RPC 2.0,NDJSON 一行一帧) ────────────── */
|
|
66
|
+
|
|
67
|
+
const (
|
|
68
|
+
// acpProtocolVersion 取 ACP SDK 0.15 的协议版本(initialize 回应里应回同一值)。
|
|
69
|
+
acpProtocolVersion = 1
|
|
70
|
+
// acpKeyPrefix 自造 session key 的前缀:形态与 openclaw 的 session key 一致
|
|
71
|
+
//(`agent:<agentId>:<rest>`),agentId 用本引擎固定使用的那个(默认 main)。
|
|
72
|
+
acpKeyPrefix = "agent:%s:acp-"
|
|
73
|
+
// acpKeyHexLen 随机 key 的十六进制长度(16 字节 = 32 字符,与 uuid 同量级)。
|
|
74
|
+
acpKeyHexLen = 32
|
|
75
|
+
// acpMaxLine 单行上限(tool_call 的 rawInput/rawOutput 可能很大)。
|
|
76
|
+
acpMaxLine = 16 * 1024 * 1024
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// acpError 是 JSON-RPC 的错误对象。
|
|
80
|
+
type acpError struct {
|
|
81
|
+
Code int `json:"code"`
|
|
82
|
+
Message string `json:"message"`
|
|
83
|
+
Data json.RawMessage `json:"data,omitempty"`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (e *acpError) Error() string {
|
|
87
|
+
if e == nil {
|
|
88
|
+
return ""
|
|
89
|
+
}
|
|
90
|
+
if len(e.Data) > 0 {
|
|
91
|
+
return fmt.Sprintf("ACP 错误 %d: %s (%s)", e.Code, e.Message, truncateForErr(string(e.Data), 200))
|
|
92
|
+
}
|
|
93
|
+
return fmt.Sprintf("ACP 错误 %d: %s", e.Code, e.Message)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// acpFrame 一帧(请求 / 响应 / 通知共用)。
|
|
97
|
+
type acpFrame struct {
|
|
98
|
+
JSONRPC string `json:"jsonrpc"`
|
|
99
|
+
ID json.RawMessage `json:"id,omitempty"`
|
|
100
|
+
Method string `json:"method,omitempty"`
|
|
101
|
+
Params json.RawMessage `json:"params,omitempty"`
|
|
102
|
+
Result json.RawMessage `json:"result,omitempty"`
|
|
103
|
+
Error *acpError `json:"error,omitempty"`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// acpUpdate 是 session/update 通知里的 update 载荷(各类型字段的并集,按需取用)。
|
|
107
|
+
type acpUpdate struct {
|
|
108
|
+
SessionUpdate string `json:"sessionUpdate"`
|
|
109
|
+
// agent_message_chunk / agent_thought_chunk / user_message_chunk:content 是 ContentBlock
|
|
110
|
+
Content json.RawMessage `json:"content"`
|
|
111
|
+
// tool_call / tool_call_update
|
|
112
|
+
ToolCallID string `json:"toolCallId"`
|
|
113
|
+
Title string `json:"title"`
|
|
114
|
+
Kind string `json:"kind"`
|
|
115
|
+
Status string `json:"status"`
|
|
116
|
+
RawInput json.RawMessage `json:"rawInput"`
|
|
117
|
+
RawOutput json.RawMessage `json:"rawOutput"`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// acpContentBlock 是 content 字段的正文形态(只关心 text)。
|
|
121
|
+
type acpContentBlock struct {
|
|
122
|
+
Type string `json:"type"`
|
|
123
|
+
Text string `json:"text"`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* ────────────── Stream:ACP 主流程 ────────────── */
|
|
127
|
+
|
|
128
|
+
// openClawACPUnavailable 标记「流式通道本身不可用」(Gateway 没起 / scope 未批 / 命令缺失),
|
|
129
|
+
// 这一类失败可以安全回退到内嵌一次性调用;其余的协议/业务失败照实返回。
|
|
130
|
+
type openClawACPUnavailable struct{ why string }
|
|
131
|
+
|
|
132
|
+
func (e *openClawACPUnavailable) Error() string { return e.why }
|
|
133
|
+
|
|
134
|
+
// acpBreaker:桥不可用后的**跨进程冷却**(落一个标记文件)。
|
|
135
|
+
//
|
|
136
|
+
// 为什么必须跨进程:上层(观物台)是**一轮一个 CLI 进程**,进程内的冷却对下一轮毫无意义 ——
|
|
137
|
+
// 实测那样写两轮都会照旧 spawn 桥(各多花 ~7s,还在 Gateway 侧各留一条 pending 请求)。
|
|
138
|
+
// 所以把「到什么时候为止别再试」写进文件:冷却期内直接走嵌套路,到点自动再试一次
|
|
139
|
+
// (用户批完 scope 后最多等这么久就恢复流式;想立刻恢复删掉该文件即可)。
|
|
140
|
+
//
|
|
141
|
+
// 文件:`~/.magic-agent/openclaw-acp-broken.json`(`MAGIC_AGENT_OPENCLAW_ACP_BREAKER` 可改路径,
|
|
142
|
+
// 验收脚本用它指向临时文件)。写不进去就退化成「没有冷却」——这类优化不该让调用失败。
|
|
143
|
+
const acpBreakerCooldown = 5 * time.Minute
|
|
144
|
+
|
|
145
|
+
// StreamAvailable 报告某引擎的流式通道**此刻**是否可用。
|
|
146
|
+
//
|
|
147
|
+
// 与 AsStreamer 的区别:后者是编译期能力(有没有实现 Streamer),前者是运行期可用性。
|
|
148
|
+
// openclaw 的流式走 ACP 桥,而桥要连本机 Gateway、Gateway 对它的授权(scope)是**外部状态**
|
|
149
|
+
// —— 用户没批的时候桥连不上,`--stream` 只会白等一次失败再回退(实测 ~7s)。
|
|
150
|
+
// 所以把「已知不可用」的冷却状态算进来,让调用方(掌天瓶)直接不传 `--stream`、
|
|
151
|
+
// 界面也不显示流式标记;冷却到期自动恢复(真连上了就一直是 true)。
|
|
152
|
+
//
|
|
153
|
+
// 注意:判据是**已知失败**而不是「主动探测」—— 探测一次要 ~7s,放进 `--engines` 会拖慢每次
|
|
154
|
+
// 刷新。所以冷却是「失败后写文件、跨进程生效」的形态:没批时最多多试一次,之后就不再宣称可用。
|
|
155
|
+
func StreamAvailable(engineName string) bool {
|
|
156
|
+
switch engineName {
|
|
157
|
+
case "openclaw":
|
|
158
|
+
return !acpBreakerCooling()
|
|
159
|
+
}
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// acpBreakerPath 冷却标记文件路径(空串 = 用不了文件,退化成不冷却)。
|
|
164
|
+
func acpBreakerPath() string {
|
|
165
|
+
if p := strings.TrimSpace(os.Getenv("MAGIC_AGENT_OPENCLAW_ACP_BREAKER")); p != "" {
|
|
166
|
+
return p
|
|
167
|
+
}
|
|
168
|
+
home, err := os.UserHomeDir()
|
|
169
|
+
if err != nil || home == "" {
|
|
170
|
+
return ""
|
|
171
|
+
}
|
|
172
|
+
return filepath.Join(home, ".magic-agent", "openclaw-acp-broken.json")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// acpBreakerCooling 现在是否处于冷却期(标记文件里的 until 还没到)。
|
|
176
|
+
func acpBreakerCooling() bool {
|
|
177
|
+
p := acpBreakerPath()
|
|
178
|
+
if p == "" {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
b, err := os.ReadFile(p)
|
|
182
|
+
if err != nil {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
var st struct {
|
|
186
|
+
Until int64 `json:"until"`
|
|
187
|
+
}
|
|
188
|
+
if json.Unmarshal(b, &st) != nil || st.Until == 0 {
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
return time.Now().Unix() < st.Until
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// markACPBridgeDown 落一次冷却(写失败就静默放弃:只是优化,不是功能)。
|
|
195
|
+
func markACPBridgeDown() {
|
|
196
|
+
p := acpBreakerPath()
|
|
197
|
+
if p == "" {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
b, _ := json.Marshal(map[string]any{
|
|
204
|
+
"until": time.Now().Add(acpBreakerCooldown).Unix(),
|
|
205
|
+
"why": "openclaw ACP 桥不可用(Gateway 没起 / scope 未批 / 命令缺失)",
|
|
206
|
+
})
|
|
207
|
+
_ = os.WriteFile(p, b, 0o644)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Stream 实现 Streamer:以 ACP client 驱动 `openclaw acp`(见文件头)。
|
|
211
|
+
func (e *OpenClawEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
212
|
+
start := time.Now()
|
|
213
|
+
bin := e.bin()
|
|
214
|
+
if bin == "" {
|
|
215
|
+
return StreamResult{}, fmt.Errorf("openclaw CLI not found; set MAGIC_AGENT_OPENCLAW_BIN")
|
|
216
|
+
}
|
|
217
|
+
// prompt 与非流式路径**同源**:同一次扁平化 + 同一段附件说明(image 走 ACP 原生块,见下)。
|
|
218
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, toolsIsOff(req))
|
|
219
|
+
if prompt == "" {
|
|
220
|
+
return StreamResult{}, fmt.Errorf("openclaw: empty prompt")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* ⚠️ ACP 通道**不能指定模型**:桥不暴露模型选择(兼容矩阵原文「Model selection … not yet
|
|
224
|
+
exposed as ACP config options」),会话用 Gateway 侧的默认/钉住模型。而 `-m` 在嵌套路里是
|
|
225
|
+
**忠实生效**的(`openclaw agent --model`,实测换成 minimax 就走 minimax 的鉴权)。
|
|
226
|
+
所以「本轮指定了模型」时宁可不流式,也不要把用户选的模型静默换掉 —— 直接走嵌套路,
|
|
227
|
+
并在 stderr 说明(与「指定模型」这件事的优先级一致:模型正确 > 逐字增量)。 */
|
|
228
|
+
if m := stripModelPrefix(req.Model); m != "" {
|
|
229
|
+
fmt.Fprintf(os.Stderr, "magic-agent: openclaw 指定了模型(%s),本轮改走非流式(ACP 通道不支持按轮指定模型)\n", m)
|
|
230
|
+
return e.completeAsStream(ctx, req, onEvent, start)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
textPart, imageBlocks, attNote := e.acpPromptParts(prompt, req)
|
|
234
|
+
if attNote != "" {
|
|
235
|
+
fmt.Fprintln(os.Stderr, "magic-agent: "+attNote)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 桥刚被判过不可用 → 冷却期内直接走嵌套路(不再 spawn:省 7s,也不再刷 pending 请求)。
|
|
239
|
+
if acpBreakerCooling() {
|
|
240
|
+
fmt.Fprintf(os.Stderr, "magic-agent: openclaw 流式通道(ACP)本轮跳过(上次判定不可用,%s 内不再重试)→ 走内嵌一次性调用\n",
|
|
241
|
+
acpBreakerCooldown)
|
|
242
|
+
return e.completeAsStream(ctx, req, onEvent, start)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
key, keyNote := e.resolveACPSessionKey(ctx, bin, req.SessionID)
|
|
246
|
+
if keyNote != "" {
|
|
247
|
+
fmt.Fprintln(os.Stderr, "magic-agent: "+keyNote)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
st, err := e.streamACPSession(ctx, bin, key, textPart, imageBlocks, onEvent)
|
|
251
|
+
if err == nil {
|
|
252
|
+
return e.acpStreamResult(st, req, key, start), nil
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 收尾:ACP 通道不可用 → 回退内嵌一次性调用(老行为),并把正文补发成一条 text 增量。
|
|
256
|
+
if _, ok := err.(*openClawACPUnavailable); !ok {
|
|
257
|
+
return StreamResult{}, err
|
|
258
|
+
}
|
|
259
|
+
markACPBridgeDown()
|
|
260
|
+
fmt.Fprintf(os.Stderr, "magic-agent: openclaw 流式通道(ACP)不可用,已回退内嵌一次性调用(本轮无逐字增量):%v\n", err)
|
|
261
|
+
return e.completeAsStream(ctx, req, onEvent, start)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// completeAsStream 走内嵌一次性调用(Complete),并把结果包装成「流式形态」:
|
|
265
|
+
// 正文补发成**一条 text 增量**,调用方(对话渲染)看到的仍是「有正文的一轮」,只是没有逐字。
|
|
266
|
+
// 用途:① ACP 桥不可用时的回退;② 本轮指定了模型(ACP 不支持下,见 Stream)。
|
|
267
|
+
func (e *OpenClawEngine) completeAsStream(
|
|
268
|
+
ctx context.Context, req Request, onEvent func(StreamEvent), start time.Time,
|
|
269
|
+
) (StreamResult, error) {
|
|
270
|
+
resp, err := e.Complete(ctx, req)
|
|
271
|
+
if err != nil {
|
|
272
|
+
return StreamResult{}, err
|
|
273
|
+
}
|
|
274
|
+
if onEvent != nil && resp.Text != "" {
|
|
275
|
+
onEvent(StreamEvent{Kind: KindText, Text: resp.Text})
|
|
276
|
+
}
|
|
277
|
+
return StreamResult{
|
|
278
|
+
Response: Response{
|
|
279
|
+
Engine: e.Name(), Text: resp.Text, Model: resp.Model, SessionID: resp.SessionID,
|
|
280
|
+
InputTokens: resp.InputTokens, OutputTokens: resp.OutputTokens, TotalTokens: resp.TotalTokens,
|
|
281
|
+
Latency: time.Since(start), Attempts: 1,
|
|
282
|
+
},
|
|
283
|
+
}, nil
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// acpStreamResult 把 ACP 会话的累积状态收成 StreamResult。
|
|
287
|
+
// 会话锚点用 **key**(不是桥随机生成的 ACP sessionId):调用方下一轮把它传回 `--session`
|
|
288
|
+
// 即续接同一 Gateway 会话(见文件头「会话续接」)。
|
|
289
|
+
func (e *OpenClawEngine) acpStreamResult(st *acpStreamState, req Request, key string, start time.Time) StreamResult {
|
|
290
|
+
text := strings.TrimSpace(st.text.String())
|
|
291
|
+
if text == "" {
|
|
292
|
+
text = strings.TrimSpace(st.lastText)
|
|
293
|
+
}
|
|
294
|
+
model := strings.TrimSpace(st.model)
|
|
295
|
+
if model == "" {
|
|
296
|
+
model = stripModelPrefix(req.Model)
|
|
297
|
+
}
|
|
298
|
+
in, out := st.usage.Input+st.usage.CacheRead, st.usage.Output
|
|
299
|
+
return StreamResult{
|
|
300
|
+
Response: Response{
|
|
301
|
+
Engine: e.Name(), Text: text, Model: model, SessionID: key,
|
|
302
|
+
InputTokens: in, OutputTokens: out, TotalTokens: in + out,
|
|
303
|
+
Latency: time.Since(start), Attempts: 1,
|
|
304
|
+
},
|
|
305
|
+
Thinking: strings.TrimSpace(st.thinking.String()),
|
|
306
|
+
Tools: st.tools,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* ────────────── 会话锚点 ────────────── */
|
|
311
|
+
|
|
312
|
+
// resolveACPSessionKey 决定本轮用哪个 Gateway session key,以及要额外说明什么(可为空)。
|
|
313
|
+
func (e *OpenClawEngine) resolveACPSessionKey(ctx context.Context, bin, sid string) (key, note string) {
|
|
314
|
+
s := strings.TrimSpace(sid)
|
|
315
|
+
if s == "" {
|
|
316
|
+
return e.newACPSessionKey(), ""
|
|
317
|
+
}
|
|
318
|
+
if strings.Contains(s, ":") {
|
|
319
|
+
return s, "" // 已经是 key 形态(本引擎流式路径返回的就是它)
|
|
320
|
+
}
|
|
321
|
+
// 裸 uuid = 老的非流式路径返回的 Gateway **session id**:反查它对应的 key 再续接,
|
|
322
|
+
// 这样升级前后的同一轮对话上下文不会丢。
|
|
323
|
+
if k, err := e.lookupSessionKey(ctx, bin, s); err == nil && k != "" {
|
|
324
|
+
return k, ""
|
|
325
|
+
}
|
|
326
|
+
return e.newACPSessionKey(), fmt.Sprintf("上一轮的会话 id %q 是内嵌路径给的(映射不到 ACP 会话 key),本轮新开会话", s)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// newACPSessionKey 造一个新的 session key。
|
|
330
|
+
func (e *OpenClawEngine) newACPSessionKey() string {
|
|
331
|
+
buf := make([]byte, acpKeyHexLen/2)
|
|
332
|
+
if _, err := rand.Read(buf); err != nil {
|
|
333
|
+
// 熵源异常极罕见;退回时间戳,保证 key 仍然唯一(宁可继续,也不要因为拿不到随机数就失败)。
|
|
334
|
+
return fmt.Sprintf(acpKeyPrefix+fmt.Sprintf("%x", time.Now().UnixNano()), e.agentID())
|
|
335
|
+
}
|
|
336
|
+
return fmt.Sprintf(acpKeyPrefix+hex.EncodeToString(buf), e.agentID())
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// lookupSessionKey 用 `openclaw sessions --json` 把 Gateway session id 反查成 session key。
|
|
340
|
+
// 复用与 lookupLatestSessionID 同一套解析(抗 banner / 日志混入)。
|
|
341
|
+
func (e *OpenClawEngine) lookupSessionKey(ctx context.Context, bin, sessionID string) (string, error) {
|
|
342
|
+
args := []string{"sessions", "--json", "--limit", "200"}
|
|
343
|
+
if a := e.agentID(); a != "" {
|
|
344
|
+
args = append(args, "--agent", a)
|
|
345
|
+
}
|
|
346
|
+
stdout, _, err := runCLI(ctx, bin, args...)
|
|
347
|
+
if err != nil {
|
|
348
|
+
return "", err
|
|
349
|
+
}
|
|
350
|
+
var env openclawSessionsEnvelope
|
|
351
|
+
if jerr := json.Unmarshal([]byte(stdout), &env); jerr != nil {
|
|
352
|
+
obj, ok := firstJSONObject(stdout)
|
|
353
|
+
if !ok {
|
|
354
|
+
return "", fmt.Errorf("openclaw sessions: 未找到 JSON envelope")
|
|
355
|
+
}
|
|
356
|
+
_ = json.Unmarshal(mustMarshal(obj), &env)
|
|
357
|
+
}
|
|
358
|
+
for _, s := range env.Sessions {
|
|
359
|
+
if s.SessionID == sessionID && s.Key != "" {
|
|
360
|
+
return s.Key, nil
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return "", nil
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* ────────────── ACP 会话:spawn + 握手 + 事件循环 ────────────── */
|
|
367
|
+
|
|
368
|
+
// acpStreamState 一次 ACP 流式调用的累积状态。
|
|
369
|
+
type acpStreamState struct {
|
|
370
|
+
onEvent func(StreamEvent)
|
|
371
|
+
text strings.Builder
|
|
372
|
+
thinking strings.Builder
|
|
373
|
+
lastText string // result 行(若有)给的权威正文
|
|
374
|
+
model string
|
|
375
|
+
usage openclawUsage
|
|
376
|
+
|
|
377
|
+
tools []ToolCall
|
|
378
|
+
toolIndex map[string]int // toolCallId → tools 下标
|
|
379
|
+
resultSeen map[string]bool // 已发过 tool_result 的 toolCallId(桥会推多条,见 handleNotification)
|
|
380
|
+
|
|
381
|
+
sessionID string // 桥给的 ACP sessionId(仅本进程内用于 prompt/cancel)
|
|
382
|
+
stopReason string
|
|
383
|
+
|
|
384
|
+
emitted bool // 是否已经吐出过增量(回退判定用)
|
|
385
|
+
acpReady bool // 桥是否已连上(handshake 有响应)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
func (st *acpStreamState) emit(ev StreamEvent) {
|
|
389
|
+
switch ev.Kind {
|
|
390
|
+
case KindText:
|
|
391
|
+
st.text.WriteString(ev.Text)
|
|
392
|
+
case KindThinking:
|
|
393
|
+
st.thinking.WriteString(ev.Text)
|
|
394
|
+
}
|
|
395
|
+
if ev.Kind == KindText || ev.Kind == KindThinking || ev.Kind == KindToolUse || ev.Kind == KindToolResult {
|
|
396
|
+
st.emitted = true
|
|
397
|
+
}
|
|
398
|
+
if st.onEvent != nil {
|
|
399
|
+
st.onEvent(ev)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// acpPermissionPolicy 返回对一次 exec 审批请求的答复策略:
|
|
404
|
+
//
|
|
405
|
+
// "allow" 选第一个 allow_* 选项
|
|
406
|
+
// "reject" 选第一个 reject_* 选项(拿不到就回 cancelled)
|
|
407
|
+
//
|
|
408
|
+
// 默认保守:只放行读/搜/取/思考/其它类(对应 ACP ToolKind: read/search/fetch/think/other),
|
|
409
|
+
// edit/delete/move/execute 一律拒绝 —— 与「不替用户扩大权限」一致。
|
|
410
|
+
// MAGIC_AGENT_OPENCLAW_ACP_APPROVE=all 时全放行(等价 `openclaw acp client --approve-all`)。
|
|
411
|
+
func acpPermissionPolicy(kind string) string {
|
|
412
|
+
if strings.EqualFold(strings.TrimSpace(os.Getenv("MAGIC_AGENT_OPENCLAW_ACP_APPROVE")), "all") {
|
|
413
|
+
return "allow"
|
|
414
|
+
}
|
|
415
|
+
switch strings.ToLower(strings.TrimSpace(kind)) {
|
|
416
|
+
case "read", "search", "fetch", "think", "other", "":
|
|
417
|
+
return "allow"
|
|
418
|
+
default: // edit / delete / move / execute
|
|
419
|
+
return "reject"
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// streamACPSession 起桥、跑完一轮,返回累积状态。失败时若判定为「通道不可用」,
|
|
424
|
+
// 返回 *openClawACPUnavailable(见 Stream 的回退)。
|
|
425
|
+
func (e *OpenClawEngine) streamACPSession(
|
|
426
|
+
ctx context.Context, bin, key, text string, images []acpImageBlock, onEvent func(StreamEvent),
|
|
427
|
+
) (*acpStreamState, error) {
|
|
428
|
+
args := []string{"--log-level", "silent", "acp", "--no-prefix-cwd"}
|
|
429
|
+
cmd := newStreamCmdIn("", bin, args)
|
|
430
|
+
|
|
431
|
+
stdin, err := cmd.StdinPipe()
|
|
432
|
+
if err != nil {
|
|
433
|
+
return nil, fmt.Errorf("openclaw acp: stdin pipe: %w", err)
|
|
434
|
+
}
|
|
435
|
+
stdoutR, stdoutW, err := os.Pipe()
|
|
436
|
+
if err != nil {
|
|
437
|
+
return nil, fmt.Errorf("openclaw acp: stdout pipe: %w", err)
|
|
438
|
+
}
|
|
439
|
+
stderrR, stderrW, err := os.Pipe()
|
|
440
|
+
if err != nil {
|
|
441
|
+
stdoutR.Close()
|
|
442
|
+
stdoutW.Close()
|
|
443
|
+
return nil, fmt.Errorf("openclaw acp: stderr pipe: %w", err)
|
|
444
|
+
}
|
|
445
|
+
cmd.Stdout = stdoutW
|
|
446
|
+
cmd.Stderr = stderrW
|
|
447
|
+
if err := cmd.Start(); err != nil {
|
|
448
|
+
stdoutR.Close()
|
|
449
|
+
stdoutW.Close()
|
|
450
|
+
stderrR.Close()
|
|
451
|
+
stderrW.Close()
|
|
452
|
+
// 连可执行文件都起不来(ENOENT / 权限)→ 属于通道不可用,回退。
|
|
453
|
+
return nil, &openClawACPUnavailable{why: fmt.Sprintf("启动 %s 失败: %v", bin, err)}
|
|
454
|
+
}
|
|
455
|
+
notifySpawn(ctx, cmd.Process.Pid)
|
|
456
|
+
stdoutW.Close() // 父进程不持写端:EOF 只取决于子进程一侧
|
|
457
|
+
stderrW.Close()
|
|
458
|
+
|
|
459
|
+
stderrTail := &streamStderrBuf{}
|
|
460
|
+
go func() { defer stderrR.Close(); _, _ = io.Copy(stderrTail, stderrR) }()
|
|
461
|
+
|
|
462
|
+
st := &acpStreamState{onEvent: onEvent, toolIndex: map[string]int{}, resultSeen: map[string]bool{}}
|
|
463
|
+
|
|
464
|
+
/* 写端串行化:ACP 的每一条请求/应答都必须整帧写出(不能交错),
|
|
465
|
+
所以所有写都走这一条通道,由唯一一个 goroutine 落笔。
|
|
466
|
+
⚠️ 关闭用 stopped 通道**而不是 close(writes)**:取消那条 goroutine 也可能在写,
|
|
467
|
+
往已关闭的通道发会 panic(第一版就是这么写的,编译过了但一取消就崩)。 */
|
|
468
|
+
writes := make(chan []byte, 32)
|
|
469
|
+
stopped := make(chan struct{})
|
|
470
|
+
var stopOnce sync.Once
|
|
471
|
+
stopWriter := func() { stopOnce.Do(func() { close(stopped) }) }
|
|
472
|
+
var wg sync.WaitGroup
|
|
473
|
+
wg.Add(1)
|
|
474
|
+
go func() {
|
|
475
|
+
defer wg.Done()
|
|
476
|
+
for {
|
|
477
|
+
select {
|
|
478
|
+
case <-stopped:
|
|
479
|
+
return
|
|
480
|
+
case b := <-writes:
|
|
481
|
+
if _, werr := stdin.Write(b); werr != nil {
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}()
|
|
487
|
+
send := func(v any) {
|
|
488
|
+
b, merr := json.Marshal(v)
|
|
489
|
+
if merr != nil {
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
select {
|
|
493
|
+
case <-stopped:
|
|
494
|
+
return
|
|
495
|
+
case writes <- append(b, '\n'):
|
|
496
|
+
default: // 通道满 = 下游堵住(罕见);丢掉比死锁好
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
sendReq := func(id int, method string, params any) {
|
|
500
|
+
send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// 取消:ACP 有 session/cancel,先礼后兵(发完再杀整组,与其他引擎的杀组语义一致)。
|
|
504
|
+
go func() {
|
|
505
|
+
<-ctx.Done()
|
|
506
|
+
if st.sessionID != "" {
|
|
507
|
+
send(map[string]any{"jsonrpc": "2.0", "method": "session/cancel",
|
|
508
|
+
"params": map[string]any{"sessionId": st.sessionID}})
|
|
509
|
+
time.Sleep(200 * time.Millisecond) // 给桥一点时间优雅收尾
|
|
510
|
+
}
|
|
511
|
+
killProcessGroup(cmd)
|
|
512
|
+
}()
|
|
513
|
+
|
|
514
|
+
// 握手:initialize → session/new(_meta.sessionKey) → session/prompt。
|
|
515
|
+
const (
|
|
516
|
+
idInit = 1
|
|
517
|
+
idNew = 2
|
|
518
|
+
idPrompt = 3
|
|
519
|
+
)
|
|
520
|
+
promptSent := false
|
|
521
|
+
promptDone := make(chan error, 1)
|
|
522
|
+
finish := func(err error) {
|
|
523
|
+
select {
|
|
524
|
+
case promptDone <- err:
|
|
525
|
+
default:
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
scanErr := make(chan error, 1)
|
|
530
|
+
go func() {
|
|
531
|
+
defer stdoutR.Close()
|
|
532
|
+
sc := bufio.NewScanner(stdoutR)
|
|
533
|
+
sc.Buffer(make([]byte, 0, 64*1024), acpMaxLine)
|
|
534
|
+
for sc.Scan() {
|
|
535
|
+
line := strings.TrimSpace(sc.Text())
|
|
536
|
+
if line == "" || line[0] != '{' {
|
|
537
|
+
continue // 桥的 banner / 日志(`◇ Doctor warnings` 那类)会混进 stdout:跳过
|
|
538
|
+
}
|
|
539
|
+
var f acpFrame
|
|
540
|
+
if jerr := json.Unmarshal([]byte(line), &f); jerr != nil {
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
if f.Method != "" && len(f.ID) == 0 { // 通知
|
|
544
|
+
st.handleNotification(f)
|
|
545
|
+
continue
|
|
546
|
+
}
|
|
547
|
+
if f.Method != "" { // server→client 请求(要应答,否则桥会等)
|
|
548
|
+
st.handleServerRequest(f, send)
|
|
549
|
+
continue
|
|
550
|
+
}
|
|
551
|
+
// 响应
|
|
552
|
+
if f.Error != nil {
|
|
553
|
+
finish(f.Error)
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
switch asInt(f.ID) {
|
|
557
|
+
case idInit:
|
|
558
|
+
st.acpReady = true
|
|
559
|
+
if !promptSent {
|
|
560
|
+
params := map[string]any{
|
|
561
|
+
"cwd": e.acpCwd(),
|
|
562
|
+
"mcpServers": []any{},
|
|
563
|
+
"_meta": map[string]any{"sessionKey": key},
|
|
564
|
+
}
|
|
565
|
+
// cwd 交给会话(桥按它为 session 记工作目录);prompt 不再重复前缀(--no-prefix-cwd)。
|
|
566
|
+
sendReq(idNew, "session/new", params)
|
|
567
|
+
}
|
|
568
|
+
case idNew:
|
|
569
|
+
var r struct {
|
|
570
|
+
SessionID string `json:"sessionId"`
|
|
571
|
+
}
|
|
572
|
+
_ = json.Unmarshal(f.Result, &r)
|
|
573
|
+
if r.SessionID == "" {
|
|
574
|
+
finish(fmt.Errorf("openclaw acp: session/new 未返回 sessionId"))
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
st.sessionID = r.SessionID
|
|
578
|
+
blocks := []any{map[string]any{"type": "text", "text": text}}
|
|
579
|
+
for _, im := range images {
|
|
580
|
+
blocks = append(blocks, map[string]any{
|
|
581
|
+
"type": "image", "data": im.Data, "mimeType": im.MIME,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
sendReq(idPrompt, "session/prompt", map[string]any{
|
|
585
|
+
"sessionId": r.SessionID, "prompt": blocks,
|
|
586
|
+
})
|
|
587
|
+
promptSent = true
|
|
588
|
+
case idPrompt:
|
|
589
|
+
var r struct {
|
|
590
|
+
StopReason string `json:"stopReason"`
|
|
591
|
+
}
|
|
592
|
+
_ = json.Unmarshal(f.Result, &r)
|
|
593
|
+
st.stopReason = r.StopReason
|
|
594
|
+
finish(nil)
|
|
595
|
+
return
|
|
596
|
+
default:
|
|
597
|
+
// 其余响应(我们没发过的 id)忽略。
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if serr := sc.Err(); serr != nil && serr != io.ErrClosedPipe {
|
|
601
|
+
scanErr <- serr
|
|
602
|
+
}
|
|
603
|
+
}()
|
|
604
|
+
|
|
605
|
+
// 发 initialize(协议版本取 SDK 0.15 同一值;clientCapabilities 里把不支持的都声明 false,
|
|
606
|
+
// 桥的兼容矩阵也写明它不调用 client 的 fs/* 与 terminal/*)。
|
|
607
|
+
sendReq(idInit, "initialize", map[string]any{
|
|
608
|
+
"protocolVersion": acpProtocolVersion,
|
|
609
|
+
"clientCapabilities": map[string]any{
|
|
610
|
+
"fs": map[string]any{"readTextFile": false, "writeTextFile": false},
|
|
611
|
+
},
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
waitErr := make(chan error, 1)
|
|
615
|
+
go func() { waitErr <- cmd.Wait() }()
|
|
616
|
+
|
|
617
|
+
var runErr error
|
|
618
|
+
select {
|
|
619
|
+
case err := <-promptDone:
|
|
620
|
+
runErr = err
|
|
621
|
+
case err := <-scanErr:
|
|
622
|
+
runErr = fmt.Errorf("openclaw acp: 读 stdout 失败: %w", err)
|
|
623
|
+
case werr := <-waitErr:
|
|
624
|
+
// 进程先退(桥连不上 / 握手失败都会走这里):把 stderr 的原文带出来。
|
|
625
|
+
runErr = fmt.Errorf("openclaw acp 进程退出: %v(stderr: %s)", werr, truncateForErr(stderrTail.String(), 400))
|
|
626
|
+
case <-ctx.Done():
|
|
627
|
+
runErr = fmt.Errorf("process group killed: %w", ctx.Err())
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// 收尾:停写端(让桥收尾)→ 杀组兜底 → 等写协程与进程回收。
|
|
631
|
+
stopWriter()
|
|
632
|
+
if runErr == nil {
|
|
633
|
+
// 正常收尾:桥在一轮结束后通常自己退出(一次性调用);给一小段时间,超时再杀。
|
|
634
|
+
select {
|
|
635
|
+
case <-waitErr:
|
|
636
|
+
case <-time.After(5 * time.Second):
|
|
637
|
+
_ = stdin.Close()
|
|
638
|
+
killProcessGroup(cmd)
|
|
639
|
+
}
|
|
640
|
+
} else {
|
|
641
|
+
_ = stdin.Close()
|
|
642
|
+
killProcessGroup(cmd)
|
|
643
|
+
select {
|
|
644
|
+
case <-waitErr:
|
|
645
|
+
case <-time.After(5 * time.Second):
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
wg.Wait()
|
|
649
|
+
|
|
650
|
+
if runErr != nil {
|
|
651
|
+
// 通道不可用(Gateway 没起 / scope 未批 / 二进制缺失)→ 交给 Stream 回退。
|
|
652
|
+
if !st.emitted && isACPBridgeUnavailable(stderrTail.String(), runErr) {
|
|
653
|
+
return nil, &openClawACPUnavailable{why: firstLine(runErr.Error())}
|
|
654
|
+
}
|
|
655
|
+
return nil, runErr
|
|
656
|
+
}
|
|
657
|
+
if st.stopReason == "cancelled" {
|
|
658
|
+
return nil, fmt.Errorf("本轮被取消(stopReason=cancelled)")
|
|
659
|
+
}
|
|
660
|
+
if strings.TrimSpace(st.text.String()) == "" && strings.TrimSpace(st.lastText) == "" {
|
|
661
|
+
return nil, fmt.Errorf("openclaw acp 返回空正文(stopReason=%s)", st.stopReason)
|
|
662
|
+
}
|
|
663
|
+
return st, nil
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// handleNotification 处理桥推来的会话通知(session/update)。
|
|
667
|
+
func (st *acpStreamState) handleNotification(f acpFrame) {
|
|
668
|
+
if f.Method != "session/update" {
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
var p struct {
|
|
672
|
+
Update acpUpdate `json:"update"`
|
|
673
|
+
}
|
|
674
|
+
if err := json.Unmarshal(f.Params, &p); err != nil {
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
u := p.Update
|
|
678
|
+
switch u.SessionUpdate {
|
|
679
|
+
case "agent_message_chunk":
|
|
680
|
+
if txt := acpTextOf(u.Content); txt != "" {
|
|
681
|
+
st.emit(StreamEvent{Kind: KindText, Text: txt})
|
|
682
|
+
}
|
|
683
|
+
case "agent_thought_chunk":
|
|
684
|
+
// 桥当前不发(兼容矩阵:thought streaming unsupported);留着以备将来支持。
|
|
685
|
+
if txt := acpTextOf(u.Content); txt != "" {
|
|
686
|
+
st.emit(StreamEvent{Kind: KindThinking, Text: txt})
|
|
687
|
+
}
|
|
688
|
+
case "tool_call":
|
|
689
|
+
/* 工具名:优先 `kind`(read / execute / edit …,短且稳定),其次 title。
|
|
690
|
+
⚠️ 别直接用 title —— openclaw 的 title 常是整条命令(实测「exec: command: echo hi」),
|
|
691
|
+
当卡片标题会很长且每次都变;命令本身已经在 rawInput(= 事件里的 args)里了。 */
|
|
692
|
+
name := firstNonEmpty(u.Kind, u.Title, "tool")
|
|
693
|
+
args := jsonText(u.RawInput)
|
|
694
|
+
st.emit(StreamEvent{Kind: KindToolUse, Text: args, Name: name, ID: u.ToolCallID})
|
|
695
|
+
if u.ToolCallID != "" {
|
|
696
|
+
st.toolIndex[u.ToolCallID] = len(st.tools)
|
|
697
|
+
st.tools = append(st.tools, ToolCall{Name: name, ID: u.ToolCallID, Args: args})
|
|
698
|
+
}
|
|
699
|
+
case "tool_call_update":
|
|
700
|
+
/* 只把「有产出」的更新当结果(pending / in_progress 是状态变化,不是一次产出),
|
|
701
|
+
并且**每个 toolCallId 只发一次**:桥对同一次调用会推多条带内容的更新
|
|
702
|
+
(实测同一次 exec 收到 content 的「hi\n」与 rawOutput 的「hi」两条)——
|
|
703
|
+
不去重就会在界面上出两张重复的工具卡。 */
|
|
704
|
+
if u.ToolCallID != "" && st.resultSeen[u.ToolCallID] {
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
text := acpToolResultText(u.Content, u.RawOutput)
|
|
708
|
+
if text == "" {
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
if u.ToolCallID != "" {
|
|
712
|
+
st.resultSeen[u.ToolCallID] = true
|
|
713
|
+
}
|
|
714
|
+
st.emit(StreamEvent{Kind: KindToolResult, Text: text, ID: u.ToolCallID})
|
|
715
|
+
if i, ok := st.toolIndex[u.ToolCallID]; ok {
|
|
716
|
+
if st.tools[i].Result == "" {
|
|
717
|
+
st.tools[i].Result = text
|
|
718
|
+
}
|
|
719
|
+
} else if u.ToolCallID != "" {
|
|
720
|
+
st.tools = append(st.tools, ToolCall{Name: firstNonEmpty(u.Kind, u.Title), ID: u.ToolCallID, Result: text})
|
|
721
|
+
}
|
|
722
|
+
default:
|
|
723
|
+
// plan / available_commands_update / current_mode_update / usage_update / user_message_chunk …
|
|
724
|
+
// 一律忽略:本引擎不消费它们(usage 从收尾 result 里也没有,token 计数保持 0)。
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// handleServerRequest 应答 server→client 请求。
|
|
729
|
+
// 目前只有 session/request_permission(exec 审批);其余一律回空 result,避免桥等我们。
|
|
730
|
+
func (st *acpStreamState) handleServerRequest(f acpFrame, send func(any)) {
|
|
731
|
+
var id any
|
|
732
|
+
if len(f.ID) > 0 {
|
|
733
|
+
_ = json.Unmarshal(f.ID, &id)
|
|
734
|
+
}
|
|
735
|
+
if f.Method != "session/request_permission" {
|
|
736
|
+
send(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{}})
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
var p struct {
|
|
740
|
+
ToolCall struct {
|
|
741
|
+
ToolCallID string `json:"toolCallId"`
|
|
742
|
+
Title string `json:"title"`
|
|
743
|
+
Kind string `json:"kind"`
|
|
744
|
+
} `json:"toolCall"`
|
|
745
|
+
Options []struct {
|
|
746
|
+
OptionID string `json:"optionId"`
|
|
747
|
+
Name string `json:"name"`
|
|
748
|
+
Kind string `json:"kind"`
|
|
749
|
+
} `json:"options"`
|
|
750
|
+
}
|
|
751
|
+
_ = json.Unmarshal(f.Params, &p)
|
|
752
|
+
want := acpPermissionPolicy(p.ToolCall.Kind)
|
|
753
|
+
pick := ""
|
|
754
|
+
for _, o := range p.Options {
|
|
755
|
+
if want == "allow" && strings.HasPrefix(o.Kind, "allow") {
|
|
756
|
+
pick = o.OptionID
|
|
757
|
+
break
|
|
758
|
+
}
|
|
759
|
+
if want == "reject" && strings.HasPrefix(o.Kind, "reject") {
|
|
760
|
+
pick = o.OptionID
|
|
761
|
+
break
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if pick == "" {
|
|
765
|
+
send(map[string]any{"jsonrpc": "2.0", "id": id,
|
|
766
|
+
"result": map[string]any{"outcome": map[string]any{"outcome": "cancelled"}}})
|
|
767
|
+
return
|
|
768
|
+
}
|
|
769
|
+
// 审批是**安全相关**的一件事:拒绝的原因必须让人看得见(不许静默)。
|
|
770
|
+
if want == "reject" {
|
|
771
|
+
fmt.Fprintf(os.Stderr, "magic-agent: openclaw 请求执行「%s」(kind=%s),按保守策略已拒绝(要放行设 MAGIC_AGENT_OPENCLAW_ACP_APPROVE=all)\n",
|
|
772
|
+
firstNonEmpty(p.ToolCall.Title, "(未命名)"), firstNonEmpty(p.ToolCall.Kind, "?"))
|
|
773
|
+
}
|
|
774
|
+
send(map[string]any{"jsonrpc": "2.0", "id": id,
|
|
775
|
+
"result": map[string]any{"outcome": map[string]any{"outcome": "selected", "optionId": pick}}})
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/* ────────────── 小工具 ────────────── */
|
|
779
|
+
|
|
780
|
+
// acpCwd 本轮会话的工作目录(`session/new` 的 cwd):桥按它把会话记到对应工作目录。
|
|
781
|
+
// 取当前进程 cwd —— CLI 层已按调用方的 workspace 切过目录(与本包其它引擎的子进程 cwd 语义一致);
|
|
782
|
+
// 拿不到(极少见)退到 "/":cwd 只是给会话做标记,写错不该让整轮失败。
|
|
783
|
+
func (e *OpenClawEngine) acpCwd() string {
|
|
784
|
+
if wd, err := os.Getwd(); err == nil && wd != "" {
|
|
785
|
+
return wd
|
|
786
|
+
}
|
|
787
|
+
return "/"
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// acpImageBlock 一张随提示词发出的图片(ACP 原生 image 内容块)。
|
|
791
|
+
type acpImageBlock struct {
|
|
792
|
+
Data string
|
|
793
|
+
MIME string
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// acpPromptParts 拆出提示词的文本部分与图片块。
|
|
797
|
+
// 图片能读成 ACP 原生块就原生发(模型真能看到图);读不了的附件退化成提示词里的绝对路径
|
|
798
|
+
// (= 今天的行为),并把原因回给调用方打一行 stderr。
|
|
799
|
+
func (e *OpenClawEngine) acpPromptParts(prompt string, req Request) (string, []acpImageBlock, string) {
|
|
800
|
+
if len(req.Attachments) == 0 {
|
|
801
|
+
return prompt, nil, ""
|
|
802
|
+
}
|
|
803
|
+
var images []acpImageBlock
|
|
804
|
+
var asPath []Attachment
|
|
805
|
+
for _, a := range req.Attachments {
|
|
806
|
+
if !a.IsImage() {
|
|
807
|
+
asPath = append(asPath, a)
|
|
808
|
+
continue
|
|
809
|
+
}
|
|
810
|
+
data, err := os.ReadFile(a.Path)
|
|
811
|
+
if err != nil {
|
|
812
|
+
asPath = append(asPath, a)
|
|
813
|
+
continue
|
|
814
|
+
}
|
|
815
|
+
images = append(images, acpImageBlock{
|
|
816
|
+
Data: base64.StdEncoding.EncodeToString(data),
|
|
817
|
+
MIME: a.MIME,
|
|
818
|
+
})
|
|
819
|
+
}
|
|
820
|
+
text := prompt
|
|
821
|
+
note := ""
|
|
822
|
+
if len(asPath) > 0 {
|
|
823
|
+
text = appendAttachmentSection(prompt, asPath)
|
|
824
|
+
note = fmt.Sprintf("%d 个附件无法作为图片发出,已退化为提示词里的路径(模型看不到图)", len(asPath))
|
|
825
|
+
}
|
|
826
|
+
return text, images, note
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// acpTextOf 从 ContentBlock 里取文本(非 text 类型返回空串)。
|
|
830
|
+
func acpTextOf(raw json.RawMessage) string {
|
|
831
|
+
if len(raw) == 0 {
|
|
832
|
+
return ""
|
|
833
|
+
}
|
|
834
|
+
var b acpContentBlock
|
|
835
|
+
if err := json.Unmarshal(raw, &b); err != nil {
|
|
836
|
+
return ""
|
|
837
|
+
}
|
|
838
|
+
if b.Type != "text" && b.Type != "" {
|
|
839
|
+
return ""
|
|
840
|
+
}
|
|
841
|
+
return b.Text
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// acpToolResultText 从 tool_call_update 的 content / rawOutput 里取可读产出。
|
|
845
|
+
// content 是 ToolCallContent 数组(每项 {type, content:{text}}),rawOutput 是工具原始输出。
|
|
846
|
+
func acpToolResultText(content json.RawMessage, rawOutput json.RawMessage) string {
|
|
847
|
+
var sb strings.Builder
|
|
848
|
+
if len(content) > 0 {
|
|
849
|
+
var arr []struct {
|
|
850
|
+
Type string `json:"type"`
|
|
851
|
+
Content json.RawMessage `json:"content"`
|
|
852
|
+
}
|
|
853
|
+
if err := json.Unmarshal(content, &arr); err == nil {
|
|
854
|
+
for _, c := range arr {
|
|
855
|
+
if t := acpTextOf(c.Content); t != "" {
|
|
856
|
+
if sb.Len() > 0 {
|
|
857
|
+
sb.WriteString("\n")
|
|
858
|
+
}
|
|
859
|
+
sb.WriteString(t)
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if sb.Len() == 0 && len(rawOutput) > 0 {
|
|
865
|
+
return jsonText(rawOutput)
|
|
866
|
+
}
|
|
867
|
+
return sb.String()
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// jsonText 把任意 JSON 值压成可读文本(字符串原样,其余紧凑 JSON)。
|
|
871
|
+
func jsonText(raw json.RawMessage) string {
|
|
872
|
+
if len(raw) == 0 {
|
|
873
|
+
return ""
|
|
874
|
+
}
|
|
875
|
+
var s string
|
|
876
|
+
if err := json.Unmarshal(raw, &s); err == nil {
|
|
877
|
+
return s
|
|
878
|
+
}
|
|
879
|
+
var buf bytes.Buffer
|
|
880
|
+
if err := json.Compact(&buf, raw); err == nil {
|
|
881
|
+
return buf.String()
|
|
882
|
+
}
|
|
883
|
+
return string(raw)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// asInt 把 JSON-RPC 的 id(可能是数字或字符串)转成 int(认不出返回 -1)。
|
|
887
|
+
func asInt(raw json.RawMessage) int {
|
|
888
|
+
if len(raw) == 0 {
|
|
889
|
+
return -1
|
|
890
|
+
}
|
|
891
|
+
var n int
|
|
892
|
+
if err := json.Unmarshal(raw, &n); err == nil {
|
|
893
|
+
return n
|
|
894
|
+
}
|
|
895
|
+
var s string
|
|
896
|
+
if err := json.Unmarshal(raw, &s); err == nil {
|
|
897
|
+
var v int
|
|
898
|
+
if _, err := fmt.Sscanf(s, "%d", &v); err == nil {
|
|
899
|
+
return v
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return -1
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// isACPBridgeUnavailable 判定「流式通道本身不可用」(可安全回退内嵌路径)。
|
|
906
|
+
// 依据:桥把 Gateway 连接错误原样打到 stderr —— 实测原文
|
|
907
|
+
// 「gateway connect failed: GatewayClientRequestError: scope upgrade pending approval」/
|
|
908
|
+
// 「ACP bridge failed: …」;另外 ACP 命令不存在(老版本 openclaw)也在这类。
|
|
909
|
+
func isACPBridgeUnavailable(stderr string, err error) bool {
|
|
910
|
+
msg := ""
|
|
911
|
+
if err != nil {
|
|
912
|
+
msg = err.Error()
|
|
913
|
+
}
|
|
914
|
+
hay := strings.ToLower(stderr + "\n" + msg)
|
|
915
|
+
for _, s := range []string{
|
|
916
|
+
"gateway connect failed",
|
|
917
|
+
"acp bridge failed",
|
|
918
|
+
"scope upgrade pending approval",
|
|
919
|
+
"not paired",
|
|
920
|
+
"unknown command",
|
|
921
|
+
"unknown option",
|
|
922
|
+
"no such file or directory",
|
|
923
|
+
"executable file not found",
|
|
924
|
+
} {
|
|
925
|
+
if strings.Contains(hay, s) {
|
|
926
|
+
return true
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return false
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// firstLine 取多行文本的第一行(stderr 摘要用)。
|
|
933
|
+
func firstLine(s string) string {
|
|
934
|
+
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
935
|
+
return s[:i]
|
|
936
|
+
}
|
|
937
|
+
return s
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// truncateForErr 截断长文本(错误信息里只留一段,避免刷屏)。
|
|
941
|
+
func truncateForErr(s string, max int) string {
|
|
942
|
+
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ⏎ "))
|
|
943
|
+
if len(s) <= max {
|
|
944
|
+
return s
|
|
945
|
+
}
|
|
946
|
+
return s[:max] + "…"
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// (streamStderrBuf.Tail 见 runcmd.go:这里只用到它的尾部摘要能力)
|