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,406 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// claude.go - Claude Code CLI 引擎。
|
|
4
|
+
//
|
|
5
|
+
// 后端:claude(Claude Code,本机 /opt/homebrew/bin/claude)。
|
|
6
|
+
// 非交互模式:-p(--print)+ --output-format json,拿到稳定 envelope:
|
|
7
|
+
//
|
|
8
|
+
// {"type":"result","subtype":"success","result":"<正文>",
|
|
9
|
+
// "session_id":"...","is_error":false, "total_cost_usd":...}
|
|
10
|
+
//
|
|
11
|
+
// 关键 flags:
|
|
12
|
+
//
|
|
13
|
+
// --model <m> 传裸模型名(sonnet / opus / claude-sonnet-4-6...)
|
|
14
|
+
// --tools "" 禁用全部内置工具,纯 chat 一次成型
|
|
15
|
+
// --permission-mode <mode> 四档权限档位(见 permission.go):
|
|
16
|
+
// manual → default;accept-edits → acceptEdits;
|
|
17
|
+
// auto → auto;full → bypassPermissions(默认档)
|
|
18
|
+
// -r, --resume <id> 续接指定会话(Request.SessionID 非空时传)。
|
|
19
|
+
// -c, --continue 续接当前目录最近一次会话(Request.Continue)。
|
|
20
|
+
// 注意:默认新会话不传 --no-session-persistence,
|
|
21
|
+
// 否则首轮会话不落盘,返回的 session_id 无法续接
|
|
22
|
+
// (claude 帮助明确写着该 flag "cannot be resumed")
|
|
23
|
+
// --append-system-prompt <s> 注入 system prompt
|
|
24
|
+
// --settings <json> 单份 JSON 载荷,合并注入三件事:
|
|
25
|
+
// ① MaxTokens → env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
|
26
|
+
// (claude 无原生 max-tokens flag)
|
|
27
|
+
// ② 四档模型的 sandbox 配置(claude **没有**
|
|
28
|
+
// --sandbox flag,只能走 settings)
|
|
29
|
+
// ③ 第 3 档的 autoMode 受信边界 + deny/ask 规则
|
|
30
|
+
// 三者必须合并:--settings 不是可重复 flag。
|
|
31
|
+
//
|
|
32
|
+
// 档位为什么走 --permission-mode 而不是 settings.permissions.defaultMode:
|
|
33
|
+
// 官方明确 auto / bypassPermissions 在项目级 .claude/settings.json 与本地级
|
|
34
|
+
// .claude/settings.local.json 中**不生效**,只有 --permission-mode(与 --settings
|
|
35
|
+
// 的 flag 层)可靠。详见 permission.go。
|
|
36
|
+
//
|
|
37
|
+
// CLI 路径解析顺序:显式 BinPath → MAGIC_AGENT_CLAUDE_BIN → 常见安装位 → PATH
|
|
38
|
+
//(探测链统一收敛在 engine_base.go 的 cliBase)。
|
|
39
|
+
|
|
40
|
+
import (
|
|
41
|
+
"context"
|
|
42
|
+
"encoding/json"
|
|
43
|
+
"fmt"
|
|
44
|
+
"io"
|
|
45
|
+
"os"
|
|
46
|
+
"path/filepath"
|
|
47
|
+
"strings"
|
|
48
|
+
"time"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// ClaudeEngine 通过 Claude Code CLI 实现 Engine。
|
|
52
|
+
type ClaudeEngine struct {
|
|
53
|
+
// BinPath 显式指定 CLI 路径(空 = 自动探测)。测试注入用。
|
|
54
|
+
BinPath string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Name 实现 Engine。
|
|
58
|
+
func (e *ClaudeEngine) Name() string { return "claude" }
|
|
59
|
+
|
|
60
|
+
// DefaultClaudeTimeout 单次尝试默认超时。
|
|
61
|
+
const DefaultClaudeTimeout = 5 * time.Minute
|
|
62
|
+
|
|
63
|
+
// DefaultClaudeModel claude 引擎的默认模型(Claude Code CLI 默认即主模型,
|
|
64
|
+
// 显式传空串别名让 CLI 自己决定,不在代理层硬编码具体版本号)。
|
|
65
|
+
const DefaultClaudeModel = ""
|
|
66
|
+
|
|
67
|
+
// bin 探测 claude CLI 路径(委托 cliBase 统一探测链)。
|
|
68
|
+
func (e *ClaudeEngine) bin() string {
|
|
69
|
+
return claudeBase.resolve(e.BinPath)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Detect 实现 Engine。
|
|
73
|
+
func (e *ClaudeEngine) Detect() (bool, string) {
|
|
74
|
+
p := claudeBase.resolve(e.BinPath)
|
|
75
|
+
if p == "" {
|
|
76
|
+
return false, claudeBase.notFound
|
|
77
|
+
}
|
|
78
|
+
return true, p
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// claudeSettingsPath 用户级 settings.json 路径(CLAUDE_CONFIG_DIR 优先,
|
|
82
|
+
// 与 claude 自身的配置目录约定一致)。
|
|
83
|
+
func claudeSettingsPath() string {
|
|
84
|
+
dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR"))
|
|
85
|
+
if dir == "" {
|
|
86
|
+
home, err := os.UserHomeDir()
|
|
87
|
+
if err != nil {
|
|
88
|
+
return ""
|
|
89
|
+
}
|
|
90
|
+
dir = filepath.Join(home, ".claude")
|
|
91
|
+
}
|
|
92
|
+
return filepath.Join(dir, "settings.json")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ListModels 实现 ModelLister。
|
|
96
|
+
//
|
|
97
|
+
// claude 没有 models 子命令(--help 只有 agents/auth/doctor/mcp/plugin/...),
|
|
98
|
+
// 所以清单取自用户配置 ~/.claude/settings.json:顶层 model + env 里
|
|
99
|
+
// ANTHROPIC_*MODEL / CLAUDE_CODE_*MODEL(**id 那一格**)的值 —— 这正是 -m 能收的标识
|
|
100
|
+
// (别名由 CLI 解析,代理侧不硬编码任何模型名)。同组的 `*_MODEL_NAME` 是显示名,不算
|
|
101
|
+
// (见 models.go 的 claudeModelsFromSettings)。
|
|
102
|
+
func (e *ClaudeEngine) ListModels(_ context.Context) ([]string, error) {
|
|
103
|
+
path := claudeSettingsPath()
|
|
104
|
+
if path == "" {
|
|
105
|
+
return nil, fmt.Errorf("%w: cannot resolve claude config dir", ErrNoModelSource)
|
|
106
|
+
}
|
|
107
|
+
data, err := os.ReadFile(path)
|
|
108
|
+
if err != nil {
|
|
109
|
+
if os.IsNotExist(err) {
|
|
110
|
+
return nil, fmt.Errorf("%w: %s not found (claude has no models subcommand)", ErrNoModelSource, path)
|
|
111
|
+
}
|
|
112
|
+
return nil, fmt.Errorf("read %s: %w", path, err)
|
|
113
|
+
}
|
|
114
|
+
models, err := claudeModelsFromSettings(data)
|
|
115
|
+
if err != nil {
|
|
116
|
+
return nil, err
|
|
117
|
+
}
|
|
118
|
+
if len(models) == 0 {
|
|
119
|
+
return nil, fmt.Errorf("%w: no model entries in %s", ErrNoModelSource, path)
|
|
120
|
+
}
|
|
121
|
+
return models, nil
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// claudeResult 对应 --output-format json 的 envelope。
|
|
125
|
+
type claudeResult struct {
|
|
126
|
+
Type string `json:"type"`
|
|
127
|
+
Subtype string `json:"subtype"`
|
|
128
|
+
IsError bool `json:"is_error"`
|
|
129
|
+
Result string `json:"result"`
|
|
130
|
+
SessionID string `json:"session_id"`
|
|
131
|
+
Model string `json:"model"`
|
|
132
|
+
TotalCostUSD float64 `json:"total_cost_usd"`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Complete 实现 Engine:单次调用 claude CLI。
|
|
136
|
+
func (e *ClaudeEngine) Complete(ctx context.Context, req Request) (Response, error) {
|
|
137
|
+
start := time.Now()
|
|
138
|
+
bin := e.bin()
|
|
139
|
+
if bin == "" {
|
|
140
|
+
return Response{}, fmt.Errorf("claude CLI not found; set MAGIC_AGENT_CLAUDE_BIN")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
|
|
144
|
+
if prompt == "" {
|
|
145
|
+
return Response{}, fmt.Errorf("claude: empty prompt")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 附件(截图):claude 的 -p 文本模式收不了图,唯一的原生通道是
|
|
149
|
+
// --input-format stream-json(图片作为 content block 走 stdin)。该模式下
|
|
150
|
+
// --output-format 也必须是 stream-json,所以这里跑一遍流式协议、把事件归约成
|
|
151
|
+
// 单次结果返回(Complete 的语义就是「一次性拿最终答案」,不给调用方发事件)。
|
|
152
|
+
if hasImageAttachment(req.Attachments) {
|
|
153
|
+
return e.completeWithAttachments(ctx, req, prompt, start)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
args := e.buildArgs(req, prompt)
|
|
157
|
+
|
|
158
|
+
// workspace:claude 没有工作目录 flag,子进程 cwd 就是原生方式(见 Request.Workspace)
|
|
159
|
+
stdout, stderr, err := runCLIIn(ctx, req.Workspace, bin, args...)
|
|
160
|
+
if err != nil {
|
|
161
|
+
return Response{}, wrapCliError("claude", stdout, stderr, err)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
raw := strings.TrimSpace(stdout)
|
|
165
|
+
if raw == "" {
|
|
166
|
+
return Response{}, fmt.Errorf("claude CLI returned empty output")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
var env claudeResult
|
|
170
|
+
if err := json.Unmarshal([]byte(raw), &env); err != nil {
|
|
171
|
+
// 个别版本对 --print + json 仍吐纯文本:原样返回。
|
|
172
|
+
return Response{
|
|
173
|
+
Text: raw,
|
|
174
|
+
Model: req.Model,
|
|
175
|
+
Latency: time.Since(start),
|
|
176
|
+
}, nil
|
|
177
|
+
}
|
|
178
|
+
if env.IsError {
|
|
179
|
+
return Response{}, fmt.Errorf("claude CLI error (subtype=%s): %s", env.Subtype, truncateStr(env.Result, 500))
|
|
180
|
+
}
|
|
181
|
+
if env.Result == "" {
|
|
182
|
+
return Response{}, fmt.Errorf("claude CLI returned empty result")
|
|
183
|
+
}
|
|
184
|
+
return Response{
|
|
185
|
+
Text: env.Result,
|
|
186
|
+
Model: env.Model,
|
|
187
|
+
SessionID: env.SessionID,
|
|
188
|
+
Latency: time.Since(start),
|
|
189
|
+
}, nil
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// completeWithAttachments 走 stream-json 输入通道跑一次带附件的调用,
|
|
193
|
+
// 把事件归约成单次 Response(不向调用方发增量事件)。
|
|
194
|
+
func (e *ClaudeEngine) completeWithAttachments(ctx context.Context, req Request, prompt string, start time.Time) (Response, error) {
|
|
195
|
+
bin := e.bin()
|
|
196
|
+
args := streamJSONArgs(e.buildArgsBase(req), prompt, false)
|
|
197
|
+
stdin, err := streamJSONUserLine(prompt, req.Attachments)
|
|
198
|
+
if err != nil {
|
|
199
|
+
return Response{}, err
|
|
200
|
+
}
|
|
201
|
+
acc := &streamAccumulator{Engine: e.Name(), OnSessionID: req.OnSessionID}
|
|
202
|
+
var fin streamJSONResult
|
|
203
|
+
seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, strings.NewReader(stdin), acc, &fin)
|
|
204
|
+
if err != nil {
|
|
205
|
+
return Response{}, wrapCliError("claude", "", "", err)
|
|
206
|
+
}
|
|
207
|
+
if !seen {
|
|
208
|
+
return Response{}, fmt.Errorf("claude CLI stream ended without result line")
|
|
209
|
+
}
|
|
210
|
+
if fin.IsError {
|
|
211
|
+
return Response{}, fmt.Errorf("claude CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
|
|
212
|
+
}
|
|
213
|
+
text := finalizeStreamText(fin, acc)
|
|
214
|
+
if text == "" {
|
|
215
|
+
return Response{}, errEmptyStreamContent("claude")
|
|
216
|
+
}
|
|
217
|
+
return Response{
|
|
218
|
+
Text: text,
|
|
219
|
+
Model: fin.Model,
|
|
220
|
+
SessionID: fin.SessionID,
|
|
221
|
+
Latency: time.Since(start),
|
|
222
|
+
}, nil
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// buildArgs 构造 claude CLI 参数(Complete 与 Stream 共用)。
|
|
226
|
+
// outputFormat:"json"(单结果 envelope)或 "stream-json"(流式 NDJSON)。
|
|
227
|
+
func (e *ClaudeEngine) buildArgs(req Request, prompt string) []string {
|
|
228
|
+
return append(e.buildArgsBase(req), prompt)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// buildArgsBase 构造参数(不含末尾的位置参数 prompt)——给附件场景复用:
|
|
232
|
+
// 那条路提示词走 stdin,不能再作为命令行参数传(streamJSONArgs 负责剔除)。
|
|
233
|
+
func (e *ClaudeEngine) buildArgsBase(req Request) []string {
|
|
234
|
+
// 工具模式 × 权限档位(四档模型见 permission.go):
|
|
235
|
+
// off --tools ""(无工具调用,权限档位无意义,不传)
|
|
236
|
+
// on 不传 --tools(引擎默认全工具)+ --permission-mode <档位>
|
|
237
|
+
// allowlist --tools <names> + --permission-mode <档位>
|
|
238
|
+
// 改造前这里在 on/白名单下恒传 --dangerously-skip-permissions(= 第 4 档),
|
|
239
|
+
// 现在由 Request.Permission 决定;默认档仍是 full,故既有调用方行为不变。
|
|
240
|
+
tools := toolsOrDefault(req.Tools)
|
|
241
|
+
args := []string{"-p", "--output-format", "json"}
|
|
242
|
+
switch {
|
|
243
|
+
case req.SessionID != "":
|
|
244
|
+
// 续接指定会话:首轮必须落盘才有 id 可续,因此默认新会话不传
|
|
245
|
+
// --no-session-persistence(该 flag 的会话 "cannot be resumed")。
|
|
246
|
+
args = append(args, "--resume", req.SessionID)
|
|
247
|
+
case req.Continue:
|
|
248
|
+
// 续接最近一次会话(无需 id)。
|
|
249
|
+
args = append(args, "--continue")
|
|
250
|
+
}
|
|
251
|
+
switch {
|
|
252
|
+
case tools.IsOff():
|
|
253
|
+
args = append(args, "--tools", "")
|
|
254
|
+
case tools.IsOn():
|
|
255
|
+
args = append(args, "--permission-mode", claudePermissionMode(req.Permission))
|
|
256
|
+
default: // allowlist
|
|
257
|
+
args = append(args, "--tools", strings.Join(tools.Allowlist(), ","),
|
|
258
|
+
"--permission-mode", claudePermissionMode(req.Permission))
|
|
259
|
+
}
|
|
260
|
+
if m := stripModelPrefix(req.Model); m != "" {
|
|
261
|
+
args = append(args, "--model", m)
|
|
262
|
+
} else {
|
|
263
|
+
args = append(args, "--model", DefaultClaudeModel)
|
|
264
|
+
}
|
|
265
|
+
// 统一参数矩阵:`--settings` 只接受**一份**载荷(文件路径或内联 JSON),
|
|
266
|
+
// 所以 MaxTokens 的 env 注入、四档模型的 sandbox / autoMode / permissions
|
|
267
|
+
// 必须合并进同一个 JSON —— 见 permission.go 的 agentSettingsPayload。
|
|
268
|
+
// 沙箱没有 CLI flag(claude 的 flags 表里没有 --sandbox),只能从这里进。
|
|
269
|
+
if payload, ok := agentSettingsPayload(req, "claude"); ok {
|
|
270
|
+
args = append(args, "--settings", payload)
|
|
271
|
+
}
|
|
272
|
+
if req.SystemPrompt != "" {
|
|
273
|
+
args = append(args, "--append-system-prompt", req.SystemPrompt)
|
|
274
|
+
}
|
|
275
|
+
return args
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Stream 实现 Streamer:流式调用 claude CLI(stream-json NDJSON)。
|
|
279
|
+
func (e *ClaudeEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
280
|
+
start := time.Now()
|
|
281
|
+
bin := e.bin()
|
|
282
|
+
if bin == "" {
|
|
283
|
+
return StreamResult{}, fmt.Errorf("claude CLI not found; set MAGIC_AGENT_CLAUDE_BIN")
|
|
284
|
+
}
|
|
285
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
|
|
286
|
+
if prompt == "" {
|
|
287
|
+
return StreamResult{}, fmt.Errorf("claude: empty prompt")
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 附件(截图)→ stream-json 输入通道:提示词不再当位置参数,改走 stdin。
|
|
291
|
+
// 常驻会话(Request.Append 非 nil)走同一条通道 —— 区别只在 stdin 是个**流**:
|
|
292
|
+
// 追加消息源源不断写进去,通道关闭才 EOF(子进程随之收尾)。
|
|
293
|
+
args := e.buildArgs(req, prompt)
|
|
294
|
+
if req.Append != nil || hasImageAttachment(req.Attachments) {
|
|
295
|
+
var stdin io.Reader
|
|
296
|
+
if req.Append != nil {
|
|
297
|
+
pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
|
|
298
|
+
if perr != nil {
|
|
299
|
+
return StreamResult{}, perr
|
|
300
|
+
}
|
|
301
|
+
stdin = pipe
|
|
302
|
+
} else {
|
|
303
|
+
line, serr := streamJSONUserLine(prompt, req.Attachments)
|
|
304
|
+
if serr != nil {
|
|
305
|
+
return StreamResult{}, serr
|
|
306
|
+
}
|
|
307
|
+
stdin = strings.NewReader(line)
|
|
308
|
+
}
|
|
309
|
+
args = streamJSONArgs(e.buildArgsBase(req), prompt, true)
|
|
310
|
+
acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent, OnSessionID: req.OnSessionID}
|
|
311
|
+
var fin streamJSONResult
|
|
312
|
+
seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, stdin, acc, &fin)
|
|
313
|
+
if err != nil {
|
|
314
|
+
return StreamResult{}, err
|
|
315
|
+
}
|
|
316
|
+
if !seen {
|
|
317
|
+
return StreamResult{}, fmt.Errorf("claude CLI stream ended without result line")
|
|
318
|
+
}
|
|
319
|
+
if fin.IsError {
|
|
320
|
+
return StreamResult{}, fmt.Errorf("claude CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
|
|
321
|
+
}
|
|
322
|
+
text := finalizeStreamText(fin, acc)
|
|
323
|
+
if text == "" {
|
|
324
|
+
return StreamResult{}, errEmptyStreamContent("claude")
|
|
325
|
+
}
|
|
326
|
+
return StreamResult{
|
|
327
|
+
Response: Response{
|
|
328
|
+
Engine: e.Name(),
|
|
329
|
+
Text: text,
|
|
330
|
+
Model: fin.Model,
|
|
331
|
+
SessionID: fin.SessionID,
|
|
332
|
+
Latency: time.Since(start),
|
|
333
|
+
},
|
|
334
|
+
Thinking: acc.Thinking.String(),
|
|
335
|
+
Tools: acc.Tools,
|
|
336
|
+
}, nil
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// json → stream-json:替换 --output-format 值并追加流式 flags。
|
|
340
|
+
for i := range args {
|
|
341
|
+
if args[i] == "--output-format" {
|
|
342
|
+
args[i+1] = "stream-json"
|
|
343
|
+
break
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
args = append(args, "--include-partial-messages", "--verbose")
|
|
347
|
+
|
|
348
|
+
acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent}
|
|
349
|
+
var fin struct {
|
|
350
|
+
Type string `json:"type"`
|
|
351
|
+
Subtype string `json:"subtype"`
|
|
352
|
+
IsError bool `json:"is_error"`
|
|
353
|
+
Result string `json:"result"`
|
|
354
|
+
SessionID string `json:"session_id"`
|
|
355
|
+
Model string `json:"model"`
|
|
356
|
+
}
|
|
357
|
+
seenResult := false
|
|
358
|
+
|
|
359
|
+
err := runStreamCLIIn(ctx, req.Workspace, bin, args, func(line string) error {
|
|
360
|
+
isResult, perr := acc.handleNDJSONLine(line)
|
|
361
|
+
if perr != nil {
|
|
362
|
+
return perr
|
|
363
|
+
}
|
|
364
|
+
if isResult {
|
|
365
|
+
_ = json.Unmarshal([]byte(line), &fin)
|
|
366
|
+
seenResult = true
|
|
367
|
+
}
|
|
368
|
+
return nil
|
|
369
|
+
})
|
|
370
|
+
if err != nil {
|
|
371
|
+
return StreamResult{}, err
|
|
372
|
+
}
|
|
373
|
+
if !seenResult {
|
|
374
|
+
return StreamResult{}, fmt.Errorf("claude CLI stream ended without result line")
|
|
375
|
+
}
|
|
376
|
+
if fin.IsError {
|
|
377
|
+
return StreamResult{}, fmt.Errorf("claude CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
text := fin.Result
|
|
381
|
+
if text == "" {
|
|
382
|
+
text = strings.TrimSpace(acc.Text.String())
|
|
383
|
+
}
|
|
384
|
+
if text == "" {
|
|
385
|
+
return StreamResult{}, fmt.Errorf("claude CLI returned empty result")
|
|
386
|
+
}
|
|
387
|
+
return StreamResult{
|
|
388
|
+
Response: Response{
|
|
389
|
+
Engine: e.Name(),
|
|
390
|
+
Text: text,
|
|
391
|
+
Model: fin.Model,
|
|
392
|
+
SessionID: fin.SessionID,
|
|
393
|
+
Latency: time.Since(start),
|
|
394
|
+
},
|
|
395
|
+
Thinking: acc.Thinking.String(),
|
|
396
|
+
Tools: acc.Tools,
|
|
397
|
+
}, nil
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// truncateStr 截断到最多 n 字节。
|
|
401
|
+
func truncateStr(s string, n int) string {
|
|
402
|
+
if len(s) <= n {
|
|
403
|
+
return s
|
|
404
|
+
}
|
|
405
|
+
return s[:n] + "..."
|
|
406
|
+
}
|