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,470 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// llmengine.go - llm 引擎:包装 simonw/LLM CLI(https://github.com/simonw/LLM)。
|
|
4
|
+
//
|
|
5
|
+
// 定位:用 llm 这一个 CLI 屏蔽背后全部模型差异(OpenAI/Anthropic/MiniMax/
|
|
6
|
+
// ollama/开源端点…),magic-agent 的 llm 引擎只做「转调 + 思维链剥离」,
|
|
7
|
+
// 模型注册、密钥、端点全部由 llm 自己管:
|
|
8
|
+
//
|
|
9
|
+
// llm models 列出可用模型(用户自己注册)
|
|
10
|
+
// llm keys set <name> 存密钥
|
|
11
|
+
// ~/.llm-venv/bin/llm ... 本机 llm 安装位(Homebrew Python 3.14
|
|
12
|
+
// pip truststore 坏,用 python3.12 venv 装)
|
|
13
|
+
//
|
|
14
|
+
// 用法:
|
|
15
|
+
//
|
|
16
|
+
// magic-agent -e llm "问题" # 配置文件的**首条**模型(无配置文件时 = llm 的默认)
|
|
17
|
+
// magic-agent -e llm -m minimax-m3 "问题" # 显式模型
|
|
18
|
+
//
|
|
19
|
+
// 两条执行路径(2026-09-17 起):
|
|
20
|
+
//
|
|
21
|
+
// ① **配置文件模型**(`~/.magic-agent/models.json`,见 llmmodels.go / llmhttp.go):
|
|
22
|
+
// `-m <id>` 命中条目 → 引擎**直连**条目里的 url,model 字段当真实模型名、
|
|
23
|
+
// extraBody 原样 merge 进请求体。id 是唯一标识(真实模型名会重复:MiniMax-M3 与
|
|
24
|
+
// minimax-nothink 同名,只差 thinking.disabled),所以参数必须传 id。
|
|
25
|
+
// 未给 -m 时用**首条**(首条即默认);显式要求 --session/-c 会明确报错(直连无会话库)。
|
|
26
|
+
// ② **llm CLI 委托**:没命中配置文件(未配置 / id 不在表里)→ 原样委托 simonw/LLM CLI,
|
|
27
|
+
// 行为与以前一致(`llm prompt -m <model>`,支持 --cid 会话续接)。
|
|
28
|
+
//
|
|
29
|
+
// CLI 调用形式(两种模式共用 buildArgs):
|
|
30
|
+
//
|
|
31
|
+
// Complete: llm prompt --no-stream --json [--cid ID | -c] [-m MODEL] \
|
|
32
|
+
// [-s SYSTEM] [-o max_tokens N] [-o temperature T] PROMPT
|
|
33
|
+
// Stream: llm prompt [--cid ID | -c] [-m MODEL] \
|
|
34
|
+
// [-s SYSTEM] [-o max_tokens N] [-o temperature T] PROMPT
|
|
35
|
+
//
|
|
36
|
+
// 故意**不传** -n/--no-log:llm 会把每轮对话写入本地 SQLite 日志库
|
|
37
|
+
//(与 claude/trae 的 transcript 落盘语义一致),会话闭环(--cid)正
|
|
38
|
+
// 依赖这份持久化 —— 传 -n 则 conversation_id 虽出现在 envelope 里、
|
|
39
|
+
// 下一轮却查无此会话(实测 2026-09-16)。
|
|
40
|
+
//
|
|
41
|
+
// Complete 额外带 --json:同时拿正文、token 计数与会话 id
|
|
42
|
+
//(envelope 各项内的 conversation_id,见下方 parseLLMJSONEntry),
|
|
43
|
+
// 回填 Response.SessionID 后下一轮可经 --cid 续接。
|
|
44
|
+
//
|
|
45
|
+
// 流式输出是**纯文本 stdout**(非 NDJSON)。MiniMax 等推理模型会把思维链
|
|
46
|
+
// 以标签形式直接混在正文里;本引擎用 thinkSplitter 状态机把标签块路由到
|
|
47
|
+
// thinking 通道。实测 llm 的 -R/--hide-reasoning 挡不住 MiniMax 的标签,
|
|
48
|
+
// 所以剥离逻辑必须自己做。
|
|
49
|
+
//
|
|
50
|
+
// 标签常量沿用 openai_client.go 的分段拼接手法:连续字面量会被写盘管线
|
|
51
|
+
// 当特殊 token 改写,导致源码与运行时值不一致。
|
|
52
|
+
|
|
53
|
+
import (
|
|
54
|
+
"context"
|
|
55
|
+
"encoding/json"
|
|
56
|
+
"fmt"
|
|
57
|
+
"os"
|
|
58
|
+
"strconv"
|
|
59
|
+
"strings"
|
|
60
|
+
"time"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// LLMEngine 包装 simonw/LLM CLI 实现 Engine + Streamer。
|
|
64
|
+
type LLMEngine struct {
|
|
65
|
+
// BinPath 显式指定 CLI 路径(空 = 自动探测)。测试注入用。
|
|
66
|
+
BinPath string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Name 实现 Engine。
|
|
70
|
+
func (e *LLMEngine) Name() string { return "llm" }
|
|
71
|
+
|
|
72
|
+
// DefaultLLMTimeout llm 引擎单次尝试默认超时。
|
|
73
|
+
const DefaultLLMTimeout = 10 * time.Minute
|
|
74
|
+
|
|
75
|
+
// DefaultLLMModel 默认模型:空串 = 配置文件里的首条(有配置文件时,见 llmmodels.go),
|
|
76
|
+
// 否则交给 llm CLI 自己的默认模型。
|
|
77
|
+
const DefaultLLMModel = ""
|
|
78
|
+
|
|
79
|
+
// bin 探测 llm CLI(委托 cliBase 统一探测链;llmBase 的候选路径保持
|
|
80
|
+
// venv 位 ~/.llm-venv 优先的次序 —— Homebrew Python 3.14 的 pip
|
|
81
|
+
// truststore 有 bug,用户专门建的 venv 是能用的那份)。
|
|
82
|
+
func (e *LLMEngine) bin() string {
|
|
83
|
+
return llmBase.resolve(e.BinPath)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Detect 实现 Engine:找到 CLI 即可用(模型/密钥由 llm 自管,不在
|
|
87
|
+
// magic-agent 侧校验;模型错了运行时 llm 会报错)。
|
|
88
|
+
// 显式指定的路径(BinPath / MAGIC_AGENT_LLM_BIN)必须真实存在,
|
|
89
|
+
// 自动探测链中的候选路径本身就经过 os.Stat 过滤。
|
|
90
|
+
func (e *LLMEngine) Detect() (bool, string) {
|
|
91
|
+
p := llmBase.resolve(e.BinPath)
|
|
92
|
+
if p == "" {
|
|
93
|
+
return false, llmBase.notFound
|
|
94
|
+
}
|
|
95
|
+
if e.BinPath != "" || os.Getenv(llmBase.envVar) != "" {
|
|
96
|
+
if _, err := os.Stat(p); err != nil {
|
|
97
|
+
return false, fmt.Sprintf("llm CLI not found at %s", p)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return true, p
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// buildArgs 构造 llm prompt 参数(Complete 与 Stream 共用)。
|
|
104
|
+
// Complete 模式额外追加 --no-stream。
|
|
105
|
+
//
|
|
106
|
+
// 模型选项经 simonw llm 的 `-o key value` 透传:
|
|
107
|
+
//
|
|
108
|
+
// --max-tokens N → -o max_tokens N
|
|
109
|
+
// --temperature T → -o temperature T
|
|
110
|
+
//
|
|
111
|
+
// 具体选项名由模型插件决定;未注册该选项时 llm 会报错,属于调用方
|
|
112
|
+
// 传错参数,不在这里静默吞掉。
|
|
113
|
+
func (e *LLMEngine) buildArgs(req Request, prompt string) []string {
|
|
114
|
+
// 不传 -n/--no-log:会话闭环依赖 llm 本地日志库持久化(见文件头注释)。
|
|
115
|
+
args := []string{"prompt"}
|
|
116
|
+
switch {
|
|
117
|
+
case req.SessionID != "":
|
|
118
|
+
// 续接指定会话:--cid <id>(llm 的 conversation id,通常取自
|
|
119
|
+
// 上一轮 --json envelope 的 conversation_id,经 Response.SessionID 透传)。
|
|
120
|
+
args = append(args, "--cid", req.SessionID)
|
|
121
|
+
case req.Continue:
|
|
122
|
+
// 续接最近一次会话:-c/--continue(无需 id)。
|
|
123
|
+
args = append(args, "-c")
|
|
124
|
+
}
|
|
125
|
+
if m := stripModelPrefix(req.Model); m != "" {
|
|
126
|
+
args = append(args, "-m", m)
|
|
127
|
+
}
|
|
128
|
+
if req.SystemPrompt != "" {
|
|
129
|
+
args = append(args, "-s", req.SystemPrompt)
|
|
130
|
+
}
|
|
131
|
+
// 附件(截图):llm CLI 原生 `-a/--attachment <path>`(多模态模型靠它收图,
|
|
132
|
+
// `llm prompt --help` 明确写了 `llm 'Extract text from this image' -a image.jpg`)。
|
|
133
|
+
// 非图片附件没有通用类型 → 路径写进提示词正文。
|
|
134
|
+
var nonImage []Attachment
|
|
135
|
+
for _, a := range req.Attachments {
|
|
136
|
+
if a.IsImage() {
|
|
137
|
+
args = append(args, "-a", a.Path)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
nonImage = append(nonImage, a)
|
|
141
|
+
}
|
|
142
|
+
if req.MaxTokens > 0 {
|
|
143
|
+
args = append(args, "-o", "max_tokens", strconv.Itoa(req.MaxTokens))
|
|
144
|
+
}
|
|
145
|
+
if req.Temperature != nil {
|
|
146
|
+
args = append(args, "-o", "temperature", formatTemperature(*req.Temperature))
|
|
147
|
+
}
|
|
148
|
+
return append(args, appendAttachmentSection(prompt, nonImage))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// formatTemperature 把温度格式化为最短十进制表示(去掉多余的尾零)。
|
|
152
|
+
func formatTemperature(t float64) string {
|
|
153
|
+
return strconv.FormatFloat(t, 'f', -1, 64)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// encodeJSONSchemaInline 把 JSONSchema 序列化成给 simonw llm 的
|
|
157
|
+
// `--schema` 接受的 JSON Schema DSL。
|
|
158
|
+
//
|
|
159
|
+
// simonw llm 的 --schema 既支持 DSL(如 "name str, age int, bio: 简介")
|
|
160
|
+
// 也支持完整 JSON Schema。我们直接传 JSON Schema 字符串,让 llm 解析。
|
|
161
|
+
func encodeJSONSchemaInline(s *JSONSchema) (string, bool) {
|
|
162
|
+
if s == nil || !strings.EqualFold(s.Type, "object") {
|
|
163
|
+
return "", false
|
|
164
|
+
}
|
|
165
|
+
// 顶层仅保留 type / properties / required 三键,剔除冗余字段。
|
|
166
|
+
doc := map[string]any{
|
|
167
|
+
"type": "object",
|
|
168
|
+
"properties": s.Properties,
|
|
169
|
+
}
|
|
170
|
+
if len(s.Required) > 0 {
|
|
171
|
+
doc["required"] = s.Required
|
|
172
|
+
}
|
|
173
|
+
out, err := json.Marshal(doc)
|
|
174
|
+
if err != nil {
|
|
175
|
+
return "", false
|
|
176
|
+
}
|
|
177
|
+
return string(out), true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ListModels 实现 ModelLister:**配置文件优先** ——
|
|
181
|
+
// `~/.magic-agent/models.json` 是 llm 引擎的模型注册表,它的 id 是 `-m` 可直接执行的
|
|
182
|
+
// (首条即默认,见 llmmodels.go);没有配置文件才退回 `llm models`(llm CLI 自己
|
|
183
|
+
// 注册的模型,含插件自带的整套目录,形如 "OpenAI Chat: gpt-4o (aliases: 4o)")。
|
|
184
|
+
func (e *LLMEngine) ListModels(ctx context.Context) ([]string, error) {
|
|
185
|
+
if list, err := LoadLLMConfigModels(); err == nil && len(list) > 0 {
|
|
186
|
+
ids := make([]string, 0, len(list))
|
|
187
|
+
for _, m := range list {
|
|
188
|
+
ids = append(ids, m.ID)
|
|
189
|
+
}
|
|
190
|
+
return ids, nil
|
|
191
|
+
}
|
|
192
|
+
stdout, err := probeCLI(ctx, "llm", e.bin(), "models")
|
|
193
|
+
if err != nil {
|
|
194
|
+
return nil, err
|
|
195
|
+
}
|
|
196
|
+
models := parseLLMModelsText(stdout)
|
|
197
|
+
if len(models) == 0 {
|
|
198
|
+
return nil, fmt.Errorf("%w: llm models returned no entries", ErrNoModelSource)
|
|
199
|
+
}
|
|
200
|
+
return models, nil
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Complete 实现 Engine:llm prompt --json --no-stream,一次性拿全文 + token 计数。
|
|
204
|
+
//
|
|
205
|
+
// 为什么加 --json:纯文本模式下 llm 不吐 token 数,上游(如 magic-video)
|
|
206
|
+
// 靠 output_tokens 判断「输出撞了 max_tokens 上限、正文可能被截断」。
|
|
207
|
+
// --json 输出同 `llm logs --json` 的结构(数组,每项含 response /
|
|
208
|
+
// input_tokens / output_tokens),解析失败时退回纯文本路径,不引入新的失败点。
|
|
209
|
+
func (e *LLMEngine) Complete(ctx context.Context, req Request) (Response, error) {
|
|
210
|
+
// 配置文件模型(models.json):命中 id → 直连端点(见 llmhttp.go)。
|
|
211
|
+
// 为什么必须按 id:id 唯一,而真实模型名会重复(MiniMax-M3 与 minimax-nothink
|
|
212
|
+
// 真实名相同,只差 extraBody)—— 用名字做参数就无法区分。
|
|
213
|
+
if m, ok := ResolveLLMConfigModel(req.Model); ok {
|
|
214
|
+
return e.completeConfigured(ctx, m, req)
|
|
215
|
+
}
|
|
216
|
+
start := time.Now()
|
|
217
|
+
bin := e.bin()
|
|
218
|
+
if bin == "" {
|
|
219
|
+
return Response{}, fmt.Errorf("llm CLI not found; set MAGIC_AGENT_LLM_BIN")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
prompt := FlattenPrompt("", req.Messages, false)
|
|
223
|
+
if prompt == "" {
|
|
224
|
+
return Response{}, fmt.Errorf("llm: empty prompt")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
args := append(e.buildArgs(req, prompt), "--no-stream", "--json")
|
|
228
|
+
// 注意:simonw llm CLI 不为 OpenAI Chat 类模型提供原生 schema 约束
|
|
229
|
+
//(实测 minimax-m3 报 "does not support schemas")。所以 magic-agent
|
|
230
|
+
// **不在 CLI 层透传** schema —— 上游(magic-video)已经渲染进
|
|
231
|
+
// system prompt 作为提示词约束,llm 引擎这里只做 response 后处理
|
|
232
|
+
//(见下方 extractJSONObjectStrict)。其他引擎(codebuddy)有自己的
|
|
233
|
+
// --json-schema flag,由 magic-agent 各自的 Complete 实现处理。
|
|
234
|
+
_ = req.JSONSchema
|
|
235
|
+
stdout, stderr, err := runCLI(ctx, bin, args...)
|
|
236
|
+
if err != nil {
|
|
237
|
+
return Response{}, wrapCliError("llm", stdout, stderr, err)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
raw := strings.TrimSpace(stdout)
|
|
241
|
+
if raw == "" {
|
|
242
|
+
return Response{}, fmt.Errorf("llm CLI returned empty output")
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if entry, ok := parseLLMJSONEntry(raw); ok {
|
|
246
|
+
text, _ := splitThinkingTags(entry.Response)
|
|
247
|
+
if strings.TrimSpace(text) == "" {
|
|
248
|
+
// 全是思维链、无正文:原样返回(可能模型把答案写在思维链里)。
|
|
249
|
+
text = entry.Response
|
|
250
|
+
}
|
|
251
|
+
// schema 严格匹配后处理:把 model 字符串化的 JSON 对象压平为
|
|
252
|
+
// 紧凑串,便于上游 json.Unmarshal 一次成功。
|
|
253
|
+
if req.JSONSchema != nil {
|
|
254
|
+
if extracted, ok := extractJSONObjectStrict(text, req.JSONSchema); ok {
|
|
255
|
+
text = extracted
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
model := entry.Model
|
|
259
|
+
if model == "" {
|
|
260
|
+
model = stripModelPrefix(req.Model)
|
|
261
|
+
}
|
|
262
|
+
total := entry.InputTokens + entry.OutputTokens
|
|
263
|
+
return Response{
|
|
264
|
+
Engine: e.Name(),
|
|
265
|
+
Text: strings.TrimSpace(text),
|
|
266
|
+
Model: model,
|
|
267
|
+
// llm --json envelope 顶层带 conversation_id(实测确认):
|
|
268
|
+
// 回填后下一轮 Request.SessionID → --cid 即可续接该会话。
|
|
269
|
+
SessionID: entry.ConversationID,
|
|
270
|
+
Latency: time.Since(start),
|
|
271
|
+
InputTokens: entry.InputTokens,
|
|
272
|
+
OutputTokens: entry.OutputTokens,
|
|
273
|
+
TotalTokens: total,
|
|
274
|
+
}, nil
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 兜底:--json 解析不出来(llm 版本差异 / 输出被污染)时按纯文本处理。
|
|
278
|
+
text, _ := splitThinkingTags(raw)
|
|
279
|
+
if strings.TrimSpace(text) == "" {
|
|
280
|
+
text = raw
|
|
281
|
+
}
|
|
282
|
+
return Response{
|
|
283
|
+
Engine: e.Name(),
|
|
284
|
+
Text: strings.TrimSpace(text),
|
|
285
|
+
Model: stripModelPrefix(req.Model),
|
|
286
|
+
Latency: time.Since(start),
|
|
287
|
+
}, nil
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// llmJSONEntry 是 `llm prompt --json` 数组里一项的所需子集。
|
|
291
|
+
type llmJSONEntry struct {
|
|
292
|
+
Model string `json:"model"`
|
|
293
|
+
Response string `json:"response"`
|
|
294
|
+
InputTokens int `json:"input_tokens"`
|
|
295
|
+
OutputTokens int `json:"output_tokens"`
|
|
296
|
+
ConversationID string `json:"conversation_id"`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// parseLLMJSONEntry 解析 `llm prompt --json` 输出,取最后一项有正文的条目。
|
|
300
|
+
//
|
|
301
|
+
// 单次 prompt 通常只有一项;带工具链(tools chain)时会有多项,最终答复
|
|
302
|
+
// 在最后一项,所以从后往前找第一个 response 非空的。
|
|
303
|
+
// 输出不是 JSON 数组时返回 ok=false,调用方走纯文本兜底。
|
|
304
|
+
func parseLLMJSONEntry(raw string) (llmJSONEntry, bool) {
|
|
305
|
+
var entries []llmJSONEntry
|
|
306
|
+
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
|
|
307
|
+
return llmJSONEntry{}, false
|
|
308
|
+
}
|
|
309
|
+
for i := len(entries) - 1; i >= 0; i-- {
|
|
310
|
+
if strings.TrimSpace(entries[i].Response) != "" {
|
|
311
|
+
return entries[i], true
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return llmJSONEntry{}, false
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Stream 实现 Streamer:llm prompt 默认流式,逐行读纯文本 stdout,
|
|
318
|
+
// thinkSplitter 把标签块路由到 thinking 通道。
|
|
319
|
+
func (e *LLMEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
320
|
+
// 配置文件模型:命中 id → 直连 SSE(见 llmhttp.go / completeConfigured 上方注释)
|
|
321
|
+
if m, ok := ResolveLLMConfigModel(req.Model); ok {
|
|
322
|
+
return e.streamConfigured(ctx, m, req, onEvent)
|
|
323
|
+
}
|
|
324
|
+
start := time.Now()
|
|
325
|
+
bin := e.bin()
|
|
326
|
+
if bin == "" {
|
|
327
|
+
return StreamResult{}, fmt.Errorf("llm CLI not found; set MAGIC_AGENT_LLM_BIN")
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
prompt := FlattenPrompt("", req.Messages, false)
|
|
331
|
+
if prompt == "" {
|
|
332
|
+
return StreamResult{}, fmt.Errorf("llm: empty prompt")
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
args := e.buildArgs(req, prompt)
|
|
336
|
+
sp := &thinkSplitter{OnEvent: onEvent}
|
|
337
|
+
|
|
338
|
+
err := runStreamCLI(ctx, bin, args, func(line string) error {
|
|
339
|
+
sp.feed(line + "\n")
|
|
340
|
+
return nil
|
|
341
|
+
})
|
|
342
|
+
if err != nil {
|
|
343
|
+
return StreamResult{}, err
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
text, thinking := sp.finish()
|
|
347
|
+
if text == "" {
|
|
348
|
+
return StreamResult{}, fmt.Errorf("llm CLI returned empty result")
|
|
349
|
+
}
|
|
350
|
+
return StreamResult{
|
|
351
|
+
Response: Response{
|
|
352
|
+
Engine: e.Name(),
|
|
353
|
+
Text: text,
|
|
354
|
+
Model: stripModelPrefix(req.Model),
|
|
355
|
+
Latency: time.Since(start),
|
|
356
|
+
},
|
|
357
|
+
Thinking: thinking,
|
|
358
|
+
}, nil
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// splitThinkingTags 把带标签的混合文本拆为 (正文, 思维链)。
|
|
362
|
+
// 供 Complete 模式使用;Stream 模式用 thinkSplitter 状态机增量处理。
|
|
363
|
+
func splitThinkingTags(s string) (text, thinking string) {
|
|
364
|
+
var tb, kb strings.Builder
|
|
365
|
+
rest := s
|
|
366
|
+
for {
|
|
367
|
+
i := strings.Index(rest, thinkOpenTag)
|
|
368
|
+
if i < 0 {
|
|
369
|
+
tb.WriteString(rest)
|
|
370
|
+
break
|
|
371
|
+
}
|
|
372
|
+
tb.WriteString(rest[:i])
|
|
373
|
+
after := rest[i+len(thinkOpenTag):]
|
|
374
|
+
j := strings.Index(after, thinkCloseTag)
|
|
375
|
+
if j < 0 {
|
|
376
|
+
// 有开无闭:残余整段视为思维链。
|
|
377
|
+
kb.WriteString(after)
|
|
378
|
+
break
|
|
379
|
+
}
|
|
380
|
+
kb.WriteString(after[:j])
|
|
381
|
+
rest = after[j+len(thinkCloseTag):]
|
|
382
|
+
}
|
|
383
|
+
return tb.String(), kb.String()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// thinkSplitter 流式思维链分离状态机。
|
|
387
|
+
//
|
|
388
|
+
// llm 的流式输出是逐行纯文本。关键事实:思维链标签本身不含换行符,
|
|
389
|
+
// 而 Scanner 按行交付完整行,因此标签永远完整落在某一行内、不会被
|
|
390
|
+
// 行边界切开——无需前缀保持(holdback)。行内出现被换行打断的伪标签
|
|
391
|
+
// (如 "<th\nink>")在原始字节流里本来就不是标签,与非流式的
|
|
392
|
+
// splitThinkingTags 判定一致:视为正文。
|
|
393
|
+
//
|
|
394
|
+
// 状态:
|
|
395
|
+
//
|
|
396
|
+
// inThink 当前是否处于思维链块内(跨行保持,直至闭标签或 EOF)
|
|
397
|
+
// sawText 已发出过实质正文(首个实质内容前的纯空白段不转发,
|
|
398
|
+
// 消化标签独立成行时残留的换行)
|
|
399
|
+
// sawThink 同上,针对思维链通道
|
|
400
|
+
type thinkSplitter struct {
|
|
401
|
+
OnEvent func(StreamEvent)
|
|
402
|
+
Text strings.Builder
|
|
403
|
+
Thinking strings.Builder
|
|
404
|
+
inThink bool
|
|
405
|
+
sawText bool
|
|
406
|
+
sawThink bool
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// feed 处理一行流式输出(line 应含行尾换行符,以保留多行结构)。
|
|
410
|
+
func (sp *thinkSplitter) feed(line string) {
|
|
411
|
+
s := line
|
|
412
|
+
for {
|
|
413
|
+
if sp.inThink {
|
|
414
|
+
j := strings.Index(s, thinkCloseTag)
|
|
415
|
+
if j < 0 {
|
|
416
|
+
sp.emitThinking(s)
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
sp.emitThinking(s[:j])
|
|
420
|
+
s = s[j+len(thinkCloseTag):]
|
|
421
|
+
sp.inThink = false
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
i := strings.Index(s, thinkOpenTag)
|
|
425
|
+
if i < 0 {
|
|
426
|
+
sp.emitText(s)
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
sp.emitText(s[:i])
|
|
430
|
+
s = s[i+len(thinkOpenTag):]
|
|
431
|
+
sp.inThink = true
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// finish 收尾。未闭合的思维链块保持到 EOF(inThink 态下的全部内容
|
|
436
|
+
// 已在 feed 中计入 thinking),无额外缓冲需要冲刷。
|
|
437
|
+
func (sp *thinkSplitter) finish() (text, thinking string) {
|
|
438
|
+
return strings.TrimSpace(sp.Text.String()), strings.TrimSpace(sp.Thinking.String())
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// emitText 发出一段正文增量。首个实质内容前的纯空白段静默吞掉
|
|
442
|
+
// (builder 不写、事件不发),避免标签行的换行污染输出。
|
|
443
|
+
func (sp *thinkSplitter) emitText(s string) {
|
|
444
|
+
if s == "" {
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
if !sp.sawText && strings.TrimSpace(s) == "" {
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
sp.sawText = true
|
|
451
|
+
sp.Text.WriteString(s)
|
|
452
|
+
if sp.OnEvent != nil {
|
|
453
|
+
sp.OnEvent(StreamEvent{Kind: KindText, Text: s})
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// emitThinking 发出一段思维链增量(同样的首段空白吞并规则)。
|
|
458
|
+
func (sp *thinkSplitter) emitThinking(s string) {
|
|
459
|
+
if s == "" {
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
if !sp.sawThink && strings.TrimSpace(s) == "" {
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
sp.sawThink = true
|
|
466
|
+
sp.Thinking.WriteString(s)
|
|
467
|
+
if sp.OnEvent != nil {
|
|
468
|
+
sp.OnEvent(StreamEvent{Kind: KindThinking, Text: s})
|
|
469
|
+
}
|
|
470
|
+
}
|