magic-agent 1.0.3 → 1.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +69 -1
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +260 -33
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +101 -10
- package/npm/bin/magic-client.js +118 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// limitnotice_test.go - 「限流/额度通告被当正文发回来」的判据与收尾验收(2026-10-10)。
|
|
4
|
+
//
|
|
5
|
+
// 钉三件事:
|
|
6
|
+
// ① splitLimitNotice 认得两条**实测原文**(中英各一,见 limitnotice.go 文件头);
|
|
7
|
+
// ② 不误伤正常回答(用户真在问「429 是什么意思」时模型的科普句);
|
|
8
|
+
// ③ 接上了:命中时**不发 turn_end、改发一条 turn_failed**(原因 = 通告原文,
|
|
9
|
+
// Text = 剥掉通告的正文),并且 Stream 返回错误(CLI 据此写失败信封、非零退出)。
|
|
10
|
+
//
|
|
11
|
+
// 为什么值得单测(而不是只靠真机跑一次):判断的两个方向都很难看 —— 判太宽 = 把
|
|
12
|
+
// 「用户真在问 429」的正常回答整段吞掉并报错;判太窄 = 又变成用户报的那个
|
|
13
|
+
// 「通告混在回复里」。真机复现要等一个额度用尽的时刻,单测一秒跑完且能钉住边界。
|
|
14
|
+
|
|
15
|
+
import (
|
|
16
|
+
"context"
|
|
17
|
+
"encoding/json"
|
|
18
|
+
"os"
|
|
19
|
+
"path/filepath"
|
|
20
|
+
"strings"
|
|
21
|
+
"testing"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const (
|
|
25
|
+
// 实测英文通告(前面没有换行,请求 id 尾巴跟在最后)。
|
|
26
|
+
limitNoticeEN = "429 usage exceeds frequency limit, but don't worry, your usage will reset at " +
|
|
27
|
+
"2026-10-11 10:32:59 UTC+8, alternatively, you can switch to the other models to continue using it."
|
|
28
|
+
limitNoticeENID = " (0l1a124d77d077ef98bb9427957af8f2/0l1a123c3-17c1-71f8-835c-3c15cbe5c7f95)"
|
|
29
|
+
// 实测中文通告(整条正文就是它)。
|
|
30
|
+
limitNoticeZH = "429 您的使用量已超出频率限制,将在 2026-09-19 17:49:56 UTC+8 重置," +
|
|
31
|
+
"您也可以切换其他模型继续使用。"
|
|
32
|
+
limitNoticeNarration = "Let me read the file's end."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
func TestSplitLimitNoticeRealSamples(t *testing.T) {
|
|
36
|
+
notice, body, ok := splitLimitNotice(limitNoticeNarration + limitNoticeEN + limitNoticeENID)
|
|
37
|
+
if !ok {
|
|
38
|
+
t.Fatalf("实测英文原文没认出来(粘在正文末尾、无换行、带请求 id 尾巴)")
|
|
39
|
+
}
|
|
40
|
+
if !strings.HasPrefix(notice, "429 usage exceeds") {
|
|
41
|
+
t.Errorf("notice = %q,应以状态码开头", notice)
|
|
42
|
+
}
|
|
43
|
+
if strings.Contains(notice, "0l1a") {
|
|
44
|
+
t.Errorf("notice = %q,请求 id 尾巴应被剥掉", notice)
|
|
45
|
+
}
|
|
46
|
+
if body != limitNoticeNarration {
|
|
47
|
+
t.Errorf("body = %q,want %q(通告之前的正文要完整留下)", body, limitNoticeNarration)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
notice, body, ok = splitLimitNotice(limitNoticeZH)
|
|
51
|
+
if !ok {
|
|
52
|
+
t.Fatalf("实测中文原文没认出来")
|
|
53
|
+
}
|
|
54
|
+
if !strings.HasPrefix(notice, "429 您的使用量") {
|
|
55
|
+
t.Errorf("notice = %q", notice)
|
|
56
|
+
}
|
|
57
|
+
if body != "" {
|
|
58
|
+
t.Errorf("body = %q,want 空串(这一轮只回了通告)", body)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 通告单独占一行(前面是正文)→ 正文照旧留下。
|
|
62
|
+
if _, b, ok := splitLimitNotice(limitNoticeNarration + "\n" + limitNoticeEN); !ok || b != limitNoticeNarration {
|
|
63
|
+
t.Errorf("通告独占一行时 body = %q ok=%v,want %q/true", b, ok, limitNoticeNarration)
|
|
64
|
+
}
|
|
65
|
+
// 超长正文末尾才是通告 → 判据只看通告那一段的长度,不该整段吞掉。
|
|
66
|
+
long := strings.Repeat("前面的正文。", 200)
|
|
67
|
+
if _, b, ok := splitLimitNotice(long + "\n" + limitNoticeEN); !ok || b != long {
|
|
68
|
+
t.Errorf("长正文末尾夹通告时没认出,或把正文吞了(body 长度 %d)", len(b))
|
|
69
|
+
}
|
|
70
|
+
// 各种状态码都算这一类。
|
|
71
|
+
for _, code := range []string{"502", "503", "402", "403"} {
|
|
72
|
+
if _, _, ok := splitLimitNotice(code + " quota exceeded, will reset at 2026-10-11 10:00:00 UTC+8"); !ok {
|
|
73
|
+
t.Errorf("状态码 %s 没认出来", code)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// 空值不抛。
|
|
77
|
+
for _, in := range []string{"", " \n ", "\r\n"} {
|
|
78
|
+
if _, _, ok := splitLimitNotice(in); ok {
|
|
79
|
+
t.Errorf("%q 不该命中", in)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func TestSplitLimitNoticeNoFalsePositive(t *testing.T) {
|
|
85
|
+
cases := map[string]string{
|
|
86
|
+
"科普句(无限流措辞)": "HTTP 429 是「请求过多」,服务端让你慢一点。",
|
|
87
|
+
"科普句(有措辞、无恢复时刻)": "HTTP 429 是限流,等额度重置即可。",
|
|
88
|
+
"科普句(有时刻、前面是普通空格)": "HTTP 429 是限流,额度会在 2026-10-11 重置。",
|
|
89
|
+
"正文里出现过 429、后面还有别的话": "日志里出现了 429。先看限流配置,额度在 2026-10-11 重置。建议改用另一个模型。",
|
|
90
|
+
"端口号不是状态码": "端口 8080 与 usage 有关。",
|
|
91
|
+
"普通回答": "已按你的要求改完 3 个文件。",
|
|
92
|
+
}
|
|
93
|
+
for name, in := range cases {
|
|
94
|
+
if _, _, ok := splitLimitNotice(in); ok {
|
|
95
|
+
t.Errorf("%s:不该被当成通告 → %q", name, in)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// codebuddyStreamWithResult 造一个假 codebuddy CLI:只吐一条给定正文的 result 行。
|
|
101
|
+
// 正文里带单引号(英文通告的 don't)时没法直接写进 shell 脚本,所以落成文件再 cat。
|
|
102
|
+
func codebuddyStreamWithResult(t *testing.T, text string) string {
|
|
103
|
+
t.Helper()
|
|
104
|
+
dir := t.TempDir()
|
|
105
|
+
line, err := json.Marshal(map[string]any{
|
|
106
|
+
"type": "result", "subtype": "success", "is_error": false,
|
|
107
|
+
"result": text, "model": "hy3", "session_id": "sid-notice",
|
|
108
|
+
})
|
|
109
|
+
if err != nil {
|
|
110
|
+
t.Fatal(err)
|
|
111
|
+
}
|
|
112
|
+
payload := filepath.Join(dir, "out.ndjson")
|
|
113
|
+
if err := os.WriteFile(payload, []byte(string(line)+"\n"), 0o644); err != nil {
|
|
114
|
+
t.Fatal(err)
|
|
115
|
+
}
|
|
116
|
+
cli := filepath.Join(dir, "codebuddy")
|
|
117
|
+
if err := os.WriteFile(cli, []byte("#!/bin/sh\ncat "+payload+"\n"), 0o755); err != nil {
|
|
118
|
+
t.Fatal(err)
|
|
119
|
+
}
|
|
120
|
+
return cli
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func TestCodeBuddyStreamLimitNoticeBecomesTurnFailed(t *testing.T) {
|
|
124
|
+
cli := codebuddyStreamWithResult(t, limitNoticeNarration+limitNoticeEN+limitNoticeENID)
|
|
125
|
+
e := &CodeBuddyEngine{BinPath: cli}
|
|
126
|
+
events, onEvent := collectEvents()
|
|
127
|
+
|
|
128
|
+
_, err := e.Stream(context.Background(), Request{
|
|
129
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
130
|
+
}, onEvent)
|
|
131
|
+
if err == nil {
|
|
132
|
+
t.Fatal("命中通告时必须返回错误(CLI 据此写失败信封、非零退出)")
|
|
133
|
+
}
|
|
134
|
+
if !strings.Contains(err.Error(), "429 usage exceeds") {
|
|
135
|
+
t.Errorf("err = %v,应带上通告原文(它才是给用户看的原因)", err)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
var failed []StreamEvent
|
|
139
|
+
for _, ev := range *events {
|
|
140
|
+
if ev.Kind == KindTurnEnd {
|
|
141
|
+
t.Errorf("命中通告时不该发 turn_end(它的语义是「成功收尾」,发了就是假成功)")
|
|
142
|
+
}
|
|
143
|
+
if ev.Kind == KindTurnFailed {
|
|
144
|
+
failed = append(failed, ev)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if len(failed) != 1 {
|
|
148
|
+
t.Fatalf("turn_failed 事件 %d 条,want 1", len(failed))
|
|
149
|
+
}
|
|
150
|
+
ev := failed[0]
|
|
151
|
+
if !strings.HasPrefix(ev.Reason, "429 usage exceeds") || ev.Reason != ev.Error {
|
|
152
|
+
t.Errorf("Reason/Error = %q / %q,want 都是通告原文", ev.Reason, ev.Error)
|
|
153
|
+
}
|
|
154
|
+
if ev.Text != limitNoticeNarration {
|
|
155
|
+
t.Errorf("Text = %q,want %q(消费方拿它覆盖气泡,通告不该留在回复里)", ev.Text, limitNoticeNarration)
|
|
156
|
+
}
|
|
157
|
+
if ev.SessionID != "sid-notice" {
|
|
158
|
+
t.Errorf("SessionID = %q,want sid-notice(会话锚点不能丢)", ev.SessionID)
|
|
159
|
+
}
|
|
160
|
+
if ev.Status != StatusFailed {
|
|
161
|
+
t.Errorf("Status = %q,want failed", ev.Status)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 正常回答不许受影响:照旧一条 turn_end,没有 turn_failed。
|
|
166
|
+
func TestCodeBuddyStreamNormalResultUnaffected(t *testing.T) {
|
|
167
|
+
cli := codebuddyStreamWithResult(t, "已经改完了,3 个文件。")
|
|
168
|
+
e := &CodeBuddyEngine{BinPath: cli}
|
|
169
|
+
events, onEvent := collectEvents()
|
|
170
|
+
|
|
171
|
+
res, err := e.Stream(context.Background(), Request{
|
|
172
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
173
|
+
}, onEvent)
|
|
174
|
+
if err != nil {
|
|
175
|
+
t.Fatalf("Stream: %v", err)
|
|
176
|
+
}
|
|
177
|
+
if res.Text != "已经改完了,3 个文件。" {
|
|
178
|
+
t.Errorf("Text = %q", res.Text)
|
|
179
|
+
}
|
|
180
|
+
var turnEnd, turnFailed int
|
|
181
|
+
for _, ev := range *events {
|
|
182
|
+
switch ev.Kind {
|
|
183
|
+
case KindTurnEnd:
|
|
184
|
+
turnEnd++
|
|
185
|
+
case KindTurnFailed:
|
|
186
|
+
turnFailed++
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if turnEnd != 1 || turnFailed != 0 {
|
|
190
|
+
t.Errorf("turn_end=%d turn_failed=%d,want 1/0", turnEnd, turnFailed)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// claude 走同一份协议 → 同一条判据。这里只钉「也接上了」。
|
|
195
|
+
func TestClaudeStreamLimitNoticeBecomesTurnFailed(t *testing.T) {
|
|
196
|
+
cli := codebuddyStreamWithResult(t, limitNoticeZH)
|
|
197
|
+
e := &ClaudeEngine{BinPath: cli}
|
|
198
|
+
events, onEvent := collectEvents()
|
|
199
|
+
|
|
200
|
+
_, err := e.Stream(context.Background(), Request{
|
|
201
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
202
|
+
}, onEvent)
|
|
203
|
+
if err == nil || !strings.Contains(err.Error(), "429 您的使用量") {
|
|
204
|
+
t.Fatalf("err = %v,want 带通告原文的错误", err)
|
|
205
|
+
}
|
|
206
|
+
for _, ev := range *events {
|
|
207
|
+
if ev.Kind == KindTurnEnd {
|
|
208
|
+
t.Error("claude 命中通告时也不该发 turn_end")
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -69,8 +69,8 @@ type LLMEngine struct {
|
|
|
69
69
|
// Name 实现 Engine。
|
|
70
70
|
func (e *LLMEngine) Name() string { return "llm" }
|
|
71
71
|
|
|
72
|
-
// DefaultLLMTimeout llm
|
|
73
|
-
const DefaultLLMTimeout =
|
|
72
|
+
// DefaultLLMTimeout llm 引擎单次尝试默认超时(2026-10-10 由 10 分钟统一上调到 1 小时)。
|
|
73
|
+
const DefaultLLMTimeout = time.Hour
|
|
74
74
|
|
|
75
75
|
// DefaultLLMModel 默认模型:空串 = 配置文件里的首条(有配置文件时,见 llmmodels.go),
|
|
76
76
|
// 否则交给 llm CLI 自己的默认模型。
|
package/internal/agent/models.go
CHANGED
|
@@ -16,8 +16,9 @@ package agent
|
|
|
16
16
|
// codex `codex debug models`(raw model catalog)
|
|
17
17
|
// openclaw `openclaw models list --json`
|
|
18
18
|
// arkclaw 无 —— 模型由网关按 claw_id 绑定,返回 ErrNoModelSource ②
|
|
19
|
-
// dsh
|
|
20
|
-
//
|
|
19
|
+
// dsh 配置文件里已配置的模型(无则回落 agent-default-model):0.1.7+ 读各
|
|
20
|
+
// profile 的 cordis.patch.yml,更早读 $DSH_HOME/settings.yaml;
|
|
21
|
+
// 输出 `route/model`,SDK 通道下就是 -m 的可取值
|
|
21
22
|
//
|
|
22
23
|
// ① claude 没有 models 子命令(--help 只有 agents/auth/doctor/mcp/plugin/
|
|
23
24
|
// project/... ),可用的模型标识只能从用户配置里读:settings.json 的
|
|
@@ -118,8 +118,9 @@ type OpenClawEngine struct {
|
|
|
118
118
|
// Name 实现 Engine。
|
|
119
119
|
func (e *OpenClawEngine) Name() string { return "openclaw" }
|
|
120
120
|
|
|
121
|
-
// DefaultOpenClawTimeout
|
|
122
|
-
|
|
121
|
+
// DefaultOpenClawTimeout 单次尝试默认超时(2026-10-10 由 10 分钟统一上调到 1 小时)。
|
|
122
|
+
// 未显式给 -t 时按它拼 openclaw 自身的 --timeout(见 buildArgsMode)。
|
|
123
|
+
const DefaultOpenClawTimeout = time.Hour
|
|
123
124
|
|
|
124
125
|
// bin 探测 openclaw CLI 路径(委托 cliBase 统一探测链)。
|
|
125
126
|
func (e *OpenClawEngine) bin() string {
|
|
@@ -510,7 +511,7 @@ func (e *OpenClawEngine) buildArgsMode(req Request, sessionID string, msgArgs []
|
|
|
510
511
|
}
|
|
511
512
|
args = append(args, "--timeout", fmt.Sprintf("%d", secs))
|
|
512
513
|
} else {
|
|
513
|
-
args = append(args, "--timeout", "
|
|
514
|
+
args = append(args, "--timeout", fmt.Sprintf("%d", int(DefaultOpenClawTimeout/time.Second)))
|
|
514
515
|
}
|
|
515
516
|
// 统一参数矩阵:MaxTokens / Temperature / Tools / JSONSchema 静默忽略(JSONSchema 走
|
|
516
517
|
// 输出后处理,Tools 是 openclaw 内嵌能力,调用方无法逐工具控)
|
|
@@ -660,6 +660,13 @@ func (e *OpenClawEngine) streamACPSession(
|
|
|
660
660
|
if strings.TrimSpace(st.text.String()) == "" && strings.TrimSpace(st.lastText) == "" {
|
|
661
661
|
return nil, fmt.Errorf("openclaw acp 返回空正文(stopReason=%s)", st.stopReason)
|
|
662
662
|
}
|
|
663
|
+
if st.stopReason == "max_tokens" {
|
|
664
|
+
// 截断收尾(2026-10-10,跨引擎渲染覆盖 P0-4):正文有了、但被 max_tokens
|
|
665
|
+
// 掐断 —— 补一条 notice,让用户知道「不是说完,是被掐断」。与 claude 流式
|
|
666
|
+
// 路径的 message_delta 分支同语义(见 stream.go);stopReason 只在收尾
|
|
667
|
+
// 出现一次,无需去重。
|
|
668
|
+
st.emit(StreamEvent{Kind: KindNotice, Text: "本轮输出在 max_tokens 上限处被截断"})
|
|
669
|
+
}
|
|
663
670
|
return st, nil
|
|
664
671
|
}
|
|
665
672
|
|
|
@@ -41,8 +41,8 @@ func TestOpenClawBuildArgsNewSession(t *testing.T) {
|
|
|
41
41
|
"--local",
|
|
42
42
|
"--agent main",
|
|
43
43
|
"--json",
|
|
44
|
-
"hi",
|
|
45
|
-
"--timeout
|
|
44
|
+
"hi", // FlattenPrompt 后正文是 "【用户】\nhi",至少含 "hi"
|
|
45
|
+
"--timeout 3600", // 未给 -t → 用 DefaultOpenClawTimeout(1 小时)
|
|
46
46
|
} {
|
|
47
47
|
if !strings.Contains(args, want) {
|
|
48
48
|
t.Errorf("want %q in args, got %q", want, args)
|
|
@@ -160,11 +160,12 @@ func PermissionTiers() []string {
|
|
|
160
160
|
//
|
|
161
161
|
// "flag:--permission-mode" claude / codebuddy / codebuddy-ai:CLI 有原生 --permission-mode,
|
|
162
162
|
// 沙箱与 autoMode 经 --settings 注入(同族 flag 面)
|
|
163
|
+
// "env:DSH_PERMISSION_MODE" dsh:档位走**子进程环境变量**(dsh 是 profile 启动器,
|
|
164
|
+
// 没有逐调用的权限 flag;shipped 的 sdk profile 直接读
|
|
165
|
+
// 这个变量决定沙箱模式与审批策略)。映射见 dshPermissionMode。
|
|
163
166
|
// "none" 其余引擎暂未接线(trae 的档位在 yaml 配置里、codex 是
|
|
164
167
|
// 「沙箱 × 审批」两轴、llm 无工具语义、openclaw 走
|
|
165
|
-
// exec.mode 五档、arkclaw 由网关侧决定;
|
|
166
|
-
// read-only / workspace-write 两个配置层预设,没有
|
|
167
|
-
// 「沙箱关闭 / 无审批」那一档,四档无法一一落地;
|
|
168
|
+
// exec.mode 五档、arkclaw 由网关侧决定;
|
|
168
169
|
// codebuddy-gateway 的档位由**网关进程**的 --permission-mode
|
|
169
170
|
// 决定,且网关会把远程任务的权限强制切到 bypassPermissions
|
|
170
171
|
// —— per-call 传四档没有落地通道)
|
|
@@ -176,6 +177,8 @@ func PermissionSupportOf(engine string) string {
|
|
|
176
177
|
switch engine {
|
|
177
178
|
case "claude", "codebuddy", "codebuddy-ai":
|
|
178
179
|
return "flag:--permission-mode"
|
|
180
|
+
case "dsh":
|
|
181
|
+
return "env:" + DshPermissionModeEnv
|
|
179
182
|
default:
|
|
180
183
|
return "none"
|
|
181
184
|
}
|
|
@@ -205,18 +208,102 @@ func claudePermissionMode(t PermissionTier) string {
|
|
|
205
208
|
return "default"
|
|
206
209
|
}
|
|
207
210
|
|
|
208
|
-
//
|
|
211
|
+
// DshPermissionModeEnv 是 dsh 侧选择沙箱档位的环境变量。
|
|
212
|
+
//
|
|
213
|
+
// 为什么不是 CLI flag:dsh 是 **profile 启动器**(`dsh --profile <name>`),它的
|
|
214
|
+
// 权限旋钮不在命令行上。shipped 的 `sdk` profile 里两处配置**直接读这个变量**
|
|
215
|
+
// (2026-10-14 用 `dsh --profile sdk --dump-config` 核对,本机 dsh 0.2.0-rc.2):
|
|
216
|
+
//
|
|
217
|
+
// - id: sandbox-policy (@deepseek-ai/dsh-sandbox-policy)
|
|
218
|
+
// mode: process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
|
|
219
|
+
// - id: approval (@deepseek-ai/dsh-user-approval)
|
|
220
|
+
// policy: DSH_PERMISSION_MODE === 'danger-full-access' ? 'never' : 'ask'
|
|
221
|
+
//
|
|
222
|
+
// 取值即 dsh 的 `SandboxMode` 封闭词表(见 @deepseek-ai/dsh-sandbox-policy 的
|
|
223
|
+
// README:read-only / workspace-write / danger-full-access)。
|
|
224
|
+
const DshPermissionModeEnv = "DSH_PERMISSION_MODE"
|
|
225
|
+
|
|
226
|
+
// dshPermissionMode 把四档映射到 dsh 的 SandboxMode;空档位返回空串(= 不注入)。
|
|
227
|
+
//
|
|
228
|
+
// 核实来源(2026-10-14,本机 dsh 0.2.0-rc.2):
|
|
229
|
+
//
|
|
230
|
+
// - `dsh --profile sdk --dump-config` 的 permission-presets 预设表:
|
|
231
|
+
// read-only → (read-only, ask) / workspace-write → (workspace-write, ask) /
|
|
232
|
+
// **danger-full-access → (danger-full-access, never)**
|
|
233
|
+
//
|
|
234
|
+
// - @deepseek-ai/dsh-permission-presets README:一个预设 = 沙箱模式 + 审批策略的捆绑
|
|
235
|
+
//
|
|
236
|
+
// 档位 DSH_PERMISSION_MODE 成效(沙箱 / 审批)
|
|
237
|
+
// manual read-only read-only / ask
|
|
238
|
+
// accept-edits workspace-write workspace-write / ask
|
|
239
|
+
// auto workspace-write workspace-write / ask
|
|
240
|
+
// full danger-full-access danger-full-access / never ← 「沙箱关闭 + 无审批」
|
|
241
|
+
//
|
|
242
|
+
// ⚠️ 两处**不假装等价**的差异,写在这里以免下一个人误读:
|
|
243
|
+
//
|
|
244
|
+
// 1. dsh 的审批策略只有 `ask` / `never` 两种,**没有 LLM 分类器** —— 第 3 档
|
|
245
|
+
// 在 dsh 上退化成「沙箱内放行」,不再是「越界交 Guardian 判定」。
|
|
246
|
+
// 2. `ask` 要求 Host 侧组合一个应答者,而 magic-agent 走的 SDK 通道**拿不到审批请求**:
|
|
247
|
+
// 官方 @deepseek-ai/dsh-sdk-protocol README 的「已知限制」原文是
|
|
248
|
+
// 「server→client 请求是未使用的能力——传输层支持,但服务器从不发送;
|
|
249
|
+
// Python SDK 的应答接口为未来审批流程预留」,协议里也确实没有审批方法。
|
|
250
|
+
// 加上 @deepseek-ai/dsh-user-approval「应答者缺失…返回 unavailable,使操作以
|
|
251
|
+
// 拒绝方式关闭」,于是第 1/2/3 档下**越界/提权操作会被 fail-closed 拒绝**。
|
|
252
|
+
// 这不是静默降级(拒绝是可见的工具结果),是 dsh 在无应答者时的既定语义;
|
|
253
|
+
// 要放行越界操作就用第 4 档。
|
|
254
|
+
//
|
|
255
|
+
// ⚠️ 空档位必须返回空串、**不能**走 permissionOrDefault:DefaultPermissionTier 是 full,
|
|
256
|
+
// 若在这里兜默认值,`magic-agent -e dsh`(未显式传档)会静默把 dsh 的沙箱整个关掉。
|
|
257
|
+
func dshPermissionMode(t PermissionTier) string {
|
|
258
|
+
if strings.TrimSpace(string(t)) == "" {
|
|
259
|
+
return ""
|
|
260
|
+
}
|
|
261
|
+
switch permissionOrDefault(t) {
|
|
262
|
+
case PermissionManual:
|
|
263
|
+
return "read-only"
|
|
264
|
+
case PermissionAcceptEdits, PermissionAuto:
|
|
265
|
+
return "workspace-write"
|
|
266
|
+
case PermissionFull:
|
|
267
|
+
return "danger-full-access"
|
|
268
|
+
}
|
|
269
|
+
return ""
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// dshPermissionEnv 返回要追加给 dsh 子进程的环境变量;空档位 → nil(用 dsh 自己的默认)。
|
|
273
|
+
// 追加在继承环境之后,Go exec 对重复 key 取后者 → 覆盖调用方环境里可能存在的同名值。
|
|
274
|
+
func dshPermissionEnv(t PermissionTier) []string {
|
|
275
|
+
mode := dshPermissionMode(t)
|
|
276
|
+
if mode == "" {
|
|
277
|
+
return nil
|
|
278
|
+
}
|
|
279
|
+
return []string{DshPermissionModeEnv + "=" + mode}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// sandboxSettings 构造 settings.sandbox 子对象。
|
|
283
|
+
//
|
|
284
|
+
// 第 1/2/3 档(manual / accept-edits / auto)—— 返回 `{"enabled": true, ...}`,
|
|
285
|
+
// 配合 --permission-mode 让模型知道「沙箱里运行」+ 哪些操作免确认
|
|
286
|
+
// 第 4 档(full / bypassPermissions)—— **返回 nil,让外层不写 sandbox 段
|
|
287
|
+
// 整段都不下发**:
|
|
288
|
+
// 1. bypassPermissions 与 sandbox 互斥(开了也是空跑);
|
|
289
|
+
// 2. 实测 2026-10-01:claude CLI 在 tools=default +
|
|
290
|
+
// bypassPermissions + `--settings {"sandbox":{"enabled":false}}`
|
|
291
|
+
// 组合下报「Invalid JSON provided to --settings」、
|
|
292
|
+
// exit 0 零输出。bypassPermissions 本身就意味着不沙箱,
|
|
293
|
+
// 显式写 enabled=false 反而触发那个 bug。
|
|
294
|
+
// 3. 用户的 ~/.claude/settings.json 若开了 sandbox,
|
|
295
|
+
// 第 4 档「--settings 优先级更高」这条契约靠的是把整段
|
|
296
|
+
// 覆盖成空 sandbox,不是不写。两段都不写 = 沙箱启用 + 第 4 档,
|
|
297
|
+
// 是 bug。
|
|
298
|
+
// 见 magic-stock `.workbuddy/memory/2026-10-01.md` 的「Invalid JSON
|
|
299
|
+
// provided to --settings」一节。
|
|
209
300
|
//
|
|
210
301
|
// engine 用于区分两家文档确认过的键集:CodeBuddy 文档未列 `failIfUnavailable`,
|
|
211
302
|
// 未知键的行为没有保证,故只在 claude 上注入。
|
|
212
303
|
func sandboxSettings(tier PermissionTier, opts PermissionOptions, engine string) map[string]any {
|
|
213
304
|
if permissionOrDefault(tier) == PermissionFull {
|
|
214
|
-
// 第 4
|
|
215
|
-
|
|
216
|
-
// 若用户的 ~/.claude/settings.json 里开着 sandbox.enabled=true,
|
|
217
|
-
// 不写这一项会让第 4 档继承到沙箱,与「沙箱关闭」的档位承诺矛盾。
|
|
218
|
-
// --settings 属于 flag 层,优先级高于 user settings,故能覆盖它。
|
|
219
|
-
return map[string]any{"enabled": false}
|
|
305
|
+
// 第 4 档:sandbox 整段都不写(见上方注释)
|
|
306
|
+
return nil
|
|
220
307
|
}
|
|
221
308
|
sb := map[string]any{
|
|
222
309
|
"enabled": true,
|
|
@@ -299,7 +386,9 @@ func agentSettingsPayload(req Request, engine string) (string, bool) {
|
|
|
299
386
|
|
|
300
387
|
// 沙箱 / autoMode / 权限规则只在真会调用工具时注入。
|
|
301
388
|
if !toolsIsOff(req) {
|
|
302
|
-
|
|
389
|
+
if sb := sandboxSettings(tier, opts, engine); sb != nil {
|
|
390
|
+
settings["sandbox"] = sb
|
|
391
|
+
}
|
|
303
392
|
if am := autoModeSettings(tier, opts); am != nil {
|
|
304
393
|
settings["autoMode"] = am
|
|
305
394
|
}
|