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,128 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// envMap 把 ["K=V", ...] 折叠成 map(exec 语义:重复 key 取后者)。
|
|
9
|
+
func envMap(kv []string) map[string]string {
|
|
10
|
+
out := make(map[string]string, len(kv))
|
|
11
|
+
for _, s := range kv {
|
|
12
|
+
k, v, ok := strings.Cut(s, "=")
|
|
13
|
+
if !ok {
|
|
14
|
+
continue
|
|
15
|
+
}
|
|
16
|
+
out[k] = v
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 默认(无任何覆盖):清空 ACC_PRODUCT_CONFIG_PATH + 指定账号 id + 该引擎的配置目录。
|
|
22
|
+
func TestCodebuddyAccountEnv(t *testing.T) {
|
|
23
|
+
t.Setenv("ACC_PRODUCT_CONFIG_V3", "")
|
|
24
|
+
t.Setenv("CODEBUDDY_CONFIG_DIR", "")
|
|
25
|
+
t.Setenv(codebuddyAIAuthIDEnv, "")
|
|
26
|
+
|
|
27
|
+
got := envMap(codebuddyAccountEnv("/tmp/cbdir", codebuddyAIAuthID, codebuddyAIAuthIDEnv))
|
|
28
|
+
|
|
29
|
+
// 必须清空:父会话指过来的 spilled 配置里的 authentication.id 优先级高于 V3
|
|
30
|
+
if v, ok := got["ACC_PRODUCT_CONFIG_PATH"]; !ok || v != "" {
|
|
31
|
+
t.Errorf("ACC_PRODUCT_CONFIG_PATH 应被显式清空,实际 ok=%v v=%q", ok, v)
|
|
32
|
+
}
|
|
33
|
+
wantID := `{"authentication":{"id":"codebuddy-ai"}}`
|
|
34
|
+
if got["ACC_PRODUCT_CONFIG_V3"] != wantID {
|
|
35
|
+
t.Errorf("ACC_PRODUCT_CONFIG_V3 = %q, want %q", got["ACC_PRODUCT_CONFIG_V3"], wantID)
|
|
36
|
+
}
|
|
37
|
+
if got["CODEBUDDY_CONFIG_DIR"] != "/tmp/cbdir" {
|
|
38
|
+
t.Errorf("CODEBUDDY_CONFIG_DIR = %q, want /tmp/cbdir", got["CODEBUDDY_CONFIG_DIR"])
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 账号覆盖只认**各引擎自己的**变量;全局的 ACC_PRODUCT_CONFIG_V3 不参与
|
|
43
|
+
// —— 否则全局存在该变量时,两个引擎会静默合并成同一个账号。
|
|
44
|
+
func TestCodebuddyAccountEnvOverride(t *testing.T) {
|
|
45
|
+
t.Setenv("CODEBUDDY_CONFIG_DIR", "")
|
|
46
|
+
t.Setenv("ACC_PRODUCT_CONFIG_V3", `{"authentication":{"id":"global-should-be-ignored"}}`)
|
|
47
|
+
t.Setenv(codebuddyAuthIDEnv, "mine")
|
|
48
|
+
|
|
49
|
+
got := envMap(codebuddyAccountEnv("/tmp/cbdir", codebuddyAuthID, codebuddyAuthIDEnv))
|
|
50
|
+
|
|
51
|
+
if got["ACC_PRODUCT_CONFIG_V3"] != `{"authentication":{"id":"mine"}}` {
|
|
52
|
+
t.Errorf("应使用本引擎的覆盖 id,实际 %q", got["ACC_PRODUCT_CONFIG_V3"])
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 用户显式设了 CODEBUDDY_CONFIG_DIR 时**不注入**(让子进程继承用户的值)。
|
|
57
|
+
func TestCodebuddyAccountEnvRespectsConfigDirOverride(t *testing.T) {
|
|
58
|
+
t.Setenv("CODEBUDDY_CONFIG_DIR", "/tmp/mine")
|
|
59
|
+
t.Setenv(codebuddyAuthIDEnv, "")
|
|
60
|
+
|
|
61
|
+
got := envMap(codebuddyAccountEnv("/tmp/cbdir", codebuddyAuthID, codebuddyAuthIDEnv))
|
|
62
|
+
|
|
63
|
+
if v, ok := got["CODEBUDDY_CONFIG_DIR"]; ok {
|
|
64
|
+
t.Errorf("用户已设置 CODEBUDDY_CONFIG_DIR 时不应再注入,实际注入 %q", v)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 两个引擎必须落在**不同**的票据文件上(同一个 CLI、两个账号)。
|
|
69
|
+
func TestCodeBuddyEnginesUseDistinctAccounts(t *testing.T) {
|
|
70
|
+
if codebuddyAuthID == codebuddyAIAuthID {
|
|
71
|
+
t.Fatalf("两个引擎的账号标识不能相同:%q", codebuddyAuthID)
|
|
72
|
+
}
|
|
73
|
+
if strings.TrimSpace(codebuddyAuthID) == "" || strings.TrimSpace(codebuddyAIAuthID) == "" {
|
|
74
|
+
t.Fatal("账号标识不能为空串:空串会退化成共享的 auth.info")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
t.Setenv("CODEBUDDY_CONFIG_DIR", "")
|
|
78
|
+
envA := envMap(codebuddyAccountEnv(codebuddyDir(), codebuddyAuthID, codebuddyAuthIDEnv))
|
|
79
|
+
envB := envMap(codebuddyAIExtraEnv())
|
|
80
|
+
if envA["ACC_PRODUCT_CONFIG_V3"] == envB["ACC_PRODUCT_CONFIG_V3"] {
|
|
81
|
+
t.Errorf("两个引擎的账号环境相同(会互相顶号):%q", envA["ACC_PRODUCT_CONFIG_V3"])
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// LoginCommand 必须返回「同一个二进制 + 本引擎的账号环境」,供 --login 使用。
|
|
86
|
+
func TestCodeBuddyLoginCommandEnv(t *testing.T) {
|
|
87
|
+
t.Setenv("ACC_PRODUCT_CONFIG_V3", "")
|
|
88
|
+
t.Setenv("CODEBUDDY_CONFIG_DIR", "")
|
|
89
|
+
t.Setenv(codebuddyAuthIDEnv, "")
|
|
90
|
+
t.Setenv(codebuddyAIAuthIDEnv, "")
|
|
91
|
+
|
|
92
|
+
// BinPath 注入假路径,避免测试依赖本机是否装了 CLI。
|
|
93
|
+
e := &CodeBuddyEngine{BinPath: "/tmp/fake-codebuddy"}
|
|
94
|
+
bin, args, env, err := e.LoginCommand()
|
|
95
|
+
if err != nil {
|
|
96
|
+
t.Fatalf("LoginCommand: %v", err)
|
|
97
|
+
}
|
|
98
|
+
if bin != "/tmp/fake-codebuddy" {
|
|
99
|
+
t.Errorf("bin = %q, want 注入的路径", bin)
|
|
100
|
+
}
|
|
101
|
+
if len(args) != 0 {
|
|
102
|
+
t.Errorf("裸启动应为空参数(交互式界面),实际 %v", args)
|
|
103
|
+
}
|
|
104
|
+
m := envMap(env)
|
|
105
|
+
if !strings.Contains(m["ACC_PRODUCT_CONFIG_V3"], `"id":"`+codebuddyAuthID+`"`) {
|
|
106
|
+
t.Errorf("--login 必须带上 codebuddy 的账号 id,实际 %q", m["ACC_PRODUCT_CONFIG_V3"])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
ai := &CodeBuddyAIEngine{BinPath: "/tmp/fake-codebuddy"}
|
|
110
|
+
_, _, envAI, err := ai.LoginCommand()
|
|
111
|
+
if err != nil {
|
|
112
|
+
t.Fatalf("codebuddy-ai LoginCommand: %v", err)
|
|
113
|
+
}
|
|
114
|
+
if mAI := envMap(envAI); mAI["ACC_PRODUCT_CONFIG_V3"] == m["ACC_PRODUCT_CONFIG_V3"] {
|
|
115
|
+
t.Error("两个引擎 --login 的账号环境相同,会把两个账号登到同一份票据上")
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 未探测到 CLI 时 --login 要明确报错(而不是静默启一个空 bin)。
|
|
120
|
+
func TestCodeBuddyLoginCommandMissingBin(t *testing.T) {
|
|
121
|
+
e := &CodeBuddyEngine{BinPath: ""}
|
|
122
|
+
if bin := e.bin(); bin != "" {
|
|
123
|
+
t.Skipf("本机 PATH 上存在 codebuddy(%s),跳过未安装分支", bin)
|
|
124
|
+
}
|
|
125
|
+
if _, _, _, err := e.LoginCommand(); err == nil {
|
|
126
|
+
t.Fatal("CLI 不存在时应返回错误")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
/* codebuddy_gateway.go - codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,
|
|
4
|
+
* 走 webhook + SSE 的「远程连接模式」)。
|
|
5
|
+
*
|
|
6
|
+
* 与其余引擎最大的不同:它既不起本机 CLI(不是 claude / codebuddy 那种),
|
|
7
|
+
* 也不走 A2A JSON-RPC(不是 arkclaw 那种),而是把 prompt **投递**给一个已经在跑的
|
|
8
|
+
* CodeBuddy Code HTTP 网关 —— 也就是 `codebuddy --serve`(或交互会话里的 `/gateway`
|
|
9
|
+
* 远程控制)暴露出来的那套服务,见官方文档「远程控制(Remote Control)」
|
|
10
|
+
* https://www.codebuddy.ai/docs/zh/cli/remote-control
|
|
11
|
+
*
|
|
12
|
+
* 为什么要有这个引擎(2026-09-24):用户要「支持这种远程连接模式接入引擎,走
|
|
13
|
+
* webhook + SSE」。它解决的是 codebuddy 引擎解决不了的两件事:
|
|
14
|
+
* · 复用**已经登录、已经配好模型与 MCP** 的那个网关进程 —— 不必每次 spawn 一个 CLI;
|
|
15
|
+
* · 把执行放到网关那台机器上(Tunnel 形态下网关可以在另一台机器 / 容器里),
|
|
16
|
+
* 本机只负责投递与收流。
|
|
17
|
+
*
|
|
18
|
+
* 两段式协议(2026-09-24 对 CodeBuddy Code 2.147.0 真机实测,别凭文档臆想):
|
|
19
|
+
*
|
|
20
|
+
* 1) 投递(本文件)
|
|
21
|
+
* POST <base>/api/v1/webhooks/{platform}
|
|
22
|
+
* authorization: Bearer <password> ← `--auth password` 时的口令
|
|
23
|
+
* x-codebuddy-request: 1 ← 网关的请求校验中间件要求(缺了 403)
|
|
24
|
+
* {"version":"1.0","id":"<msgId>","type":"message",
|
|
25
|
+
* "source":{"platform":"generic","sender":{"id":"…"},
|
|
26
|
+
* "conversation":{"id":"<会话锚点>","type":"direct"}},
|
|
27
|
+
* "payload":{"text":"<prompt>","attachments":[…]}}
|
|
28
|
+
* → 202 {"data":{"runId":"<uuid>","status":"accepted"}} (实测 3~13ms 返回)
|
|
29
|
+
*
|
|
30
|
+
* 2) 收流(codebuddy_gateway_stream.go)
|
|
31
|
+
* GET <base>/api/v1/runs/{runId}/stream
|
|
32
|
+
* accept: text/event-stream
|
|
33
|
+
* → SSE,逐帧 data: {"version":"1.0","replyTo":"<msgId>","status":"…",…}
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ 关键事实:**投递响应里没有正文**。网关是「受理即返回」的异步模型,正文只在
|
|
36
|
+
* SSE 通道上出现(`status=streaming` 的 `content.chunk` 增量 + `status=completed`
|
|
37
|
+
* 的 `content.markdown` 整段)。所以本引擎的 Complete 也必须读 SSE —— 这不是「为了
|
|
38
|
+
* 流式而流式」,而是那条通道是唯一拿得到正文的路。
|
|
39
|
+
* 附带结论:platform 只能选 generic —— wecom / wechat-kf 的适配器会把回复**推给平台**,
|
|
40
|
+
* 而 generic 适配器根本没有实现 sendReply(实测回调一次都不会触发),正文只留在 run
|
|
41
|
+
* 流里,正好由本引擎消费。
|
|
42
|
+
*
|
|
43
|
+
* 参数映射(统一参数矩阵的 codebuddy-gateway 列):
|
|
44
|
+
* - Model:协议里没有模型字段,模型由网关进程自己的配置决定(`--model` / 会话设置)
|
|
45
|
+
* → 传 -m 不生效,打一行告警后忽略(与 arkclaw 的「模型被 claw_id 绑死」同构)
|
|
46
|
+
* - SessionID:写入 source.conversation.id —— 网关按它 getOrCreateSession,
|
|
47
|
+
* 同一个 id = 同一个会话(**这是真正的多轮续接**,实测有效)
|
|
48
|
+
* - Continue:网关没有「查询最近会话」的接口 → **显式报错**(静默开新会话会让
|
|
49
|
+
* 调用方以为上下文接上了)
|
|
50
|
+
* - SystemPrompt:协议无独立 system 角色 → 展平进 prompt 头部
|
|
51
|
+
* - MaxTokens / Temperature:协议无对应字段 → 静默忽略
|
|
52
|
+
* - Tools / Permission:会话的 agent 模式与权限档位由**网关进程启动参数**决定
|
|
53
|
+
* (`--agent` / `--permission-mode`),网关还会把远程任务的权限强制切到
|
|
54
|
+
* bypassPermissions(远程场景无法交互式审批)→ per-call 无法落地,能力表报 none
|
|
55
|
+
* - Workspace:网关在**它自己的 cwd** 里跑 → per-call 无法指定,能力表报 none
|
|
56
|
+
* - Attachments:走 payload.attachments 原生字段(urlType=local-path),
|
|
57
|
+
* 由网关把它渲染成「附件清单 + 路径」塞进 prompt,模型再用 Read 工具去看 ——
|
|
58
|
+
* 所以能力表如实报 "prompt"(图**不会**以图片形式进模型上下文,别报成 part:file)
|
|
59
|
+
* - JSONSchema:无原生约束 → 输出后处理抽 JSON(同 llm / openclaw / arkclaw)
|
|
60
|
+
* - Append(常驻会话):webhook 是「一次投递一个 run」的模型,没有持续喂消息的通道
|
|
61
|
+
* → 不支持,能力表报 false
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import (
|
|
65
|
+
"bytes"
|
|
66
|
+
"context"
|
|
67
|
+
"crypto/rand"
|
|
68
|
+
"encoding/hex"
|
|
69
|
+
"encoding/json"
|
|
70
|
+
"fmt"
|
|
71
|
+
"io"
|
|
72
|
+
"net/http"
|
|
73
|
+
"net/url"
|
|
74
|
+
"strings"
|
|
75
|
+
"time"
|
|
76
|
+
|
|
77
|
+
"github.com/darren/magic-agent/internal/config"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// DefaultCBGatewayTimeout 单次调用的默认超时。
|
|
81
|
+
//
|
|
82
|
+
// 10 分钟:网关侧的 runTimeoutMs 默认就是 1_800_000(30 分钟,实测值来自网关内核
|
|
83
|
+
// DEFAULT_RUN_TIMEOUT_MS),而 CLI 的 -t 默认也是 600s —— 取 10 分钟与 CLI 层一致,
|
|
84
|
+
// 避免「CLI 说 600s、引擎内部却更短」这种自相矛盾。要跑更久的任务显式给 -t。
|
|
85
|
+
const DefaultCBGatewayTimeout = 10 * time.Minute
|
|
86
|
+
|
|
87
|
+
// cbGatewayMaxBody 单次响应体读取上限(webhook 受理响应很小,留足余量即可)。
|
|
88
|
+
const cbGatewayMaxBody = 1 << 20 // 1 MiB
|
|
89
|
+
|
|
90
|
+
// cbGatewayEngineName 引擎标识(注册名 / -e 取值 / --engines 展示名)。
|
|
91
|
+
const cbGatewayEngineName = "codebuddy-gateway"
|
|
92
|
+
|
|
93
|
+
// cbGatewayDefaultSender 未配置 sender 时用的发送者标识(网关按它做限流与归属)。
|
|
94
|
+
const cbGatewayDefaultSender = "magic-agent"
|
|
95
|
+
|
|
96
|
+
// cbGatewayPlatformGeneric generic 平台:唯一「正文只留在 run 流里」的通道,
|
|
97
|
+
// 也是本引擎的默认(见文件头「关键事实」)。
|
|
98
|
+
const cbGatewayPlatformGeneric = "generic"
|
|
99
|
+
|
|
100
|
+
// cbGatewaySecurityHeader 网关请求校验中间件要求的头。
|
|
101
|
+
//
|
|
102
|
+
// 依据:内核里的 gateway_request_validation_middleware —— 非豁免路径缺这个头直接
|
|
103
|
+
// 403 {"error":"Missing required header: x-codebuddy-request"}。实测 GET /api/v1/runs/{id}
|
|
104
|
+
// 就命中过这个 403。webhook 的 POST 虽在豁免表里,一并带上没有副作用。
|
|
105
|
+
const cbGatewaySecurityHeader = "x-codebuddy-request"
|
|
106
|
+
|
|
107
|
+
// CodeBuddyGatewayEngine 通过 CodeBuddy Code HTTP 网关(webhook + SSE)实现 Engine。
|
|
108
|
+
type CodeBuddyGatewayEngine struct {
|
|
109
|
+
// URL / Password / Platform / Sender / Conversation 显式覆盖配置与环境变量。
|
|
110
|
+
// 生产路径留空、走配置文件;测试注入用。
|
|
111
|
+
URL string
|
|
112
|
+
Password string
|
|
113
|
+
Platform string
|
|
114
|
+
Sender string
|
|
115
|
+
Conversation string
|
|
116
|
+
|
|
117
|
+
// ConfigPath 显式配置文件路径(空 = config.Path() 默认探测链)。测试注入用。
|
|
118
|
+
ConfigPath string
|
|
119
|
+
|
|
120
|
+
// HTTPClient 显式 HTTP 客户端(空 = 每请求带 ctx 超时的默认客户端)。测试注入用。
|
|
121
|
+
HTTPClient *http.Client
|
|
122
|
+
|
|
123
|
+
// Timeout 单次调用超时(0 = DefaultCBGatewayTimeout)。
|
|
124
|
+
Timeout time.Duration
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Name 实现 Engine。
|
|
128
|
+
func (e *CodeBuddyGatewayEngine) Name() string { return cbGatewayEngineName }
|
|
129
|
+
|
|
130
|
+
// CapabilityFamily 实现 CapabilityFamily:本引擎自成一族(协议与 arkclaw / codebuddy
|
|
131
|
+
// 都不同),故家族名就是自己的名字 —— 五张能力表按它查。
|
|
132
|
+
func (e *CodeBuddyGatewayEngine) CapabilityFamily() string { return cbGatewayEngineName }
|
|
133
|
+
|
|
134
|
+
// cbGatewayResolved 是一次调用生效的连接参数(显式字段 → 环境变量 → 配置文件)。
|
|
135
|
+
type cbGatewayResolved struct {
|
|
136
|
+
baseURL string
|
|
137
|
+
password string
|
|
138
|
+
platform string
|
|
139
|
+
sender string
|
|
140
|
+
conversation string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// resolve 解析生效的连接参数。
|
|
144
|
+
//
|
|
145
|
+
// 优先级:结构体显式字段 → 环境变量 → 配置文件
|
|
146
|
+
// (环境变量覆盖文件值这一步由 config.LoadFrom 内部完成)。
|
|
147
|
+
func (e *CodeBuddyGatewayEngine) resolve() (cbGatewayResolved, error) {
|
|
148
|
+
path := e.ConfigPath
|
|
149
|
+
if strings.TrimSpace(path) == "" {
|
|
150
|
+
path = config.Path()
|
|
151
|
+
}
|
|
152
|
+
cfg, err := config.LoadFrom(path)
|
|
153
|
+
if err != nil {
|
|
154
|
+
return cbGatewayResolved{}, err
|
|
155
|
+
}
|
|
156
|
+
g := cfg.CodeBuddyGateway
|
|
157
|
+
return cbGatewayResolved{
|
|
158
|
+
baseURL: strings.TrimRight(firstNonEmpty(e.URL, g.URL), "/"),
|
|
159
|
+
password: firstNonEmpty(e.Password, g.Password),
|
|
160
|
+
platform: firstNonEmpty(e.Platform, g.Platform, cbGatewayPlatformGeneric),
|
|
161
|
+
sender: firstNonEmpty(e.Sender, g.Sender, cbGatewayDefaultSender),
|
|
162
|
+
conversation: firstNonEmpty(e.Conversation, g.Conversation),
|
|
163
|
+
}, nil
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// configHint 返回报错时提示的配置文件路径。
|
|
167
|
+
func (e *CodeBuddyGatewayEngine) configHint() string {
|
|
168
|
+
p := strings.TrimSpace(e.ConfigPath)
|
|
169
|
+
if p == "" {
|
|
170
|
+
p = config.Path()
|
|
171
|
+
}
|
|
172
|
+
if p == "" {
|
|
173
|
+
return "(配置文件路径未知)"
|
|
174
|
+
}
|
|
175
|
+
return p + ` 的 "codebuddyGateway" 节`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Detect 实现 Engine。
|
|
179
|
+
//
|
|
180
|
+
// 与 arkclaw 同一取舍:**只查配置齐备,不发网络请求**。
|
|
181
|
+
// 理由:`--engines` 会对每个引擎调一次 Detect,加一次网络探测会让列表慢上一个 RTT,
|
|
182
|
+
// 而「网关此刻在不在跑」是运行期状态 —— 真连不上时 Complete/Stream 会给出准确报错。
|
|
183
|
+
func (e *CodeBuddyGatewayEngine) Detect() (bool, string) {
|
|
184
|
+
r, err := e.resolve()
|
|
185
|
+
if err != nil {
|
|
186
|
+
return false, fmt.Sprintf("%s config error: %v", e.Name(), err)
|
|
187
|
+
}
|
|
188
|
+
if r.baseURL == "" {
|
|
189
|
+
return false, fmt.Sprintf("%s not configured: missing url in %s (or set %s)",
|
|
190
|
+
e.Name(), e.configHint(), config.EnvCBGWURL)
|
|
191
|
+
}
|
|
192
|
+
return true, r.baseURL
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ListModels 实现 ModelLister:网关**不提供**模型清单。
|
|
196
|
+
//
|
|
197
|
+
// 模型由网关进程自己的配置决定(`--model` / 会话里的模型选择),webhook 协议里没有
|
|
198
|
+
// 模型字段 → 不编造列表,只返回 ErrNoModelSource + 原因。
|
|
199
|
+
func (e *CodeBuddyGatewayEngine) ListModels(_ context.Context) ([]string, error) {
|
|
200
|
+
return nil, fmt.Errorf("%w: codebuddy-gateway uses the model configured in the gateway process (start it with --model or switch in the session)", ErrNoModelSource)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// cbGatewayInbound 是投递给网关的入站消息(Gateway Protocol 格式)。
|
|
204
|
+
//
|
|
205
|
+
// 字段名与网关内核的 generic 适配器解析一一对应:
|
|
206
|
+
// id / type / source / payload 是**必需**的(缺 id 或 type 直接 400
|
|
207
|
+
// "Invalid generic message format: missing required fields (id, type)",实测过)。
|
|
208
|
+
type cbGatewayInbound struct {
|
|
209
|
+
Version string `json:"version"`
|
|
210
|
+
ID string `json:"id"`
|
|
211
|
+
Type string `json:"type"`
|
|
212
|
+
Source cbGatewaySource `json:"source"`
|
|
213
|
+
Payload cbGatewayPayload `json:"payload"`
|
|
214
|
+
Action string `json:"action,omitempty"`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// cbGatewaySource 是消息来源(平台 + 发送者 + 会话锚点)。
|
|
218
|
+
type cbGatewaySource struct {
|
|
219
|
+
Platform string `json:"platform"`
|
|
220
|
+
Sender cbGatewaySender `json:"sender"`
|
|
221
|
+
Conversation cbGatewayConv `json:"conversation"`
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// cbGatewaySender 是发送者。
|
|
225
|
+
type cbGatewaySender struct {
|
|
226
|
+
ID string `json:"id"`
|
|
227
|
+
Name string `json:"name,omitempty"`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// cbGatewayConv 是会话(网关按 Conversation.ID 做 getOrCreateSession,即续接锚点)。
|
|
231
|
+
type cbGatewayConv struct {
|
|
232
|
+
ID string `json:"id"`
|
|
233
|
+
Type string `json:"type"`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// cbGatewayPayload 是消息体(正文 + 可选附件)。
|
|
237
|
+
type cbGatewayPayload struct {
|
|
238
|
+
Text string `json:"text,omitempty"`
|
|
239
|
+
Attachments []cbGatewayAttachment `json:"attachments,omitempty"`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// cbGatewayAttachment 是附件条目。
|
|
243
|
+
//
|
|
244
|
+
// 网关侧把它渲染成「附件清单(请按需使用 Read / WebFetch 等工具查看):- [类型] 名称
|
|
245
|
+
// (本地路径,可用 Read): <路径>」拼进 prompt —— 所以本引擎报的附件能力是 "prompt"
|
|
246
|
+
// 而不是图片直传(见文件头)。
|
|
247
|
+
type cbGatewayAttachment struct {
|
|
248
|
+
Type string `json:"type"`
|
|
249
|
+
Name string `json:"name,omitempty"`
|
|
250
|
+
URL string `json:"url"`
|
|
251
|
+
URLType string `json:"urlType"`
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// cbGatewayAccepted 是 webhook 受理响应的信封。
|
|
255
|
+
type cbGatewayAccepted struct {
|
|
256
|
+
Data struct {
|
|
257
|
+
RunID string `json:"runId"`
|
|
258
|
+
Status string `json:"status"`
|
|
259
|
+
} `json:"data"`
|
|
260
|
+
Error *struct {
|
|
261
|
+
Code string `json:"code"`
|
|
262
|
+
Message string `json:"message"`
|
|
263
|
+
} `json:"error"`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Complete 实现 Engine:一次 webhook 投递 + 一条 SSE 收流。
|
|
267
|
+
//
|
|
268
|
+
// 单次尝试;超时与重试由 Runner 在外层编排。
|
|
269
|
+
func (e *CodeBuddyGatewayEngine) Complete(ctx context.Context, req Request) (Response, error) {
|
|
270
|
+
res, err := e.run(ctx, req, nil)
|
|
271
|
+
return res.Response, err
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// run 是 webhook + SSE 的公共执行核(Complete 与 Stream 共用)。
|
|
275
|
+
//
|
|
276
|
+
// onEvent 为 nil = 非流式(只在收尾返回全文);非 nil = 实时把正文增量转出去。
|
|
277
|
+
// 无论哪种,正文都必须经 SSE 拿 —— 这是协议决定的(见文件头)。
|
|
278
|
+
func (e *CodeBuddyGatewayEngine) run(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
279
|
+
start := time.Now()
|
|
280
|
+
|
|
281
|
+
r, err := e.resolve()
|
|
282
|
+
if err != nil {
|
|
283
|
+
return StreamResult{}, fmt.Errorf("%s: %w", e.Name(), err)
|
|
284
|
+
}
|
|
285
|
+
if r.baseURL == "" {
|
|
286
|
+
return StreamResult{}, fmt.Errorf("%s: 未配置(需要 url);请在 %s 补齐,或设置 %s",
|
|
287
|
+
e.Name(), e.configHint(), config.EnvCBGWURL)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 网关没有「查询最近会话」的接口:续接只能靠显式 conversation id。
|
|
291
|
+
if req.Continue && req.SessionID == "" {
|
|
292
|
+
return StreamResult{}, fmt.Errorf("%s: 不支持 continue(网关无「查询最近会话」接口),请用 --session <conversation_id> 显式续接", e.Name())
|
|
293
|
+
}
|
|
294
|
+
// 模型由网关进程绑定,-m 在这里没有落地通道 —— 如实告警,不静默吞掉。
|
|
295
|
+
if strings.TrimSpace(req.Model) != "" {
|
|
296
|
+
fmt.Fprintf(stderr, " ⚠ %s: --model %s 不生效(模型由网关进程自己的配置决定),要换模型请在网关侧改\n", e.Name(), req.Model)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
|
|
300
|
+
if strings.TrimSpace(prompt) == "" {
|
|
301
|
+
return StreamResult{}, fmt.Errorf("%s: empty prompt", e.Name())
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 会话锚点:显式 --session 优先(= 续接同一会话);否则用配置里的固定 conversation;
|
|
305
|
+
// 都没有就每次生成一个新的(= 每次新会话,不串上下文)。
|
|
306
|
+
convID := firstNonEmpty(req.SessionID, r.conversation)
|
|
307
|
+
if convID == "" {
|
|
308
|
+
convID = "magic-agent-" + cbGatewayRandomHex(8)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
timeout := req.Timeout
|
|
312
|
+
if timeout <= 0 {
|
|
313
|
+
timeout = e.Timeout
|
|
314
|
+
}
|
|
315
|
+
if timeout <= 0 {
|
|
316
|
+
timeout = DefaultCBGatewayTimeout
|
|
317
|
+
}
|
|
318
|
+
httpCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
319
|
+
defer cancel()
|
|
320
|
+
|
|
321
|
+
client := e.HTTPClient
|
|
322
|
+
if client == nil {
|
|
323
|
+
client = &http.Client{}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
msgID := "magic-agent-" + cbGatewayRandomHex(12)
|
|
327
|
+
runID, err := e.deliver(httpCtx, client, r, msgID, convID, prompt, req.Attachments)
|
|
328
|
+
if err != nil {
|
|
329
|
+
return StreamResult{Response: e.baseResponse(start, convID)}, err
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
st := &cbGatewayStreamState{
|
|
333
|
+
onEvent: onEvent,
|
|
334
|
+
msgID: msgID,
|
|
335
|
+
runID: runID,
|
|
336
|
+
convID: convID,
|
|
337
|
+
sessionID: convID,
|
|
338
|
+
}
|
|
339
|
+
if err := e.consumeStream(httpCtx, client, r, runID, st, req.JSONSchema); err != nil {
|
|
340
|
+
return st.result(e, req, start), err
|
|
341
|
+
}
|
|
342
|
+
res := st.result(e, req, start)
|
|
343
|
+
if strings.TrimSpace(res.Text) == "" {
|
|
344
|
+
return res, fmt.Errorf("%s: 网关返回空正文(run %s)", e.Name(), runID)
|
|
345
|
+
}
|
|
346
|
+
return res, nil
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// deliver 投递一条入站消息,返回网关分配的 runId。
|
|
350
|
+
func (e *CodeBuddyGatewayEngine) deliver(ctx context.Context, client *http.Client, r cbGatewayResolved, msgID, convID, prompt string, atts []Attachment) (string, error) {
|
|
351
|
+
inbound := cbGatewayInbound{
|
|
352
|
+
Version: "1.0",
|
|
353
|
+
ID: msgID,
|
|
354
|
+
Type: "message",
|
|
355
|
+
Source: cbGatewaySource{
|
|
356
|
+
Platform: r.platform,
|
|
357
|
+
Sender: cbGatewaySender{ID: r.sender, Name: r.sender},
|
|
358
|
+
Conversation: cbGatewayConv{ID: convID, Type: "direct"},
|
|
359
|
+
},
|
|
360
|
+
Payload: cbGatewayPayload{
|
|
361
|
+
Text: prompt,
|
|
362
|
+
Attachments: cbGatewayAttachments(atts),
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
body, err := json.Marshal(inbound)
|
|
366
|
+
if err != nil {
|
|
367
|
+
return "", fmt.Errorf("%s: 构造投递请求失败: %w", e.Name(), err)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
endpoint := r.baseURL + "/api/v1/webhooks/" + url.PathEscape(r.platform)
|
|
371
|
+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
|
372
|
+
if err != nil {
|
|
373
|
+
return "", fmt.Errorf("%s: 构造 HTTP 请求失败: %w", e.Name(), err)
|
|
374
|
+
}
|
|
375
|
+
httpReq.Header.Set("content-type", "application/json")
|
|
376
|
+
cbGatewaySetAuth(httpReq, r.password)
|
|
377
|
+
|
|
378
|
+
httpResp, err := client.Do(httpReq)
|
|
379
|
+
if err != nil {
|
|
380
|
+
return "", fmt.Errorf("%s: 投递失败(网关 %s 是否在跑?): %w", e.Name(), r.baseURL, err)
|
|
381
|
+
}
|
|
382
|
+
defer httpResp.Body.Close()
|
|
383
|
+
raw, err := io.ReadAll(io.LimitReader(httpResp.Body, cbGatewayMaxBody))
|
|
384
|
+
if err != nil {
|
|
385
|
+
return "", fmt.Errorf("%s: 读取投递响应失败: %w", e.Name(), err)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
var acc cbGatewayAccepted
|
|
389
|
+
_ = json.Unmarshal(raw, &acc) // 非 JSON 正文(401 的 text/plain 等)留给下面的状态码分支解释
|
|
390
|
+
if httpResp.StatusCode != http.StatusAccepted {
|
|
391
|
+
return "", fmt.Errorf("%s: 网关拒绝投递(HTTP %d): %s", e.Name(), httpResp.StatusCode, cbGatewayErrorDetail(raw))
|
|
392
|
+
}
|
|
393
|
+
if acc.Data.RunID == "" {
|
|
394
|
+
return "", fmt.Errorf("%s: 网关未返回 runId(HTTP %d): %s", e.Name(), httpResp.StatusCode, cbGatewayErrorDetail(raw))
|
|
395
|
+
}
|
|
396
|
+
return acc.Data.RunID, nil
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// baseResponse 构造「只有引擎/会话信息」的响应骨架(错误路径用)。
|
|
400
|
+
func (e *CodeBuddyGatewayEngine) baseResponse(start time.Time, convID string) Response {
|
|
401
|
+
return Response{
|
|
402
|
+
Engine: e.Name(),
|
|
403
|
+
SessionID: convID,
|
|
404
|
+
Latency: time.Since(start),
|
|
405
|
+
Attempts: 1,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// cbGatewaySetAuth 给请求带上网关认证(`--auth password` 时的 Bearer 口令)。
|
|
410
|
+
//
|
|
411
|
+
// 网关的 authenticate() 支持四种:?password= / gateway_session cookie /
|
|
412
|
+
// authorization: Bearer / x-access-token(实测读内核得到)。这里用最标准的 Bearer。
|
|
413
|
+
// 口令为空(`--auth none`)时不加任何头 —— 加了反而可能被当成无效凭据。
|
|
414
|
+
func cbGatewaySetAuth(req *http.Request, password string) {
|
|
415
|
+
req.Header.Set(cbGatewaySecurityHeader, "1")
|
|
416
|
+
if strings.TrimSpace(password) != "" {
|
|
417
|
+
req.Header.Set("authorization", "Bearer "+strings.TrimSpace(password))
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// cbGatewayAttachments 把 CLI 层校验过的附件映射成网关协议里的附件条目。
|
|
422
|
+
//
|
|
423
|
+
// urlType 一律 "local-path":网关与本机同机(或同容器)时路径才有效;
|
|
424
|
+
// 跨机(Tunnel 形态)时路径对网关侧无意义 —— 这一点在 README 里写明,不在这里猜。
|
|
425
|
+
func cbGatewayAttachments(atts []Attachment) []cbGatewayAttachment {
|
|
426
|
+
if len(atts) == 0 {
|
|
427
|
+
return nil
|
|
428
|
+
}
|
|
429
|
+
out := make([]cbGatewayAttachment, 0, len(atts))
|
|
430
|
+
for _, a := range atts {
|
|
431
|
+
out = append(out, cbGatewayAttachment{
|
|
432
|
+
Type: cbGatewayAttachmentType(a.MIME),
|
|
433
|
+
URL: a.Path,
|
|
434
|
+
URLType: "local-path",
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
return out
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// cbGatewayAttachmentType 按 MIME 归类附件类型(网关用它渲染清单里的中文标签)。
|
|
441
|
+
func cbGatewayAttachmentType(mime string) string {
|
|
442
|
+
m := strings.ToLower(strings.TrimSpace(mime))
|
|
443
|
+
switch {
|
|
444
|
+
case strings.HasPrefix(m, "image/"):
|
|
445
|
+
return "image"
|
|
446
|
+
case strings.HasPrefix(m, "audio/"):
|
|
447
|
+
return "voice"
|
|
448
|
+
case strings.HasPrefix(m, "video/"):
|
|
449
|
+
return "video"
|
|
450
|
+
}
|
|
451
|
+
return "file"
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// cbGatewayErrorDetail 从错误响应体里抽一句可读说明(JSON 优先,否则原文截断)。
|
|
455
|
+
func cbGatewayErrorDetail(raw []byte) string {
|
|
456
|
+
var env struct {
|
|
457
|
+
Error *struct {
|
|
458
|
+
Code string `json:"code"`
|
|
459
|
+
Message string `json:"message"`
|
|
460
|
+
} `json:"error"`
|
|
461
|
+
}
|
|
462
|
+
if err := json.Unmarshal(raw, &env); err == nil && env.Error != nil && env.Error.Message != "" {
|
|
463
|
+
if env.Error.Code != "" {
|
|
464
|
+
return env.Error.Code + ": " + env.Error.Message
|
|
465
|
+
}
|
|
466
|
+
return env.Error.Message
|
|
467
|
+
}
|
|
468
|
+
return truncateStr(strings.TrimSpace(string(raw)), 300)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// cbGatewayRandomHex 生成 n 字节随机数的十六进制串(消息 id / 会话 id 用)。
|
|
472
|
+
func cbGatewayRandomHex(n int) string {
|
|
473
|
+
b := make([]byte, n)
|
|
474
|
+
if _, err := rand.Read(b); err != nil {
|
|
475
|
+
// 随机源不可用时退化成时间戳:id 只要在本机唯一即可,不需要密码学强度。
|
|
476
|
+
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
477
|
+
}
|
|
478
|
+
return hex.EncodeToString(b)
|
|
479
|
+
}
|