magic-agent 2.4.3 → 2.6.3
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 +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -3
- 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 +11 -0
- package/package.json +14 -3
package/internal/agent/env.go
CHANGED
|
@@ -6,8 +6,10 @@ package agent
|
|
|
6
6
|
// 「只能属于父进程」的变量。见 childEnvDenylist。
|
|
7
7
|
|
|
8
8
|
import (
|
|
9
|
+
"net"
|
|
9
10
|
"os"
|
|
10
11
|
"strings"
|
|
12
|
+
"time"
|
|
11
13
|
)
|
|
12
14
|
|
|
13
15
|
// serverEnvPrefix 剔除的前缀:父会话内置 HTTP 服务的监听地址。
|
|
@@ -98,24 +100,194 @@ var envDenylist = map[string]bool{
|
|
|
98
100
|
// - CODEBUDDY_CONFIG_DIR / CODEBUDDY_CODE_IMAGE_COMPRESSION_MAX_DIMENSION /
|
|
99
101
|
// CODEBUDDY_SAFE_DELETE_BULK_GUARD —— 宿主配置/沙箱细节,claude/codebuddy
|
|
100
102
|
// 不需要。
|
|
103
|
+
// Electron 的「拿 Electron 当 node 跑」开关。宿主应用(WorkBuddy)把它设成 1,
|
|
104
|
+
// 子进程继承后,任何 Electron 可执行文件都会以 node 模式启动 —— 引擎 CLI 不需要
|
|
105
|
+
// 这个身份,留着只会把宿主的启动链路带进来(2026-10-26 实测,见下方 NODE_OPTIONS)。
|
|
106
|
+
"ELECTRON_RUN_AS_NODE": true,
|
|
107
|
+
|
|
101
108
|
"CODEBUDDY_MCP_CONFIG": true,
|
|
102
109
|
"CODEBUDDY_CONFIG_DIR": true,
|
|
103
110
|
"CODEBUDDY_CODE_IMAGE_COMPRESSION_MAX_DIMENSION": true,
|
|
104
111
|
"CODEBUDDY_SAFE_DELETE_BULK_GUARD": true,
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
// hostNodeShim 判断一条 NODE_OPTIONS 是否是**宿主 Electron 应用**注入的垫片。
|
|
115
|
+
//
|
|
116
|
+
// 2026-10-26 实测:从 WorkBuddy 的终端里重启 magic-client,父进程环境带着
|
|
117
|
+
//
|
|
118
|
+
// NODE_OPTIONS=--require=".../WorkBuddy.app/Contents/Resources/app.asar.unpacked/
|
|
119
|
+
// cli/vendor/shim/node-language-shim.cjs"
|
|
120
|
+
// ELECTRON_RUN_AS_NODE=1
|
|
121
|
+
//
|
|
122
|
+
// 于是**每一个**引擎子进程(node 系的 dsh/codex/claude,连 llm 的 node 包装)启动时
|
|
123
|
+
// 都被迫加载那个 shim;shim 要去连 WorkBuddy 的 broker,而 broker 只在 WorkBuddy
|
|
124
|
+
// 运行时存在 —— 报 `connect failed: Connection refused`,任务全部 failed。
|
|
125
|
+
// 不是引擎坏了,是继承了一份不属于它的启动垫片。
|
|
126
|
+
//
|
|
127
|
+
// 只认「.asar / WorkBuddy」这两个特征:用户自己设的 `--max-old-space-size` 之类
|
|
128
|
+
// 照常保留,别误伤。
|
|
129
|
+
func hostNodeShim(val string) bool {
|
|
130
|
+
v := strings.ToLower(val)
|
|
131
|
+
return strings.Contains(v, ".asar") || strings.Contains(v, "workbuddy")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// hostShimDir 判断 PATH 里的一段是不是宿主 Electron 应用塞进来的「假命令」目录。
|
|
135
|
+
//
|
|
136
|
+
// 2026-10-26 真机实测(比 NODE_OPTIONS 更隐蔽的那个):从 WorkBuddy 的终端起
|
|
137
|
+
// magic-client,继承到的 PATH **最前面**是
|
|
138
|
+
//
|
|
139
|
+
// /Applications/WorkBuddy.app/.../cli/vendor/shim/brokered-bin
|
|
140
|
+
//
|
|
141
|
+
// 里面有 29 个被替换掉的基础命令(cat / cp / dd / chmod / 7z …)。命中它们之后,
|
|
142
|
+
// 那个 shell 脚本会 require `node-brokered-fs-shim.cjs`,去连 WorkBuddy 的 broker;
|
|
143
|
+
// broker 只在 WorkBuddy 运行时存在 —— 于是引擎一调 `cat` 就
|
|
144
|
+
// `connect failed: Connection refused`,整条任务 failed。
|
|
145
|
+
//
|
|
146
|
+
// 只认 `.asar` 与 `brokered-bin` 两个特征,用户自己的 PATH 顺序原样保留。
|
|
147
|
+
func hostShimDir(seg string) bool {
|
|
148
|
+
v := strings.ToLower(seg)
|
|
149
|
+
return strings.Contains(v, ".asar") || strings.Contains(v, "brokered-bin")
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// stripHostShimPath 从 PATH 值里摘掉宿主垫片目录,其余段与顺序不动。
|
|
153
|
+
func stripHostShimPath(val string) string {
|
|
154
|
+
parts := strings.Split(val, string(os.PathListSeparator))
|
|
155
|
+
kept := parts[:0]
|
|
156
|
+
for _, p := range parts {
|
|
157
|
+
if p == "" || hostShimDir(p) {
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
kept = append(kept, p)
|
|
161
|
+
}
|
|
162
|
+
return strings.Join(kept, string(os.PathListSeparator))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// proxyVarNames 参与「死端口」检查的代理变量名。
|
|
166
|
+
//
|
|
167
|
+
// 大小写两套都列:unix 上 HTTP_PROXY 与 http_proxy 是**两个不同的变量**,
|
|
168
|
+
// 而 Node / Go / curl 各读各的(Node 优先小写、Go 优先大写、curl 只读小写)
|
|
169
|
+
// —— 只清一套等于没清。
|
|
170
|
+
//
|
|
171
|
+
// NO_PROXY **不在列**:它是「绕过名单」而不是代理地址,按地址去探测没有意义。
|
|
172
|
+
var proxyVarNames = []string{
|
|
173
|
+
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
|
|
174
|
+
"http_proxy", "https_proxy", "all_proxy",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// proxyProbeTimeout 探测本机代理端口的上限。
|
|
178
|
+
//
|
|
179
|
+
// 回环地址只有两种结局:立刻连上,或立刻 ECONNREFUSED —— 不存在「慢慢超时」。
|
|
180
|
+
// 留 100ms 只为兜个别平台上 SYN 被丢弃的情况,别让探测本身变成新的延迟。
|
|
181
|
+
const proxyProbeTimeout = 100 * time.Millisecond
|
|
182
|
+
|
|
183
|
+
// loopbackProxyAddr 从一条代理变量值里取出 host:port;**不是本机回环**就返回空。
|
|
184
|
+
//
|
|
185
|
+
// 只认回环是刻意的保守选择:沙箱 / 隧道环境把出网流量转到 127.0.0.1:<会漂移的端口>,
|
|
186
|
+
// 进程启动时拿到的快照一旦过期就是这个状态(见 strippedProxyKeys 的实测数据)。
|
|
187
|
+
// 非回环(公司代理、远程 squid)**一律不碰** —— 那可能只是这一瞬间抖动,清掉会让
|
|
188
|
+
// 本来能出网的引擎彻底断网,代价远大于收益。
|
|
189
|
+
func loopbackProxyAddr(val string) string {
|
|
190
|
+
v := strings.TrimSpace(val)
|
|
191
|
+
if v == "" {
|
|
192
|
+
return ""
|
|
193
|
+
}
|
|
194
|
+
if i := strings.Index(v, "://"); i >= 0 { // 去 scheme:http:// https:// socks5://
|
|
195
|
+
v = v[i+3:]
|
|
196
|
+
}
|
|
197
|
+
if i := strings.IndexAny(v, "/?#"); i >= 0 { // 去 path / query
|
|
198
|
+
v = v[:i]
|
|
199
|
+
}
|
|
200
|
+
if i := strings.LastIndexByte(v, '@'); i >= 0 { // 去 user:pass@
|
|
201
|
+
v = v[i+1:]
|
|
202
|
+
}
|
|
203
|
+
host, port, err := net.SplitHostPort(v)
|
|
204
|
+
if err != nil || port == "" {
|
|
205
|
+
return ""
|
|
206
|
+
}
|
|
207
|
+
host = strings.Trim(host, "[]")
|
|
208
|
+
if !strings.EqualFold(host, "localhost") {
|
|
209
|
+
if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
|
|
210
|
+
return ""
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return net.JoinHostPort(host, port)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// proxyAddrDead 本机 addr 上是否已经没人监听。
|
|
217
|
+
func proxyAddrDead(addr string) bool {
|
|
218
|
+
c, err := net.DialTimeout("tcp", addr, proxyProbeTimeout)
|
|
219
|
+
if err != nil {
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
c.Close()
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// strippedProxyKeys 返回需要**剔除**的代理变量名(可能为空)。
|
|
227
|
+
//
|
|
228
|
+
// 2026-10-26 实测(本机 dsh 0.2.0-rc.2,同一句提示词,各跑两遍):
|
|
229
|
+
//
|
|
230
|
+
// 代理端口活着 → 首个输出 2.0~2.5s,内容正常
|
|
231
|
+
// 代理端口已死 → 首个输出 **15.4s / 16.7s**,内容是 dsh: TRANSPORT: Connection error.
|
|
232
|
+
// 清掉代理变量 → 首个输出 **2.0s / 2.1s**,内容正常
|
|
233
|
+
//
|
|
234
|
+
// 一次失败的代理连接要白等十几秒才报错 —— 这正是「会话发出去半天没响应」。
|
|
235
|
+
// 而沙箱代理端口随会话漂移(旧会话留下的进程快照必然过期),所以这个状态很常见。
|
|
236
|
+
// 15 秒 vs 至多一次 100ms 的探测,这个取舍没什么可犹豫的。
|
|
237
|
+
//
|
|
238
|
+
// 注意:**只有本机回环地址才会去探**,且同一地址在一次调用里只探一次。
|
|
239
|
+
func strippedProxyKeys() []string {
|
|
240
|
+
dead := map[string]bool{} // addr → 已确认没人监听
|
|
241
|
+
var out []string
|
|
242
|
+
for _, name := range proxyVarNames {
|
|
243
|
+
addr := loopbackProxyAddr(os.Getenv(name))
|
|
244
|
+
if addr == "" {
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
v, seen := dead[addr]
|
|
248
|
+
if !seen {
|
|
249
|
+
v = proxyAddrDead(addr)
|
|
250
|
+
dead[addr] = v
|
|
251
|
+
}
|
|
252
|
+
if v {
|
|
253
|
+
out = append(out, name)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
107
259
|
// environ 返回供子 CLI 继承的环境变量(已剔除父进程专属项)。
|
|
108
260
|
func environ() []string {
|
|
109
261
|
src := os.Environ()
|
|
110
262
|
out := make([]string, 0, len(src))
|
|
263
|
+
/* 先算出「指向死端口的本机代理」,循环里直接不继承。
|
|
264
|
+
子进程环境里就**不该出现**这个变量 —— 只补空值不行:空值虽然能压下
|
|
265
|
+
exec 的「重复 key 取后者」,但环境块里仍留着一条 `HTTP_PROXY=http://...`
|
|
266
|
+
的原值,任何「先写入者胜」或自己拼 map 的读取方都还会看到死地址。 */
|
|
267
|
+
deadProxy := map[string]bool{}
|
|
268
|
+
for _, k := range strippedProxyKeys() {
|
|
269
|
+
deadProxy[k] = true
|
|
270
|
+
}
|
|
111
271
|
for _, kv := range src {
|
|
112
272
|
name, _, ok := strings.Cut(kv, "=")
|
|
113
273
|
if !ok {
|
|
114
274
|
continue
|
|
115
275
|
}
|
|
116
|
-
if envDenied(name) {
|
|
276
|
+
if envDenied(name) || deadProxy[name] {
|
|
117
277
|
continue
|
|
118
278
|
}
|
|
279
|
+
if name == "NODE_OPTIONS" && hostNodeShim(kv) {
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
/* PATH 不能整条丢(引擎全靠它找 node/python),只摘掉宿主的假命令目录。 */
|
|
283
|
+
if name == "PATH" {
|
|
284
|
+
if _, v, ok := strings.Cut(kv, "="); ok {
|
|
285
|
+
if cleaned := stripHostShimPath(v); cleaned != v {
|
|
286
|
+
out = append(out, "PATH="+cleaned)
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
119
291
|
out = append(out, kv)
|
|
120
292
|
}
|
|
121
293
|
return out
|
|
@@ -9,6 +9,7 @@ package agent
|
|
|
9
9
|
// 本文件锁定「SERVER__ 前缀必须被剔除」这一行为,防止回归。
|
|
10
10
|
|
|
11
11
|
import (
|
|
12
|
+
"net"
|
|
12
13
|
"os"
|
|
13
14
|
"strings"
|
|
14
15
|
"testing"
|
|
@@ -146,3 +147,116 @@ func TestEnvDenied(t *testing.T) {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
}
|
|
150
|
+
|
|
151
|
+
// ── 本机代理端口已死时的剔除(2026-10-26)─────────────────────────
|
|
152
|
+
//
|
|
153
|
+
// 背景:沙箱 / 隧道环境把出网流量转到 127.0.0.1:<会漂移的端口>,进程启动时
|
|
154
|
+
// 拿到的快照一旦过期,子 CLI 每次请求都要白等十几秒才报
|
|
155
|
+
// `dsh: TRANSPORT: Connection error.` —— 用户看到的就是「发出去半天没响应」。
|
|
156
|
+
// 实测:代理活着 2.0s、代理已死 15.4s、清掉代理 2.0s。
|
|
157
|
+
|
|
158
|
+
func TestLoopbackProxyAddr(t *testing.T) {
|
|
159
|
+
cases := []struct{ in, want string }{
|
|
160
|
+
{"http://127.0.0.1:54107", "127.0.0.1:54107"},
|
|
161
|
+
{"http://127.0.0.1:54107/", "127.0.0.1:54107"},
|
|
162
|
+
{"https://127.0.0.1:8080", "127.0.0.1:8080"},
|
|
163
|
+
{"socks5://127.0.0.1:1080", "127.0.0.1:1080"},
|
|
164
|
+
{"http://user:pass@127.0.0.1:3128", "127.0.0.1:3128"},
|
|
165
|
+
{"http://localhost:8080", "localhost:8080"},
|
|
166
|
+
{"http://[::1]:8080", "[::1]:8080"},
|
|
167
|
+
{"127.0.0.1:9999", "127.0.0.1:9999"},
|
|
168
|
+
// 非回环一律返回空:公司代理 / 远程 squid 不能因为一次抖动就断网。
|
|
169
|
+
{"http://proxy.corp.example:8080", ""},
|
|
170
|
+
{"http://10.0.0.1:8080", ""},
|
|
171
|
+
{"", ""},
|
|
172
|
+
{" ", ""},
|
|
173
|
+
{"http://127.0.0.1", ""}, // 没端口
|
|
174
|
+
{"not a url", ""},
|
|
175
|
+
}
|
|
176
|
+
for _, c := range cases {
|
|
177
|
+
if got := loopbackProxyAddr(c.in); got != c.want {
|
|
178
|
+
t.Errorf("loopbackProxyAddr(%q) = %q, want %q", c.in, got, c.want)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 死端口必须从环境块里**消失**(不是补空值:留着原值会让「先写入者胜」的
|
|
184
|
+
// 读取方仍看到死地址)。大小写两套都要处理(Node/Go/curl 各读各的)。
|
|
185
|
+
func TestEnvironStripsDeadLoopbackProxy(t *testing.T) {
|
|
186
|
+
dead := "http://127.0.0.1:1" // 1 号端口不会有服务
|
|
187
|
+
for _, k := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} {
|
|
188
|
+
t.Setenv(k, dead)
|
|
189
|
+
}
|
|
190
|
+
for _, kv := range environ() {
|
|
191
|
+
name, val, _ := strings.Cut(kv, "=")
|
|
192
|
+
if isProxyNameForTest(name) && val == dead {
|
|
193
|
+
t.Errorf("%s 仍指向死端口:%s", name, kv)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 活着的回环代理必须**原样保留** —— 断掉用户本来能用的代理是更严重的事故。
|
|
199
|
+
func TestEnvironKeepsLiveLoopbackProxy(t *testing.T) {
|
|
200
|
+
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
201
|
+
if err != nil {
|
|
202
|
+
t.Skipf("无法监听回环端口:%v", err)
|
|
203
|
+
}
|
|
204
|
+
defer ln.Close()
|
|
205
|
+
go func() {
|
|
206
|
+
for {
|
|
207
|
+
c, err := ln.Accept()
|
|
208
|
+
if err != nil {
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
c.Close()
|
|
212
|
+
}
|
|
213
|
+
}()
|
|
214
|
+
live := "http://" + ln.Addr().String()
|
|
215
|
+
t.Setenv("HTTP_PROXY", live)
|
|
216
|
+
t.Setenv("http_proxy", live)
|
|
217
|
+
|
|
218
|
+
for _, kv := range environ() {
|
|
219
|
+
name, val, _ := strings.Cut(kv, "=")
|
|
220
|
+
if name == "HTTP_PROXY" || name == "http_proxy" {
|
|
221
|
+
if val != live {
|
|
222
|
+
t.Fatalf("%s 本来可用却被改了:want %q, got %q", name, live, val)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 非回环代理一律不动 —— 哪怕连不上,也可能是这一瞬间抖动。
|
|
229
|
+
func TestEnvironKeepsRemoteProxy(t *testing.T) {
|
|
230
|
+
remote := "http://proxy.invalid.example:3128"
|
|
231
|
+
t.Setenv("HTTP_PROXY", remote)
|
|
232
|
+
for _, kv := range environ() {
|
|
233
|
+
name, val, _ := strings.Cut(kv, "=")
|
|
234
|
+
if name == "HTTP_PROXY" && val != remote {
|
|
235
|
+
t.Fatalf("非回环代理不该被改:want %q, got %q", remote, val)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// NO_PROXY 是绕过名单,不是代理地址,绝不能被当成死代理剔掉。
|
|
241
|
+
func TestEnvironKeepsNoProxy(t *testing.T) {
|
|
242
|
+
t.Setenv("NO_PROXY", "localhost,127.0.0.1,.internal")
|
|
243
|
+
found := false
|
|
244
|
+
for _, kv := range environ() {
|
|
245
|
+
if kv == "NO_PROXY=localhost,127.0.0.1,.internal" {
|
|
246
|
+
found = true
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if !found {
|
|
250
|
+
t.Fatal("NO_PROXY 被误删了")
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// isProxyNameForTest 测试侧的白名单判断(生产侧的白名单在 env.go)。
|
|
255
|
+
func isProxyNameForTest(name string) bool {
|
|
256
|
+
switch name {
|
|
257
|
+
case "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
|
|
258
|
+
"http_proxy", "https_proxy", "all_proxy":
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// fallback.go - 主引擎失败时降级到备用引擎(2026-10-24;2026-10-26 按真机报障修口径)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要它:额度用尽(如 dsh 的 402 Token Plan 上限)、临时不可用、单模型故障
|
|
6
|
+
// 都不该让一整轮直接失败 —— 用户在一次设置里就能指定「主 + 备」,主的那条不行时
|
|
7
|
+
// 自动换备用再跑一次。
|
|
8
|
+
//
|
|
9
|
+
// 2026-10-26 用户报「主备模型还没真的发挥作用」,真机作业记录给出了三个原因,逐个钉住:
|
|
10
|
+
//
|
|
11
|
+
// 1. **只当引擎变了才降级**:`fallback_engine == 主引擎` 直接被判「没配备用」。
|
|
12
|
+
// 手机上最常见的配法恰恰是同引擎换模型(dsh 的 modelverse 主、volcengine 备;
|
|
13
|
+
// codebuddy 一个账号换另一个模型)—— 以前这一路**永远不会降级**。
|
|
14
|
+
// 2. **备用模型被带到了备用引擎上**:清单是各引擎自己的,把 codebuddy 的模型喂给 dsh
|
|
15
|
+
// 只会得到「未知模型」。以前又恰好因为第 1 条,这条错路没被走到。
|
|
16
|
+
// 现在:同引擎沿用备用模型;换引擎时备用模型不往下传(FallbackDown),引擎用自己的默认。
|
|
17
|
+
// 3. **主引擎吐过思考 / 计划 / 工具调用就不降级**:额度耗尽往往在**第一段输出之前**
|
|
18
|
+
// 就会报出来,但有的引擎先吐空 delta 或只吐思考;只要不是正文,用户就没看到答案,
|
|
19
|
+
// 更不该让这一轮直接死掉。
|
|
20
|
+
//
|
|
21
|
+
// 四条纪律(写死,别放开):
|
|
22
|
+
//
|
|
23
|
+
// 1. **只有正文才算产出**:主引擎已经吐过正文 / 图片就不再换(工具结果也算 —— 说明
|
|
24
|
+
// 它已经被喂过输出、可能动过手了);思考、空正文、计划 / 待授权不拦降级 —— 换引擎
|
|
25
|
+
// 重跑时这些都只是过程,用户不会看到两套答案。
|
|
26
|
+
// 2. **降级清空会话锚点**:备用引擎没有主引擎那条会话(SessionID / Continue 都是
|
|
27
|
+
// 别人的),带过去只会 resume 失败。降级一定是「新开一轮」。
|
|
28
|
+
// 3. **只降一级**:备用失败就报「主备都失败」,不再链式找第三个 —— 配置里只有两档,
|
|
29
|
+
// 行为要可预期。ctx 已取消 / 超时同样不降级(不是引擎的错,重试也没用)。
|
|
30
|
+
// 4. **决定的理由进返回**:FallbackInfo 告诉调用方「降了没有、为什么没降」,
|
|
31
|
+
// 由 CLI 落到事件流的 `fallback` 帧 —— 用户能一眼确认主备确实接上了(问题 1 的现场
|
|
32
|
+
// 就是「配了、跑完了、没有任何痕迹」,没法判断到底生效没有)。
|
|
33
|
+
//
|
|
34
|
+
// 降级发生时先发一条 `fallback` 帧(结构化,客户端据此把这一轮标成「由备用引擎作答」),
|
|
35
|
+
// 再发一条 KindNotice(人话一行,老客户端照旧看到「已降级到备用引擎」)。
|
|
36
|
+
|
|
37
|
+
import (
|
|
38
|
+
"context"
|
|
39
|
+
"fmt"
|
|
40
|
+
"strings"
|
|
41
|
+
"time"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// RunOptions 一次非流式调用的编排参数(与 Runner 字段同义,供 RunWithFallback 复用)。
|
|
45
|
+
type RunOptions struct {
|
|
46
|
+
Timeout time.Duration
|
|
47
|
+
Retries int
|
|
48
|
+
Backoff time.Duration
|
|
49
|
+
Verbose bool
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// FallbackInfo 这一轮的主备结论(无论降没降都有,供 CLI 落 fallback 帧 / 打日志)。
|
|
53
|
+
type FallbackInfo struct {
|
|
54
|
+
// PrimaryEngine / PrimaryModel 这一轮主引擎实际跑的引擎与模型。
|
|
55
|
+
PrimaryEngine string
|
|
56
|
+
PrimaryModel string
|
|
57
|
+
// ConfiguredEngine / ConfiguredModel 用户配的备用(原样)。
|
|
58
|
+
ConfiguredEngine string
|
|
59
|
+
ConfiguredModel string
|
|
60
|
+
// Used 是否真的降级并由备用引擎成功作答。
|
|
61
|
+
Used bool
|
|
62
|
+
// Engine / Model 备用引擎实际用的引擎与模型(Used 时有意义)。
|
|
63
|
+
Engine string
|
|
64
|
+
Model string
|
|
65
|
+
// SwitchedEngine 备用是另一个引擎(而不是同引擎换模型)。
|
|
66
|
+
SwitchedEngine bool
|
|
67
|
+
// Reason 没降级的原因(人话,中文);降级时为空。客户端直接显示。
|
|
68
|
+
Reason string
|
|
69
|
+
// Err 主引擎的原始错误(没降级且主引擎失败时非空)。
|
|
70
|
+
Err error
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// FallbackEvent 降级那一刻发的事件正文(StreamEvent.Fallback)。
|
|
74
|
+
type FallbackEvent struct {
|
|
75
|
+
FromEngine string `json:"from_engine"`
|
|
76
|
+
FromModel string `json:"from_model,omitempty"`
|
|
77
|
+
ToEngine string `json:"to_engine"`
|
|
78
|
+
ToModel string `json:"to_model,omitempty"`
|
|
79
|
+
Switched bool `json:"switched_engine"`
|
|
80
|
+
Reason string `json:"reason,omitempty"`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// event 把 FallbackEvent 转成一条 StreamEvent(Kind=fallback)。
|
|
84
|
+
func (f FallbackEvent) event() StreamEvent {
|
|
85
|
+
return StreamEvent{Kind: KindFallback, Text: FallbackNoticeText(f), Fallback: &f}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// fallbackTarget 解析降级目标:返回备用引擎对象与改写后的请求。
|
|
89
|
+
//
|
|
90
|
+
// 没配 / 配的就是主引擎且没换模型 / 引擎不存在 / 引擎没有所需能力(流式)时 ok=false,
|
|
91
|
+
// 原因在 reason(人话;没配备用时为空)。调用方保留原始错误。
|
|
92
|
+
func fallbackTarget(primary Engine, req Request, needStream bool) (Engine, Request, string, bool) {
|
|
93
|
+
name := strings.TrimSpace(req.FallbackEngine)
|
|
94
|
+
if name == "" {
|
|
95
|
+
return nil, req, "", false
|
|
96
|
+
}
|
|
97
|
+
if strings.EqualFold(name, primary.Name()) && strings.TrimSpace(req.FallbackModel) == "" {
|
|
98
|
+
return nil, req, "备用就是主引擎(引擎与模型都相同),没有可降级的目标", false
|
|
99
|
+
}
|
|
100
|
+
eng := Lookup(name)
|
|
101
|
+
if eng == nil {
|
|
102
|
+
return nil, req, fmt.Sprintf("备用引擎「%s」不存在,没有降级", name), false
|
|
103
|
+
}
|
|
104
|
+
if needStream && AsStreamer(eng) == nil {
|
|
105
|
+
return nil, req, fmt.Sprintf("备用引擎「%s」不支持流式调用,没有降级", name), false
|
|
106
|
+
}
|
|
107
|
+
fb := req
|
|
108
|
+
fb.Engine = eng.Name()
|
|
109
|
+
// 会话锚点是主引擎的,备用引擎接不上(见文件头第 2 条)。
|
|
110
|
+
fb.SessionID = ""
|
|
111
|
+
fb.Continue = false
|
|
112
|
+
// 只降一级。
|
|
113
|
+
fb.FallbackEngine = ""
|
|
114
|
+
fb.FallbackModel = ""
|
|
115
|
+
fb.FallbackDown = true
|
|
116
|
+
if strings.EqualFold(eng.Name(), primary.Name()) {
|
|
117
|
+
// 同引擎换模型:备用模型就是这一路的意义所在,原样带上。
|
|
118
|
+
fb.Model = strings.TrimSpace(req.FallbackModel)
|
|
119
|
+
} else {
|
|
120
|
+
/* 换引擎:备用模型属于主引擎的清单(codebuddy 的 hy3 喂给 dsh = 未知模型)。
|
|
121
|
+
清空 + FallbackDown,让备用引擎用自己的默认模型跑 —— 有答案胜过必然失败。 */
|
|
122
|
+
fb.Model = ""
|
|
123
|
+
}
|
|
124
|
+
return eng, fb, "", true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// configured 用户是否配了备用(引擎非空且与主引擎不同,或同引擎换了模型)。
|
|
128
|
+
func configured(primary Engine, req Request) bool {
|
|
129
|
+
fb := strings.TrimSpace(req.FallbackEngine)
|
|
130
|
+
if fb == "" {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
if !strings.EqualFold(fb, primary.Name()) {
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
return strings.TrimSpace(req.FallbackModel) != ""
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// FallbackNoticeText 降级提示的文案(主会话里的一行,不是错误)。
|
|
140
|
+
//
|
|
141
|
+
// 同引擎换模型时说「已切到备用模型」—— 用户配的正是模型,提示里得看到它。
|
|
142
|
+
func FallbackNoticeText(f FallbackEvent) string {
|
|
143
|
+
reason := strings.TrimSpace(f.Reason)
|
|
144
|
+
if reason == "" {
|
|
145
|
+
reason = "主引擎失败"
|
|
146
|
+
}
|
|
147
|
+
to := fmt.Sprintf("「%s」", f.ToEngine)
|
|
148
|
+
if f.ToModel != "" {
|
|
149
|
+
to = fmt.Sprintf("「%s」的 %s", f.ToEngine, f.ToModel)
|
|
150
|
+
}
|
|
151
|
+
if !f.Switched && f.FromEngine == f.ToEngine {
|
|
152
|
+
return fmt.Sprintf("「%s」执行失败(%s),已切到备用模型%s重试", f.FromEngine, reason, to)
|
|
153
|
+
}
|
|
154
|
+
return fmt.Sprintf("「%s」执行失败(%s),已降级到备用引擎%s重试", f.FromEngine, reason, to)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// eventProduced 这条事件算不算「已经有产出了」(见文件头纪律第 1 条)。
|
|
158
|
+
//
|
|
159
|
+
// 思考**不算**:它只是过程;用户没看到任何答案,换引擎重跑不会让他看到两套正文。
|
|
160
|
+
// 空白正文也不算(有些引擎会发空 delta)。工具结果算 —— 主引擎已经动过手。
|
|
161
|
+
func eventProduced(ev StreamEvent) bool {
|
|
162
|
+
switch ev.Kind {
|
|
163
|
+
case KindText, KindImage:
|
|
164
|
+
return strings.TrimSpace(ev.Text) != ""
|
|
165
|
+
case KindToolResult:
|
|
166
|
+
return strings.TrimSpace(ev.Text) != ""
|
|
167
|
+
}
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// stripNonResetKinds 去掉「结束这一轮」语义的过程帧:降级后备用引擎紧接着还要跑,
|
|
172
|
+
// 调用方若把主引擎残留的 turn_end / turn_failed 转出去,这一轮会在备用引擎开口之前
|
|
173
|
+
// 就被画成「已结束」(或直接关机),备用答案再无去处。
|
|
174
|
+
//
|
|
175
|
+
// 留下来的其它事件(思考、工具卡、计划 / 待授权、notice)照常转发:它们已落盘,是真实过程。
|
|
176
|
+
func stripNonResetKinds(ev StreamEvent) (StreamEvent, bool) {
|
|
177
|
+
switch ev.Kind {
|
|
178
|
+
case KindTurnEnd, KindTurnFailed:
|
|
179
|
+
return StreamEvent{}, false
|
|
180
|
+
}
|
|
181
|
+
return ev, true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// StreamWithFallback 流式执行:主引擎失败或只有过程、没有正文时,自动换备用引擎重跑一次。
|
|
185
|
+
//
|
|
186
|
+
// primary 必须是 Streamer(团队子任务与 ask 的流式路径都满足)。
|
|
187
|
+
// onEvent 收到的 fallback / notice 需要调用方照常转发(它们属于「过程」,不是错误)。
|
|
188
|
+
// 返回的 info 说明这一轮的结论(没降级时的原因由调用方落 fallback 帧)。
|
|
189
|
+
func StreamWithFallback(ctx context.Context, primary Engine, req Request, onEvent func(StreamEvent)) (StreamResult, FallbackInfo, error) {
|
|
190
|
+
info := FallbackInfo{
|
|
191
|
+
PrimaryEngine: primary.Name(),
|
|
192
|
+
PrimaryModel: req.Model,
|
|
193
|
+
ConfiguredEngine: strings.TrimSpace(req.FallbackEngine),
|
|
194
|
+
ConfiguredModel: strings.TrimSpace(req.FallbackModel),
|
|
195
|
+
}
|
|
196
|
+
if st := AsStreamer(primary); st == nil {
|
|
197
|
+
return StreamResult{}, info, fmt.Errorf("%s 不支持流式调用", primary.Name())
|
|
198
|
+
}
|
|
199
|
+
if onEvent == nil {
|
|
200
|
+
onEvent = func(StreamEvent) {}
|
|
201
|
+
}
|
|
202
|
+
produced := false
|
|
203
|
+
watch := func(ev StreamEvent) {
|
|
204
|
+
if eventProduced(ev) {
|
|
205
|
+
produced = true
|
|
206
|
+
}
|
|
207
|
+
onEvent(ev)
|
|
208
|
+
}
|
|
209
|
+
res, err := AsStreamer(primary).Stream(ctx, req, watch)
|
|
210
|
+
if err == nil || produced || ctx.Err() != nil {
|
|
211
|
+
if produced && configured(primary, req) {
|
|
212
|
+
info.Reason = "主引擎已经输出正文,本轮不降级(避免两段互相矛盾的答案)"
|
|
213
|
+
}
|
|
214
|
+
return res, info, err
|
|
215
|
+
}
|
|
216
|
+
eng, fbReq, blocked, ok := fallbackTarget(primary, req, true)
|
|
217
|
+
if !ok {
|
|
218
|
+
info.Reason = blocked
|
|
219
|
+
return res, info, err
|
|
220
|
+
}
|
|
221
|
+
fe := FallbackEvent{
|
|
222
|
+
FromEngine: primary.Name(),
|
|
223
|
+
FromModel: strings.TrimSpace(req.Model),
|
|
224
|
+
ToEngine: eng.Name(),
|
|
225
|
+
ToModel: strings.TrimSpace(fbReq.Model),
|
|
226
|
+
Switched: !strings.EqualFold(eng.Name(), primary.Name()),
|
|
227
|
+
Reason: shortReason(err),
|
|
228
|
+
}
|
|
229
|
+
onEvent(fe.event())
|
|
230
|
+
res2, err2 := AsStreamer(eng).Stream(ctx, fbReq, func(ev StreamEvent) {
|
|
231
|
+
if kept, ok := stripNonResetKinds(ev); ok {
|
|
232
|
+
onEvent(kept)
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
if err2 == nil {
|
|
236
|
+
info.Used = true
|
|
237
|
+
info.Engine = eng.Name()
|
|
238
|
+
info.Model = effectiveModel(strings.TrimSpace(res2.Model), fe.ToModel)
|
|
239
|
+
info.SwitchedEngine = fe.Switched
|
|
240
|
+
return res2, info, nil
|
|
241
|
+
}
|
|
242
|
+
return res2, info, fmt.Errorf("「%s」失败(%s);备用引擎「%s」也失败:%w",
|
|
243
|
+
primary.Name(), shortReason(err), eng.Name(), err2)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// RunWithFallback 非流式执行(含超时与重试):主引擎跑完仍失败且没拿到正文时,
|
|
247
|
+
// 换备用引擎按同样的超时 / 重试策略再跑一次。
|
|
248
|
+
//
|
|
249
|
+
// onFallback 可为 nil;非空时在降级前调用一次(CLI 用它往 stderr / 事件流写提示)。
|
|
250
|
+
func RunWithFallback(ctx context.Context, primary Engine, req Request, ro RunOptions, onFallback func(FallbackEvent)) (Response, FallbackInfo, error) {
|
|
251
|
+
info := FallbackInfo{
|
|
252
|
+
PrimaryEngine: primary.Name(),
|
|
253
|
+
PrimaryModel: req.Model,
|
|
254
|
+
ConfiguredEngine: strings.TrimSpace(req.FallbackEngine),
|
|
255
|
+
ConfiguredModel: strings.TrimSpace(req.FallbackModel),
|
|
256
|
+
}
|
|
257
|
+
run := func(e Engine, r Request) (Response, error) {
|
|
258
|
+
return (&Runner{Engine: e, Timeout: ro.Timeout, Retries: ro.Retries, Backoff: ro.Backoff, Verbose: ro.Verbose}).Run(ctx, r)
|
|
259
|
+
}
|
|
260
|
+
resp, err := run(primary, req)
|
|
261
|
+
if err == nil || strings.TrimSpace(resp.Text) != "" || ctx.Err() != nil {
|
|
262
|
+
return resp, info, err
|
|
263
|
+
}
|
|
264
|
+
eng, fbReq, blocked, ok := fallbackTarget(primary, req, false)
|
|
265
|
+
if !ok {
|
|
266
|
+
info.Reason = blocked
|
|
267
|
+
return resp, info, err
|
|
268
|
+
}
|
|
269
|
+
fe := FallbackEvent{
|
|
270
|
+
FromEngine: primary.Name(),
|
|
271
|
+
FromModel: strings.TrimSpace(req.Model),
|
|
272
|
+
ToEngine: eng.Name(),
|
|
273
|
+
ToModel: strings.TrimSpace(fbReq.Model),
|
|
274
|
+
Switched: !strings.EqualFold(eng.Name(), primary.Name()),
|
|
275
|
+
Reason: shortReason(err),
|
|
276
|
+
}
|
|
277
|
+
if onFallback != nil {
|
|
278
|
+
onFallback(fe)
|
|
279
|
+
}
|
|
280
|
+
resp2, err2 := run(eng, fbReq)
|
|
281
|
+
if err2 != nil {
|
|
282
|
+
// 备用也没成:Engine 留空 —— 它是「谁给出了答案」的标签,没答案就不记。
|
|
283
|
+
return resp2, info, fmt.Errorf("「%s」失败(%s);备用引擎「%s」也失败:%w",
|
|
284
|
+
primary.Name(), shortReason(err), eng.Name(), err2)
|
|
285
|
+
}
|
|
286
|
+
info.Used = true
|
|
287
|
+
info.Engine = eng.Name()
|
|
288
|
+
info.SwitchedEngine = fe.Switched
|
|
289
|
+
info.Model = effectiveModel(strings.TrimSpace(resp2.Model), fe.ToModel)
|
|
290
|
+
return resp2, info, nil
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// effectiveModel 备用引擎实际用的模型标签:引擎没回填时用降级目标上配的那个,
|
|
294
|
+
// 两者都空 = 引擎默认模型(标签留空,不编)。
|
|
295
|
+
func effectiveModel(reported, target string) string {
|
|
296
|
+
if reported != "" {
|
|
297
|
+
return reported
|
|
298
|
+
}
|
|
299
|
+
return target
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// shortReason 降级提示里用的短原因:去掉重复的外层包装,压成一行。
|
|
303
|
+
func shortReason(err error) string {
|
|
304
|
+
s := strings.TrimSpace(ReasonOf(err))
|
|
305
|
+
if s == "" {
|
|
306
|
+
s = strings.TrimSpace(err.Error())
|
|
307
|
+
}
|
|
308
|
+
s = strings.ReplaceAll(s, "\n", " ")
|
|
309
|
+
if n := len([]rune(s)); n > 160 {
|
|
310
|
+
s = string([]rune(s)[:160]) + "…"
|
|
311
|
+
}
|
|
312
|
+
return s
|
|
313
|
+
}
|