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,185 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// streamjson.go - claude / codebuddy 共用的 stream-json 输入输出(附件走这条路)。
|
|
4
|
+
//
|
|
5
|
+
// 背景(2026-09-17 实测,claude / codebuddy 两家 CLI 同族):
|
|
6
|
+
//
|
|
7
|
+
// --input-format stream-json 可以喂「提示词 + 图片 content block」的 user 消息
|
|
8
|
+
// (附件唯一的原生输入通道;两家的 -p 文本模式收不了图)
|
|
9
|
+
// 但它要求 --output-format 也必须是 stream-json,所以收尾正文要从
|
|
10
|
+
// NDJSON 的 result 行里取 —— 于是非流式(Complete)在**有附件时**也得跑这条协议。
|
|
11
|
+
//
|
|
12
|
+
// 本文件把「喂 stdin + 归约 NDJSON」抽成一份,claude/codebuddy 各自复用,
|
|
13
|
+
// 免得两家各写一套解析(历史上两份实现漂移过一次,见 magic-test 的 agent-cli 注释)。
|
|
14
|
+
|
|
15
|
+
import (
|
|
16
|
+
"context"
|
|
17
|
+
"encoding/json"
|
|
18
|
+
"fmt"
|
|
19
|
+
"io"
|
|
20
|
+
"strings"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// streamJSONResult 两家 CLI 收尾行(type=result)的公共形状。
|
|
24
|
+
type streamJSONResult struct {
|
|
25
|
+
Type string `json:"type"`
|
|
26
|
+
Subtype string `json:"subtype"`
|
|
27
|
+
IsError bool `json:"is_error"`
|
|
28
|
+
Result string `json:"result"`
|
|
29
|
+
SessionID string `json:"session_id"`
|
|
30
|
+
Model string `json:"model"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// runStreamJSONIn 跑一次 stream-json 调用:增量喂给 acc,收尾行填进 fin。
|
|
34
|
+
// 返回是否见到了 result 行(未见 = 协议没跑完,调用方按错误处理)。
|
|
35
|
+
//
|
|
36
|
+
// stdin 为 nil 时不喂 stdin(提示词走命令行位置参数)。每见到一次 result 行,
|
|
37
|
+
// 除填 fin 外还会向调用方发一个 KindTurnEnd 事件 —— 常驻会话(Request.Append)
|
|
38
|
+
// 靠它判断「这轮做完了,可以继续追加」。
|
|
39
|
+
func runStreamJSONIn(
|
|
40
|
+
ctx context.Context, dir string, extraEnv []string, bin string, args []string,
|
|
41
|
+
stdin io.Reader, acc *streamAccumulator, fin *streamJSONResult,
|
|
42
|
+
) (bool, error) {
|
|
43
|
+
seen := false
|
|
44
|
+
err := runStreamCLIStdinReaderIn(ctx, dir, extraEnv, bin, args, stdin, func(line string) error {
|
|
45
|
+
isResult, perr := acc.handleNDJSONLine(line)
|
|
46
|
+
if perr != nil {
|
|
47
|
+
return perr
|
|
48
|
+
}
|
|
49
|
+
if isResult {
|
|
50
|
+
_ = json.Unmarshal([]byte(line), fin)
|
|
51
|
+
seen = true
|
|
52
|
+
// 轮次边界事件(Text = 该轮最终正文,SessionID = 该轮会话 id)。
|
|
53
|
+
// ⚠️ 它必须带 session_id:常驻会话的最终 result 信封要等会话结束才输出
|
|
54
|
+
//(默认空闲 5m),调用方要靠这一条把会话锚点绑上(见 StreamEvent.SessionID)。
|
|
55
|
+
acc.emitTurnEnd(fin.Result, fin.SessionID)
|
|
56
|
+
}
|
|
57
|
+
return nil
|
|
58
|
+
})
|
|
59
|
+
return seen, err
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// streamJSONInputPipe 常驻会话的 stdin:先写首条 user 消息(提示词 + 附件),
|
|
63
|
+
// 之后每从 appendCh 收到一条就再写一轮;appendCh 关闭 → 关掉写端 → 子进程收尾。
|
|
64
|
+
//
|
|
65
|
+
// 返回的 reader 交给子进程当 stdin(不 EOF 就一直活着)。写失败(子进程已退)时
|
|
66
|
+
// 直接收摊,不让 goroutine 泄漏。
|
|
67
|
+
func streamJSONInputPipe(prompt string, atts []Attachment, appendCh <-chan string) (io.Reader, error) {
|
|
68
|
+
first, err := streamJSONUserLine(prompt, atts)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return nil, err
|
|
71
|
+
}
|
|
72
|
+
pr, pw := io.Pipe()
|
|
73
|
+
go func() {
|
|
74
|
+
defer pw.Close()
|
|
75
|
+
if _, err := pw.Write([]byte(first)); err != nil {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
for msg := range appendCh {
|
|
79
|
+
line, lerr := streamJSONUserLine(msg, nil)
|
|
80
|
+
if lerr != nil {
|
|
81
|
+
continue // 追加内容构造失败(不该发生):跳过这一条,别把整个会话带崩
|
|
82
|
+
}
|
|
83
|
+
if _, err := pw.Write([]byte(line)); err != nil {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}()
|
|
88
|
+
return pr, nil
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// streamJSONUserLine 把「提示词 + 附件」拼成一行 stream-json 输入(含结尾换行)。
|
|
92
|
+
//
|
|
93
|
+
// content 块顺序:图片附件(image block)在前、提示词文本在后 —— 与官方
|
|
94
|
+
// 多模态示例一致(图先给,问题随后)。**非图片**附件没有对应的 content 类型,
|
|
95
|
+
// 只能把绝对路径追加进文本(与 trae/openclaw 的兜底同一语义)。
|
|
96
|
+
func streamJSONUserLine(prompt string, atts []Attachment) (string, error) {
|
|
97
|
+
blocks := make([]map[string]any, 0, len(atts)+1)
|
|
98
|
+
var nonImage []Attachment
|
|
99
|
+
for _, a := range atts {
|
|
100
|
+
if !a.IsImage() {
|
|
101
|
+
nonImage = append(nonImage, a)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
data, err := readAttachmentBase64(a)
|
|
105
|
+
if err != nil {
|
|
106
|
+
return "", err
|
|
107
|
+
}
|
|
108
|
+
mime := a.MIME
|
|
109
|
+
if mime == "" {
|
|
110
|
+
mime = "image/png"
|
|
111
|
+
}
|
|
112
|
+
blocks = append(blocks, map[string]any{
|
|
113
|
+
"type": "image",
|
|
114
|
+
"source": map[string]any{"type": "base64", "media_type": mime, "data": data},
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
text := appendAttachmentSection(prompt, nonImage)
|
|
118
|
+
blocks = append(blocks, map[string]any{"type": "text", "text": text})
|
|
119
|
+
|
|
120
|
+
msg := map[string]any{
|
|
121
|
+
"type": "user",
|
|
122
|
+
"message": map[string]any{"role": "user", "content": blocks},
|
|
123
|
+
}
|
|
124
|
+
raw, err := json.Marshal(msg)
|
|
125
|
+
if err != nil {
|
|
126
|
+
return "", err
|
|
127
|
+
}
|
|
128
|
+
return string(raw) + "\n", nil
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// hasImageAttachment 是否含图片附件(决定要不要走 stream-json 输入通道)。
|
|
132
|
+
func hasImageAttachment(atts []Attachment) bool {
|
|
133
|
+
for _, a := range atts {
|
|
134
|
+
if a.IsImage() {
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// setArgValue 把 args 里 <flag> 的下一个值改成 want(找不到就追加 <flag> want)。
|
|
142
|
+
// claude/codebuddy 的 --output-format json → stream-json 就靠它。
|
|
143
|
+
func setArgValue(args []string, flag, want string) []string {
|
|
144
|
+
for i := 0; i < len(args)-1; i++ {
|
|
145
|
+
if args[i] == flag {
|
|
146
|
+
args[i+1] = want
|
|
147
|
+
return args
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return append(args, flag, want)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// finalizeStreamText 取最终正文:优先收尾行的 result,其次增量拼接。
|
|
154
|
+
func finalizeStreamText(fin streamJSONResult, acc *streamAccumulator) string {
|
|
155
|
+
if t := strings.TrimSpace(fin.Result); t != "" {
|
|
156
|
+
return t
|
|
157
|
+
}
|
|
158
|
+
return strings.TrimSpace(acc.Text.String())
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// streamJSONArgs 把「文本模式」的 args 改造成 stream-json 输入模式:
|
|
162
|
+
//
|
|
163
|
+
// 去掉末尾的位置参数(提示词走 stdin,不再作为命令行参数)
|
|
164
|
+
// --output-format → stream-json
|
|
165
|
+
// 追加 --input-format stream-json --verbose(+ stream 模式下再加 --include-partial-messages)
|
|
166
|
+
func streamJSONArgs(baseArgs []string, prompt string, streaming bool) []string {
|
|
167
|
+
args := make([]string, 0, len(baseArgs))
|
|
168
|
+
for _, a := range baseArgs {
|
|
169
|
+
if a == prompt { // 末尾的位置参数就是提示词,排除掉
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
args = append(args, a)
|
|
173
|
+
}
|
|
174
|
+
args = setArgValue(args, "--output-format", "stream-json")
|
|
175
|
+
args = append(args, "--input-format", "stream-json", "--verbose")
|
|
176
|
+
if streaming {
|
|
177
|
+
args = append(args, "--include-partial-messages")
|
|
178
|
+
}
|
|
179
|
+
return args
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// errEmptyStreamContent 两家在 stream-json 收尾为空时的公共错误。
|
|
183
|
+
func errEmptyStreamContent(label string) error {
|
|
184
|
+
return fmt.Errorf("%s CLI returned empty result", label)
|
|
185
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// tags.go - 思维链标签常量与剥离逻辑。
|
|
4
|
+
//
|
|
5
|
+
// 部分推理模型(MiniMax-M3 等)经 simonw/llm CLI 输出时,思维链以
|
|
6
|
+
// 标签形式直接混在正文里。本文件提供:
|
|
7
|
+
//
|
|
8
|
+
// thinkOpenTag / thinkCloseTag 标签常量(运行时值)
|
|
9
|
+
// stripThinkingTags 非流式剥离(正文里删掉标签块)
|
|
10
|
+
// splitThinkingTags 非流式拆分(正文、思维链都要)— 在 llmengine.go
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ 标签常量必须分段拼接,不能写成连续字面量 —— 该标签 token 序列会被
|
|
13
|
+
// 某些分词器当作特殊 token 并在写盘时改写(实测被替换成全角竖线形式,
|
|
14
|
+
// 长度从 8 变 19),导致源码里的标签与实际字符串不符、Index 偏移错位。
|
|
15
|
+
// 分段拼接后运行时值不变,但源码中不出现该 token 序列。
|
|
16
|
+
|
|
17
|
+
import "strings"
|
|
18
|
+
|
|
19
|
+
// 思维链标签的构造片段(分段拼接,见文件头说明)。
|
|
20
|
+
const (
|
|
21
|
+
tagLT = "<"
|
|
22
|
+
tagGT = ">"
|
|
23
|
+
tagSlash = "/"
|
|
24
|
+
tagWord = "think"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// thinkOpenTag / thinkCloseTag 运行时值为思维链的开始/结束标签。
|
|
28
|
+
var (
|
|
29
|
+
thinkOpenTag = tagLT + tagWord + tagGT
|
|
30
|
+
thinkCloseTag = tagLT + tagSlash + tagWord + tagGT
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// stripThinkingTags 剥离正文中残留的思维链标签块,返回剩余正文。
|
|
34
|
+
// 有开标签无闭标签时,开标签之后的残余整段丢弃(视为未完成的思考)。
|
|
35
|
+
func stripThinkingTags(s string) string {
|
|
36
|
+
for {
|
|
37
|
+
i := strings.Index(s, thinkOpenTag)
|
|
38
|
+
if i < 0 {
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
rest := s[i+len(thinkOpenTag):]
|
|
42
|
+
j := strings.Index(rest, thinkCloseTag)
|
|
43
|
+
if j < 0 {
|
|
44
|
+
// 有开无闭:整段视为未完成的思考,丢弃。
|
|
45
|
+
s = s[:i]
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
// 相对 rest 切片,避免绝对/相对偏移混算。
|
|
49
|
+
s = s[:i] + rest[j+len(thinkCloseTag):]
|
|
50
|
+
}
|
|
51
|
+
return strings.TrimSpace(s)
|
|
52
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
import "strings"
|
|
4
|
+
|
|
5
|
+
/* 工具调用的**子形态**(tool kind)—— 由归一化层判定,渲染层不再靠工具名猜。
|
|
6
|
+
*
|
|
7
|
+
* ── 为什么要有它 ──
|
|
8
|
+
* 客户端要按形态给不同的卡:命令、文件变更、网页检索、MCP、子代理、提问授权、通用。
|
|
9
|
+
* 此前只能在**渲染层按工具名猜**(见 magic-test 的 qaFileChangeOf(u.name, uo)),
|
|
10
|
+
* 于是每接一个新引擎就漏一批名字,同一个名字在不同引擎里含义还会漂,
|
|
11
|
+
* 两个界面(旧工作台 / PC 基础对话 UI)各猜一套,必然越走越远。
|
|
12
|
+
* 放在归一化层 = **一处判定**,SSE 流与会话日志两条通道同时带上,两边共用同一份。
|
|
13
|
+
*
|
|
14
|
+
* ── 取值为什么是这几个词 ──
|
|
15
|
+
* 刻意对齐 agents-anywhere 的 `ToolContentKind`(connector/connector/runtime_protocol/timeline.py):
|
|
16
|
+
* 同一样东西在两个界面上说同一个词,才有「统一」可言。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const (
|
|
20
|
+
// ToolKindCommand 执行命令(Bash / shell / exec 一类)。
|
|
21
|
+
ToolKindCommand = "command"
|
|
22
|
+
// ToolKindFileChange 改动文件(Edit / Write / apply_patch 一类)。
|
|
23
|
+
ToolKindFileChange = "file_change"
|
|
24
|
+
// ToolKindWebSearch 联网检索或抓取网页。
|
|
25
|
+
ToolKindWebSearch = "web_search"
|
|
26
|
+
// ToolKindMCP MCP 服务器提供的工具(名字通常带 `mcp__` 前缀)。
|
|
27
|
+
ToolKindMCP = "mcp"
|
|
28
|
+
// ToolKindAgentCall 派子代理(Task / Agent 一类,主会话里会渲染成委派卡)。
|
|
29
|
+
ToolKindAgentCall = "agent_call"
|
|
30
|
+
// ToolKindInputRequest 向用户提问、要输入(AskUserQuestion 一类,会渲染成提问卡)。
|
|
31
|
+
ToolKindInputRequest = "input_request"
|
|
32
|
+
// ToolKindPermission 请求授权(权限确认那一类,会渲染成审批卡)。
|
|
33
|
+
ToolKindPermission = "permission"
|
|
34
|
+
// ToolKindToolCall 其余**已知**工具(读文件 / 列目录 / 待办…)。
|
|
35
|
+
//
|
|
36
|
+
// ⚠️ 认不出来也归这里,**不归 unknown** —— 与 anywhere 的 claude projector 一致
|
|
37
|
+
//(未知工具名 → 通用 ToolCallContent):它是「有一次工具调用但形态未细分」,
|
|
38
|
+
// 不是「不知道这是什么事件」。unknown 留给连工具名都没有的情况。
|
|
39
|
+
ToolKindToolCall = "tool_call"
|
|
40
|
+
// ToolKindUnknown 连名字都没有,无法判定形态。
|
|
41
|
+
ToolKindUnknown = "unknown"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// 名字 → 形态。全部小写、无命名空间前缀。
|
|
45
|
+
// 覆盖本仓库已接的引擎:claude / codebuddy(Bash、Edit、Read、Task…)、
|
|
46
|
+
// codex(command_execution、apply_patch…)、openclaw / trae(namespace.tool 形状,已剥前缀)。
|
|
47
|
+
var toolKindByName = map[string]string{
|
|
48
|
+
// 执行命令
|
|
49
|
+
"bash": ToolKindCommand, "shell": ToolKindCommand, "sh": ToolKindCommand,
|
|
50
|
+
"exec": ToolKindCommand, "execute": ToolKindCommand, "run": ToolKindCommand,
|
|
51
|
+
"command": ToolKindCommand, "command_execution": ToolKindCommand,
|
|
52
|
+
"bashoutput": ToolKindCommand, "killshell": ToolKindCommand, "terminal": ToolKindCommand,
|
|
53
|
+
"local_shell": ToolKindCommand, "run_command": ToolKindCommand,
|
|
54
|
+
// 改动文件
|
|
55
|
+
"edit": ToolKindFileChange, "write": ToolKindFileChange, "multiedit": ToolKindFileChange,
|
|
56
|
+
"notebookedit": ToolKindFileChange, "apply_patch": ToolKindFileChange,
|
|
57
|
+
"str_replace_editor": ToolKindFileChange, "str_replace": ToolKindFileChange,
|
|
58
|
+
"create_file": ToolKindFileChange, "write_file": ToolKindFileChange,
|
|
59
|
+
"edit_file": ToolKindFileChange, "patch": ToolKindFileChange,
|
|
60
|
+
// 联网
|
|
61
|
+
"websearch": ToolKindWebSearch, "web_search": ToolKindWebSearch,
|
|
62
|
+
"webfetch": ToolKindWebSearch, "web_fetch": ToolKindWebSearch,
|
|
63
|
+
"fetch": ToolKindWebSearch, "browser": ToolKindWebSearch,
|
|
64
|
+
// 子代理
|
|
65
|
+
"task": ToolKindAgentCall, "agent": ToolKindAgentCall, "subagent": ToolKindAgentCall,
|
|
66
|
+
"delegate": ToolKindAgentCall,
|
|
67
|
+
// 提问 / 要输入
|
|
68
|
+
"askuserquestion": ToolKindInputRequest, "ask_user_question": ToolKindInputRequest,
|
|
69
|
+
"elicit": ToolKindInputRequest, "input_request": ToolKindInputRequest,
|
|
70
|
+
// 授权
|
|
71
|
+
"permission": ToolKindPermission, "can_use_tool": ToolKindPermission,
|
|
72
|
+
"askpermission": ToolKindPermission, "approve": ToolKindPermission,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ToolKindFor 判定一次工具调用的子形态。name 是引擎给的工具名;args 暂未参与判定
|
|
76
|
+
// (留作后续细分,例如按命令内容区分只读查询与写操作)。认不出来的名字归 `tool_call`。
|
|
77
|
+
func ToolKindFor(name, args string) string {
|
|
78
|
+
_ = args // 预留:形态细分仍可能要看参数,先固定签名免得调用点来回改
|
|
79
|
+
n := strings.ToLower(strings.TrimSpace(name))
|
|
80
|
+
if n == "" {
|
|
81
|
+
return ToolKindUnknown
|
|
82
|
+
}
|
|
83
|
+
// MCP 的名字带命名空间前缀(`mcp__github__create_issue` / `mcp.github.x`),先认它,
|
|
84
|
+
// 否则会被下面剥前缀的动作毁掉线索。
|
|
85
|
+
if strings.HasPrefix(n, "mcp__") || strings.HasPrefix(n, "mcp.") {
|
|
86
|
+
return ToolKindMCP
|
|
87
|
+
}
|
|
88
|
+
// 其余命名空间形状(trae / openclaw 会给 `namespace.tool`)剥掉前缀再查表。
|
|
89
|
+
if i := strings.LastIndex(n, "."); i >= 0 && i < len(n)-1 {
|
|
90
|
+
n = n[i+1:]
|
|
91
|
+
}
|
|
92
|
+
if k, ok := toolKindByName[n]; ok {
|
|
93
|
+
return k
|
|
94
|
+
}
|
|
95
|
+
return ToolKindToolCall
|
|
96
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
/* 这份表就是「形态判定」的契约。改它等于改两个界面的渲染分支,别顺手改。 */
|
|
6
|
+
func TestToolKindFor(t *testing.T) {
|
|
7
|
+
cases := []struct {
|
|
8
|
+
name string
|
|
9
|
+
want string
|
|
10
|
+
}{
|
|
11
|
+
// 执行命令(claude / codebuddy / codex / openclaw 各自的名字)
|
|
12
|
+
{"Bash", ToolKindCommand},
|
|
13
|
+
{"bash", ToolKindCommand},
|
|
14
|
+
{"command", ToolKindCommand},
|
|
15
|
+
{"command_execution", ToolKindCommand},
|
|
16
|
+
{"BashOutput", ToolKindCommand},
|
|
17
|
+
{"KillShell", ToolKindCommand},
|
|
18
|
+
{"terminal", ToolKindCommand},
|
|
19
|
+
// 改动文件
|
|
20
|
+
{"Edit", ToolKindFileChange},
|
|
21
|
+
{"Write", ToolKindFileChange},
|
|
22
|
+
{"MultiEdit", ToolKindFileChange},
|
|
23
|
+
{"NotebookEdit", ToolKindFileChange},
|
|
24
|
+
{"apply_patch", ToolKindFileChange},
|
|
25
|
+
{"str_replace_editor", ToolKindFileChange},
|
|
26
|
+
// 联网
|
|
27
|
+
{"WebSearch", ToolKindWebSearch},
|
|
28
|
+
{"WebFetch", ToolKindWebSearch},
|
|
29
|
+
{"web_search", ToolKindWebSearch},
|
|
30
|
+
// MCP:带命名空间前缀,且**不能**被剥前缀那一步吃掉
|
|
31
|
+
{"mcp__github__create_issue", ToolKindMCP},
|
|
32
|
+
{"mcp.github.create_issue", ToolKindMCP},
|
|
33
|
+
{"MCP__filesystem__read", ToolKindMCP},
|
|
34
|
+
// 子代理
|
|
35
|
+
{"Task", ToolKindAgentCall},
|
|
36
|
+
{"Agent", ToolKindAgentCall},
|
|
37
|
+
// 提问 / 授权
|
|
38
|
+
{"AskUserQuestion", ToolKindInputRequest},
|
|
39
|
+
{"can_use_tool", ToolKindPermission},
|
|
40
|
+
// 命名空间形状(trae / openclaw):剥前缀后仍要查得出来
|
|
41
|
+
{"builtin.Bash", ToolKindCommand},
|
|
42
|
+
{"tools.Edit", ToolKindFileChange},
|
|
43
|
+
{"acp.Task", ToolKindAgentCall},
|
|
44
|
+
// 已知但未细分的工具 → tool_call(对齐 anywhere 的通用 ToolCallContent)
|
|
45
|
+
{"Read", ToolKindToolCall},
|
|
46
|
+
{"Glob", ToolKindToolCall},
|
|
47
|
+
{"Grep", ToolKindToolCall},
|
|
48
|
+
{"TodoWrite", ToolKindToolCall},
|
|
49
|
+
{"exit_plan_mode", ToolKindToolCall},
|
|
50
|
+
// 从没见过的名字也归 tool_call,**不是** unknown
|
|
51
|
+
{"some_brand_new_tool", ToolKindToolCall},
|
|
52
|
+
// 连名字都没有 → unknown
|
|
53
|
+
{"", ToolKindUnknown},
|
|
54
|
+
{" ", ToolKindUnknown},
|
|
55
|
+
}
|
|
56
|
+
for _, c := range cases {
|
|
57
|
+
if got := ToolKindFor(c.name, ""); got != c.want {
|
|
58
|
+
t.Errorf("ToolKindFor(%q) = %q, want %q", c.name, got, c.want)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* 名字大小写与前后空格不该影响判定(引擎给的名字大小写并不统一)。 */
|
|
64
|
+
func TestToolKindForNormalizesName(t *testing.T) {
|
|
65
|
+
for _, n := range []string{" Bash ", "BASH", "bAsH"} {
|
|
66
|
+
if got := ToolKindFor(n, ""); got != ToolKindCommand {
|
|
67
|
+
t.Errorf("ToolKindFor(%q) = %q, want %q", n, got, ToolKindCommand)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// tools_suffix_test.go - 「工具启用无效」回归测试。
|
|
4
|
+
//
|
|
5
|
+
// 背景:noToolSuffix 明文写着「严禁使用任何工具」。此前 codebuddy / trae
|
|
6
|
+
// 无条件把它注入 system prompt,导致 --tools on / 白名单模式下 CLI 侧
|
|
7
|
+
// 工具已开、system prompt 却在压制模型调用,表现为「启用了工具但没有
|
|
8
|
+
// 网络搜索」。
|
|
9
|
+
//
|
|
10
|
+
// 正确语义:off 模式注入(防「伪工具调用」),on / 白名单模式**不注入**。
|
|
11
|
+
|
|
12
|
+
import (
|
|
13
|
+
"context"
|
|
14
|
+
"os"
|
|
15
|
+
"path/filepath"
|
|
16
|
+
"strings"
|
|
17
|
+
"testing"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
// argLogger 造一个记录参数的假 CLI,返回 (引擎可用的 cli 路径, 读取参数函数)。
|
|
21
|
+
func argLogger(t *testing.T) (string, func() string) {
|
|
22
|
+
t.Helper()
|
|
23
|
+
dir := t.TempDir()
|
|
24
|
+
log := filepath.Join(dir, "args.log")
|
|
25
|
+
cli := filepath.Join(dir, "fake")
|
|
26
|
+
script := "#!/bin/sh\nprintf '%s\\n' \"$*\" > " + log +
|
|
27
|
+
"\necho '{\"type\":\"result\",\"result\":\"ok\"}'\n"
|
|
28
|
+
if err := os.WriteFile(cli, []byte(script), 0o755); err != nil {
|
|
29
|
+
t.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
return cli, func() string {
|
|
32
|
+
b, _ := os.ReadFile(log)
|
|
33
|
+
return string(b)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func TestCodeBuddyToolModeSuffixMatrix(t *testing.T) {
|
|
38
|
+
cases := []struct {
|
|
39
|
+
name string
|
|
40
|
+
tools ToolsMode
|
|
41
|
+
wantSuffix bool
|
|
42
|
+
wantToolArg []string // 必须出现的参数对
|
|
43
|
+
absentArg []string // 必须不出现的参数
|
|
44
|
+
}{
|
|
45
|
+
{
|
|
46
|
+
name: "off 注入约束且禁工具",
|
|
47
|
+
tools: ToolsOff,
|
|
48
|
+
wantSuffix: true,
|
|
49
|
+
wantToolArg: []string{"--tools", ""},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "on 不注入约束(否则工具被压制)",
|
|
53
|
+
tools: ToolsOn,
|
|
54
|
+
wantSuffix: false,
|
|
55
|
+
absentArg: []string{"[约束]"},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "allowlist 不注入约束且带上白名单",
|
|
59
|
+
tools: ToolsAllowlist([]string{"WebSearch", "WebFetch"}),
|
|
60
|
+
wantSuffix: false,
|
|
61
|
+
absentArg: []string{"[约束]"},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for _, tc := range cases {
|
|
66
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
67
|
+
cli, argsOf := argLogger(t)
|
|
68
|
+
e := &CodeBuddyEngine{BinPath: cli}
|
|
69
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
70
|
+
Tools: tc.tools,
|
|
71
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
72
|
+
}); err != nil {
|
|
73
|
+
t.Fatalf("Complete: %v", err)
|
|
74
|
+
}
|
|
75
|
+
args := argsOf()
|
|
76
|
+
|
|
77
|
+
if got := strings.Contains(args, "[约束]"); got != tc.wantSuffix {
|
|
78
|
+
t.Errorf("noToolSuffix 注入 = %v, want %v\nargs=%q", got, tc.wantSuffix, args)
|
|
79
|
+
}
|
|
80
|
+
for _, a := range tc.wantToolArg {
|
|
81
|
+
if a == "" {
|
|
82
|
+
if !strings.Contains(args, "--tools ") {
|
|
83
|
+
t.Errorf("缺少 --tools 参数: %q", args)
|
|
84
|
+
}
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if !strings.Contains(args, a) {
|
|
88
|
+
t.Errorf("缺少参数 %q: %q", a, args)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for _, a := range tc.absentArg {
|
|
92
|
+
if strings.Contains(args, a) {
|
|
93
|
+
t.Errorf("不应包含 %q: %q", a, args)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// 白名单模式下白名单工具名必须出现
|
|
97
|
+
if tc.name == "allowlist 不注入约束且带上白名单" {
|
|
98
|
+
if !strings.Contains(args, "WebSearch") {
|
|
99
|
+
t.Errorf("白名单工具名未传入: %q", args)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func TestTraeToolModeSuffix(t *testing.T) {
|
|
107
|
+
cases := []struct {
|
|
108
|
+
name string
|
|
109
|
+
tools ToolsMode
|
|
110
|
+
wantSuffix bool
|
|
111
|
+
}{
|
|
112
|
+
{"off 注入约束", ToolsOff, true},
|
|
113
|
+
{"on 不注入约束", ToolsOn, false},
|
|
114
|
+
{"allowlist 不注入约束", ToolsAllowlist([]string{"Bash"}), false},
|
|
115
|
+
}
|
|
116
|
+
for _, tc := range cases {
|
|
117
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
118
|
+
cli, argsOf := argLogger(t)
|
|
119
|
+
e := &TraeEngine{BinPath: cli}
|
|
120
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
121
|
+
Tools: tc.tools,
|
|
122
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
123
|
+
}); err != nil {
|
|
124
|
+
t.Fatalf("Complete: %v", err)
|
|
125
|
+
}
|
|
126
|
+
args := argsOf()
|
|
127
|
+
if got := strings.Contains(args, "[约束]"); got != tc.wantSuffix {
|
|
128
|
+
t.Errorf("noToolSuffix = %v, want %v\nargs=%q", got, tc.wantSuffix, args)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// TestTraeToolModeMapping 固化 trae 的工具模式契约(2026-09-16 真机验证)。
|
|
135
|
+
//
|
|
136
|
+
// trae-cli 的权限开关实测语义:
|
|
137
|
+
// - --allowed-tool 只做「自动批准」,不裁剪工具集(--allowed-tool WebFetch
|
|
138
|
+
// 仍下发全部 18 个工具);
|
|
139
|
+
// - --disallowed-tool 才真正做减法;
|
|
140
|
+
// - -y 跳过全部权限检查 ⇒ 全工具可用。
|
|
141
|
+
//
|
|
142
|
+
// 因此白名单模式在 trae 上定义为「默认开启所有工具」:走 -y 全放行,
|
|
143
|
+
// 不再输出 --allowed-tool(它既不能收窄,全放行后也毫无作用,只会造成
|
|
144
|
+
// 「allowlist 被 -y 架空」的错觉)。
|
|
145
|
+
func TestTraeToolModeMapping(t *testing.T) {
|
|
146
|
+
cases := []struct {
|
|
147
|
+
name string
|
|
148
|
+
tools ToolsMode
|
|
149
|
+
wantArgs []string
|
|
150
|
+
absentArgs []string
|
|
151
|
+
wantDisallow bool // 是否出现任何 --disallowed-tool
|
|
152
|
+
}{
|
|
153
|
+
{
|
|
154
|
+
name: "off 逐个真名禁用且不全放行",
|
|
155
|
+
tools: ToolsOff,
|
|
156
|
+
wantArgs: []string{"Bash", "Edit", "Write", "Glob", "Grep", "Read"},
|
|
157
|
+
absentArgs: []string{"-y", "Replace"},
|
|
158
|
+
wantDisallow: true,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "on 全放行不做减法",
|
|
162
|
+
tools: ToolsOn,
|
|
163
|
+
wantArgs: []string{"-y"},
|
|
164
|
+
wantDisallow: false,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "allowlist 默认开启所有工具(全放行、不收窄)",
|
|
168
|
+
tools: ToolsAllowlist([]string{"WebFetch", "Bash"}),
|
|
169
|
+
wantArgs: []string{"-y"},
|
|
170
|
+
absentArgs: []string{"--allowed-tool"},
|
|
171
|
+
wantDisallow: false,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for _, tc := range cases {
|
|
176
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
177
|
+
cli, argsOf := argLogger(t)
|
|
178
|
+
e := &TraeEngine{BinPath: cli}
|
|
179
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
180
|
+
Tools: tc.tools,
|
|
181
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
182
|
+
}); err != nil {
|
|
183
|
+
t.Fatalf("Complete: %v", err)
|
|
184
|
+
}
|
|
185
|
+
args := argsOf()
|
|
186
|
+
|
|
187
|
+
for _, a := range tc.wantArgs {
|
|
188
|
+
if !hasArgToken(args, a) {
|
|
189
|
+
t.Errorf("缺少参数 %q: %q", a, args)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for _, a := range tc.absentArgs {
|
|
193
|
+
if strings.Contains(args, a) {
|
|
194
|
+
t.Errorf("不应包含 %q: %q", a, args)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// off 必须逐个用 --disallowed-tool 列出真名;on/白名单必须是纯全放行。
|
|
198
|
+
gotDisallow := strings.Contains(args, "--disallowed-tool")
|
|
199
|
+
if gotDisallow != tc.wantDisallow {
|
|
200
|
+
t.Errorf("--disallowed-tool 出现 = %v, want %v\nargs=%q", gotDisallow, tc.wantDisallow, args)
|
|
201
|
+
}
|
|
202
|
+
if tc.wantDisallow {
|
|
203
|
+
for _, a := range traeOffDisableTools {
|
|
204
|
+
if !strings.Contains(args, "--disallowed-tool "+a) {
|
|
205
|
+
t.Errorf("off 模式漏禁 %q: %q", a, args)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// hasArgToken 判断命令行里是否出现某个完整参数(避免 "-y" 被别的串误命中)。
|
|
214
|
+
func hasArgToken(args, want string) bool {
|
|
215
|
+
for _, f := range strings.Fields(args) {
|
|
216
|
+
if f == want {
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// nil ToolsMode 必须按 off 处理(默认关工具,向后兼容)。
|
|
224
|
+
func TestToolsIsOffDefaultsToOff(t *testing.T) {
|
|
225
|
+
if !toolsIsOff(Request{}) {
|
|
226
|
+
t.Error("空 Tools 应视为 off")
|
|
227
|
+
}
|
|
228
|
+
if toolsIsOff(Request{Tools: ToolsOn}) {
|
|
229
|
+
t.Error("ToolsOn 不应判定为 off")
|
|
230
|
+
}
|
|
231
|
+
if toolsIsOff(Request{Tools: ToolsAllowlist([]string{"Read"})}) {
|
|
232
|
+
t.Error("白名单不应判定为 off")
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// noToolSuffix 必须显式禁止「伪造工具返回」。
|
|
237
|
+
//
|
|
238
|
+
// 背景:off 模式下模型被要求「输出工具抓到的内容」时会编造返回体
|
|
239
|
+
// (实测约 1/6),仅禁「调用工具」堵不住,必须有一条明文禁止伪造。
|
|
240
|
+
func TestNoToolSuffixForbidsFabricatedToolOutput(t *testing.T) {
|
|
241
|
+
if !strings.Contains(noToolSuffix, "伪造工具返回") {
|
|
242
|
+
t.Errorf("noToolSuffix 缺少「禁止伪造工具返回」约束: %q", noToolSuffix)
|
|
243
|
+
}
|
|
244
|
+
// 该约束要能经 FlattenPrompt 落到 system prompt 里。
|
|
245
|
+
got := FlattenPrompt("你是助手", nil, true)
|
|
246
|
+
if !strings.Contains(got, "伪造工具返回") {
|
|
247
|
+
t.Errorf("伪造工具返回约束未进入 prompt: %q", got)
|
|
248
|
+
}
|
|
249
|
+
// 且 off 模式下 codebuddy 参数里能看到。
|
|
250
|
+
cli, argsOf := argLogger(t)
|
|
251
|
+
e := &CodeBuddyEngine{BinPath: cli}
|
|
252
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
253
|
+
Tools: ToolsOff,
|
|
254
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
255
|
+
}); err != nil {
|
|
256
|
+
t.Fatalf("Complete: %v", err)
|
|
257
|
+
}
|
|
258
|
+
if !strings.Contains(argsOf(), "伪造工具返回") {
|
|
259
|
+
t.Errorf("codebuddy off 模式 args 未带伪造约束: %q", argsOf())
|
|
260
|
+
}
|
|
261
|
+
}
|