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,599 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// codex.go - Codex CLI 引擎。
|
|
4
|
+
//
|
|
5
|
+
// 后端:codex(@openai/codex,本机 /opt/homebrew/bin/codex)。
|
|
6
|
+
// 非交互模式:`codex exec`,协议面:
|
|
7
|
+
//
|
|
8
|
+
// --json NDJSON 事件流,stdout 逐行 {"type":"...","...":"..."}
|
|
9
|
+
// -o, --output-last-message <FILE>
|
|
10
|
+
// 最终 assistant 正文落盘;也用于 --output-schema 时取
|
|
11
|
+
// schema 化的 JSON(可能被 ```json fences 包裹)
|
|
12
|
+
// --output-schema <FILE>
|
|
13
|
+
// 原生 JSONSchema 约束(首选)
|
|
14
|
+
// --skip-git-repo-check
|
|
15
|
+
// 允许在非 git 目录跑(默认外层 cwd 不一定在 git 仓库内)
|
|
16
|
+
// --ephemeral 不落盘 session(仅当不需要续接时使用 —— 与 SessionID 互斥)
|
|
17
|
+
// -m, --model <m> 模型
|
|
18
|
+
// -c, --config k=v TOML 配置覆写,dotted path
|
|
19
|
+
// -s, --sandbox <MODE>
|
|
20
|
+
// read-only / workspace-write / danger-full-access
|
|
21
|
+
// --dangerously-bypass-approvals-and-sandbox
|
|
22
|
+
// 全放行(off 模式不要用,会与「禁用工具」冲突)
|
|
23
|
+
//
|
|
24
|
+
// 关键事件:
|
|
25
|
+
//
|
|
26
|
+
// {"type":"thread.started","thread_id":"<uuid>"} — 拿到会话 id
|
|
27
|
+
// {"type":"turn.started"}
|
|
28
|
+
// {"type":"item.completed","item":{"type":"agent_message","text":"..."}}
|
|
29
|
+
// {"type":"turn.completed","usage":{
|
|
30
|
+
// "input_tokens":...,"cached_input_tokens":...,
|
|
31
|
+
// "output_tokens":...,"reasoning_output_tokens":...}}
|
|
32
|
+
//
|
|
33
|
+
// 续接:`codex exec resume <thread_id>`(指定 id)或 `codex exec resume --last`
|
|
34
|
+
//(接最近一次)。 Continue → resume --last;SessionID 非空 → resume <id>。
|
|
35
|
+
//
|
|
36
|
+
// SystemPrompt 注入:`-c developer_instructions=<text>`。其值解析为 TOML,
|
|
37
|
+
// 因此双引号、反斜杠、换行须用 json.Marshal 转义(json 的转义集与 TOML basic
|
|
38
|
+
// string 完全兼容,已实测)。
|
|
39
|
+
//
|
|
40
|
+
// MaxTokens / Temperature 静默忽略 —— codex 无 `--max-tokens` / `--temperature`
|
|
41
|
+
// 原生 flag;`-c model_max_output_tokens=...` / `-c temperature=...` 在本版本
|
|
42
|
+
// 0.133.0 未生效(实测 CLI 静默接受但结果未改变),因此按矩阵 ② 直接忽略。
|
|
43
|
+
//
|
|
44
|
+
// CLI 路径解析顺序:显式 BinPath → MAGIC_AGENT_CODEX_BIN → 常见安装位 → PATH
|
|
45
|
+
// (探测链统一收敛在 engine_base.go 的 cliBase)。
|
|
46
|
+
//
|
|
47
|
+
// CODEX_HOME 隔离(codex_home.go,2026-09-16 实测 0.154.0):
|
|
48
|
+
// ~/.codex/auth.json 的 ChatGPT 登录态会让 codex exec 启动时拉取
|
|
49
|
+
// cloud config bundle(auth.openai.com);本机 DNS 解析该域名超时时
|
|
50
|
+
// 直接报 "timed out waiting for cloud config bundle after 15s" 退出。
|
|
51
|
+
// 引擎层注入镜像目录(无 auth.json)根治;外部显式设置的 CODEX_HOME /
|
|
52
|
+
// MAGIC_AGENT_CODEX_HOME 均被尊重。
|
|
53
|
+
//
|
|
54
|
+
// web_search 覆写:config.toml 常为 web_search="disabled"(无联网),
|
|
55
|
+
// 需要联网类查询时设 MAGIC_AGENT_CODEX_WEBSEARCH=live|cached|indexed
|
|
56
|
+
// 注入 -c web_search=<v>(仅非 resume 调用;值域 0.154 实测为
|
|
57
|
+
// disabled/cached/indexed/live)。
|
|
58
|
+
//
|
|
59
|
+
// 不实现 Stream:codex 的 `--output-last-message` 仅一次性产出最终
|
|
60
|
+
// message,且 `--json` 协议未稳定暴露正文 token 增量;流式能力按矩阵⑥在
|
|
61
|
+
// 后续版本补齐。CLI 层面对 nil Streamer 有明确报错路径(see cli/ask.go)。
|
|
62
|
+
|
|
63
|
+
import (
|
|
64
|
+
"bufio"
|
|
65
|
+
"context"
|
|
66
|
+
"encoding/json"
|
|
67
|
+
"fmt"
|
|
68
|
+
"os"
|
|
69
|
+
"path/filepath"
|
|
70
|
+
"strings"
|
|
71
|
+
"time"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// CodexEngine 通过 Codex CLI 实现 Engine。
|
|
75
|
+
type CodexEngine struct {
|
|
76
|
+
// BinPath 显式指定 CLI 路径(空 = 自动探测)。测试注入用。
|
|
77
|
+
BinPath string
|
|
78
|
+
|
|
79
|
+
// Model 显式模型(与 Request.Model 效果一致;引擎构造时显式覆盖优先)。
|
|
80
|
+
Model string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Name 实现 Engine。
|
|
84
|
+
func (e *CodexEngine) Name() string { return "codex" }
|
|
85
|
+
|
|
86
|
+
// DefaultCodexTimeout 单次尝试默认超时(codex 沙箱+网路,整体偏慢)。
|
|
87
|
+
const DefaultCodexTimeout = 8 * time.Minute
|
|
88
|
+
|
|
89
|
+
// bin 探测 codex CLI 路径(委托 cliBase 统一探测链)。
|
|
90
|
+
func (e *CodexEngine) bin() string {
|
|
91
|
+
return codexBase.resolve(e.BinPath)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Detect 实现 Engine。
|
|
95
|
+
func (e *CodexEngine) Detect() (bool, string) {
|
|
96
|
+
p := codexBase.resolve(e.BinPath)
|
|
97
|
+
if p == "" {
|
|
98
|
+
return false, codexBase.notFound
|
|
99
|
+
}
|
|
100
|
+
return true, p
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// codexTurnUsage 对应 turn.completed.usage(实测结构)。
|
|
104
|
+
type codexTurnUsage struct {
|
|
105
|
+
InputTokens int `json:"input_tokens"`
|
|
106
|
+
CachedInputTokens int `json:"cached_input_tokens"`
|
|
107
|
+
OutputTokens int `json:"output_tokens"`
|
|
108
|
+
ReasoningOutputTokens int `json:"reasoning_output_tokens"`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// codexThreadStarted 对应 thread.started 事件。
|
|
112
|
+
type codexThreadStarted struct {
|
|
113
|
+
Type string `json:"type"`
|
|
114
|
+
ThreadID string `json:"thread_id"`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// codexItemCompleted 对应 item.completed 事件,agent_message 即最终正文。
|
|
118
|
+
type codexItemCompleted struct {
|
|
119
|
+
Type string `json:"type"`
|
|
120
|
+
Item map[string]any `json:"item"`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// codexTurnCompleted 对应 turn.completed 事件(带 usage)。
|
|
124
|
+
type codexTurnCompleted struct {
|
|
125
|
+
Type string `json:"type"`
|
|
126
|
+
Usage codexTurnUsage `json:"usage"`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ListModels 实现 ModelLister:`codex debug models` 输出 CLI 当前生效的
|
|
130
|
+
// raw model catalog(JSON),取 slug(= -m 接受的标识)。
|
|
131
|
+
func (e *CodexEngine) ListModels(ctx context.Context) ([]string, error) {
|
|
132
|
+
stdout, err := probeCLI(ctx, "codex", e.bin(), "debug", "models")
|
|
133
|
+
if err != nil {
|
|
134
|
+
return nil, err
|
|
135
|
+
}
|
|
136
|
+
objs, err := jsonModelsArray(stdout)
|
|
137
|
+
if err != nil {
|
|
138
|
+
return nil, err
|
|
139
|
+
}
|
|
140
|
+
models := modelNamesFromObjects(objs, "slug", "display_name")
|
|
141
|
+
if len(models) == 0 {
|
|
142
|
+
return nil, fmt.Errorf("%w: codex debug models returned no entries", ErrNoModelSource)
|
|
143
|
+
}
|
|
144
|
+
return models, nil
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Complete 实现 Engine:单次调用 codex exec。
|
|
148
|
+
//
|
|
149
|
+
// 执行模型:
|
|
150
|
+
// 1. 构造参数(codex exec [resume [id|--last]] ... --json -o <tmpfile> ...)
|
|
151
|
+
// 2. runCLI 拉取 stdout(NDJSON 事件流)+ stderr
|
|
152
|
+
// 3. 逐行解析事件:thread.started.thread_id → SessionID;
|
|
153
|
+
// 最后一个 item.completed.item.type=="agent_message" → Text(兜底用 -o 文件);
|
|
154
|
+
// turn.completed.usage → Tokens
|
|
155
|
+
// 4. --output-last-message 落盘文件作 fallback 兜底(--output-schema 时也用它读 JSON)
|
|
156
|
+
func (e *CodexEngine) Complete(ctx context.Context, req Request) (Response, error) {
|
|
157
|
+
start := time.Now()
|
|
158
|
+
bin := e.bin()
|
|
159
|
+
if bin == "" {
|
|
160
|
+
return Response{}, fmt.Errorf("codex CLI not found; set MAGIC_AGENT_CODEX_BIN")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, toolsIsOff(req))
|
|
164
|
+
if prompt == "" {
|
|
165
|
+
return Response{}, fmt.Errorf("codex: empty prompt")
|
|
166
|
+
}
|
|
167
|
+
if req.SystemPrompt == "" && toolsIsOff(req) {
|
|
168
|
+
prompt += noToolSuffix
|
|
169
|
+
}
|
|
170
|
+
prompt = codexPromptWithAttachments(prompt, req)
|
|
171
|
+
|
|
172
|
+
// 临时文件 -o:codex 把最终正文写到该路径。--output-schema 同样用它取结果。
|
|
173
|
+
tmpDir, err := os.MkdirTemp("", "magic-agent-codex-")
|
|
174
|
+
if err != nil {
|
|
175
|
+
return Response{}, fmt.Errorf("codex: mktemp: %w", err)
|
|
176
|
+
}
|
|
177
|
+
defer os.RemoveAll(tmpDir)
|
|
178
|
+
outFile := filepath.Join(tmpDir, "last.txt")
|
|
179
|
+
// JSONSchema 约束落到独立文件,与 -o 不冲突;最终回包做 fence 剥离。
|
|
180
|
+
var schemaFile string
|
|
181
|
+
if req.JSONSchema != nil {
|
|
182
|
+
schemaFile = filepath.Join(tmpDir, "schema.json")
|
|
183
|
+
if err := writeJSONSchemaFile(schemaFile, req.JSONSchema); err != nil {
|
|
184
|
+
return Response{}, fmt.Errorf("codex: write schema: %w", err)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
args := e.buildArgs(req, prompt, outFile, schemaFile)
|
|
189
|
+
// CODEX_HOME 隔离:镜像目录无 auth.json → codex 不拉 cloud config
|
|
190
|
+
//(auth.openai.com DNS 不可达时的 15s 超时根因,见 codex_home.go)。
|
|
191
|
+
// 同步失败静默回退旧行为(ensureCodexHome 内部已兜底)。
|
|
192
|
+
codexHome, injectHome, _ := ensureCodexHome()
|
|
193
|
+
var extraEnv []string
|
|
194
|
+
if injectHome {
|
|
195
|
+
extraEnv = append(extraEnv, "CODEX_HOME="+codexHome)
|
|
196
|
+
}
|
|
197
|
+
stdout, stderr, runErr := runCLIEnvIn(ctx, req.Workspace, extraEnv, bin, args...)
|
|
198
|
+
// 即使 runErr 非空(如非零退出),仍尝试从落盘文件抢救文本;NDJSON
|
|
199
|
+
// 事件流部分事件已写入 stdout,--output-last-message 通常也已落盘。
|
|
200
|
+
resp := e.parseStdout(stdout)
|
|
201
|
+
if resp.Latency == 0 {
|
|
202
|
+
resp.Latency = time.Since(start)
|
|
203
|
+
}
|
|
204
|
+
resp.Engine = e.Name()
|
|
205
|
+
|
|
206
|
+
// 兜底正文:agent_message 事件可能没拿到(text 为空),从 -o 落盘文件读。
|
|
207
|
+
if strings.TrimSpace(resp.Text) == "" {
|
|
208
|
+
if data, rerr := os.ReadFile(outFile); rerr == nil {
|
|
209
|
+
resp.Text = stripJSONFence(strings.TrimSpace(string(data)))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// JSONSchema 模式:进一步尝试严格抽取顶层 JSON 对象替换 Text。
|
|
213
|
+
if req.JSONSchema != nil {
|
|
214
|
+
if m, ok := extractJSONObjectStrict(resp.Text, req.JSONSchema); ok {
|
|
215
|
+
if b, jerr := json.Marshal(m); jerr == nil {
|
|
216
|
+
resp.Text = string(b)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if runErr != nil {
|
|
221
|
+
return resp, wrapCliError("codex", stdout, stderr, runErr)
|
|
222
|
+
}
|
|
223
|
+
if strings.TrimSpace(resp.Text) == "" {
|
|
224
|
+
return resp, fmt.Errorf("codex CLI returned empty result")
|
|
225
|
+
}
|
|
226
|
+
return resp, nil
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// parseStdout 逐行解析 --json NDJSON,提取 Text / SessionID / Token 计数。
|
|
230
|
+
// 行可能为空(JSON 行间无空行)或非法 JSON(警告类输出),失败跳过。
|
|
231
|
+
func (e *CodexEngine) parseStdout(stdout string) Response {
|
|
232
|
+
var (
|
|
233
|
+
text string
|
|
234
|
+
sessionID string
|
|
235
|
+
usage codexTurnUsage
|
|
236
|
+
gotUsage bool
|
|
237
|
+
gotItem bool
|
|
238
|
+
)
|
|
239
|
+
sc := bufio.NewScanner(strings.NewReader(stdout))
|
|
240
|
+
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) // 16MB 行上限
|
|
241
|
+
for sc.Scan() {
|
|
242
|
+
line := strings.TrimSpace(sc.Text())
|
|
243
|
+
if line == "" {
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
// 尝试多事件类型,逐个 unmarshal,失败说明不是该事件,继续。
|
|
247
|
+
var ts codexThreadStarted
|
|
248
|
+
if json.Unmarshal([]byte(line), &ts) == nil && ts.Type == "thread.started" && ts.ThreadID != "" {
|
|
249
|
+
sessionID = ts.ThreadID
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
var ic codexItemCompleted
|
|
253
|
+
if json.Unmarshal([]byte(line), &ic) == nil && ic.Type == "item.completed" {
|
|
254
|
+
if t, _ := ic.Item["type"].(string); t == "agent_message" {
|
|
255
|
+
if s, _ := ic.Item["text"].(string); s != "" {
|
|
256
|
+
text = s
|
|
257
|
+
gotItem = true
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
var tc codexTurnCompleted
|
|
263
|
+
if json.Unmarshal([]byte(line), &tc) == nil && tc.Type == "turn.completed" {
|
|
264
|
+
usage = tc.Usage
|
|
265
|
+
gotUsage = true
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
// 非预期事件:忽略(其他事件如 message.created/command_execution...)。
|
|
269
|
+
}
|
|
270
|
+
_ = gotItem
|
|
271
|
+
_ = gotUsage
|
|
272
|
+
resp := Response{
|
|
273
|
+
Text: text,
|
|
274
|
+
SessionID: sessionID,
|
|
275
|
+
Model: stripModelPrefix(reqModelForCodex(e, "")),
|
|
276
|
+
}
|
|
277
|
+
// codex usage 不区分 input/output 思路:这里只赋值已知键,zero-value 自然兜底。
|
|
278
|
+
resp.InputTokens = usage.InputTokens + usage.CachedInputTokens
|
|
279
|
+
resp.OutputTokens = usage.OutputTokens + usage.ReasoningOutputTokens
|
|
280
|
+
resp.TotalTokens = resp.InputTokens + resp.OutputTokens
|
|
281
|
+
return resp
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Stream 实现 Streamer:`codex exec --json` 的 NDJSON 事件流逐行转发。
|
|
285
|
+
//
|
|
286
|
+
// ⚠️ 这一版 codex(0.133,2026-09-17 实测)**没有逐字文本增量** —— 正文在
|
|
287
|
+
// `item.completed{item.type=agent_message,text}` 里整段给出。实测事件形状(--tools on):
|
|
288
|
+
//
|
|
289
|
+
// {"type":"thread.started","thread_id":"…"} → 会话 id
|
|
290
|
+
// {"type":"turn.started"}
|
|
291
|
+
// {"type":"item.started","item":{"type":"command_execution","id":"item_0","command":"…"}}
|
|
292
|
+
// {"type":"item.completed","item":{"type":"command_execution","id":"item_0","command":"…","aggregated_output":"…","exit_code":0}}
|
|
293
|
+
// {"type":"item.completed","item":{"type":"agent_message","text":"…"}} → 正文(整段)
|
|
294
|
+
// {"type":"turn.completed","usage":{"input_tokens":…,"cached_input_tokens":…,"output_tokens":…,"reasoning_output_tokens":…}}
|
|
295
|
+
//
|
|
296
|
+
// 所以 codex 的「流式」是**事件级**的:工具调用/正文一到就发事件,调用方仍能实时看到
|
|
297
|
+
// 进展(只是没有逐字打字效果)。未知 item 类型忽略,不猜。
|
|
298
|
+
func (e *CodexEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
299
|
+
start := time.Now()
|
|
300
|
+
bin := e.bin()
|
|
301
|
+
if bin == "" {
|
|
302
|
+
return StreamResult{}, fmt.Errorf("codex CLI not found; set MAGIC_AGENT_CODEX_BIN")
|
|
303
|
+
}
|
|
304
|
+
prompt := FlattenPrompt(req.SystemPrompt, req.Messages, toolsIsOff(req))
|
|
305
|
+
if prompt == "" {
|
|
306
|
+
return StreamResult{}, fmt.Errorf("codex: empty prompt")
|
|
307
|
+
}
|
|
308
|
+
if req.SystemPrompt == "" && toolsIsOff(req) {
|
|
309
|
+
prompt += noToolSuffix
|
|
310
|
+
}
|
|
311
|
+
prompt = codexPromptWithAttachments(prompt, req)
|
|
312
|
+
|
|
313
|
+
// 流式不走 --output-last-message(正文直接从事件流拿),也不写 --output-schema 文件
|
|
314
|
+
//(schema 仍走调用方的输出后处理,与 Complete 的兜底一致);传空路径即自动跳过这两个 flag。
|
|
315
|
+
args := e.buildArgs(req, prompt, "", "")
|
|
316
|
+
codexHome, injectHome, _ := ensureCodexHome()
|
|
317
|
+
var extraEnv []string
|
|
318
|
+
if injectHome {
|
|
319
|
+
extraEnv = append(extraEnv, "CODEX_HOME="+codexHome)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
acc := &codexStreamAcc{OnEvent: onEvent}
|
|
323
|
+
if err := runStreamCLIEnvIn(ctx, req.Workspace, extraEnv, bin, args, acc.handleLine); err != nil {
|
|
324
|
+
return StreamResult{}, err
|
|
325
|
+
}
|
|
326
|
+
text := acc.Text.String()
|
|
327
|
+
if strings.TrimSpace(text) == "" {
|
|
328
|
+
return StreamResult{}, fmt.Errorf("codex CLI returned empty result")
|
|
329
|
+
}
|
|
330
|
+
in := acc.Usage.InputTokens + acc.Usage.CachedInputTokens
|
|
331
|
+
out := acc.Usage.OutputTokens + acc.Usage.ReasoningOutputTokens
|
|
332
|
+
return StreamResult{
|
|
333
|
+
Response: Response{
|
|
334
|
+
Engine: e.Name(),
|
|
335
|
+
Text: strings.TrimSpace(text),
|
|
336
|
+
Model: stripModelPrefix(reqModelForCodex(e, req.Model)),
|
|
337
|
+
SessionID: acc.SessionID,
|
|
338
|
+
InputTokens: in,
|
|
339
|
+
OutputTokens: out,
|
|
340
|
+
TotalTokens: in + out,
|
|
341
|
+
Latency: time.Since(start),
|
|
342
|
+
},
|
|
343
|
+
Thinking: strings.TrimSpace(acc.Thinking.String()),
|
|
344
|
+
}, nil
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// codexStreamAcc 把 codex 的 NDJSON 事件流映射成 StreamEvent(映射表见 Stream 注释)。
|
|
348
|
+
type codexStreamAcc struct {
|
|
349
|
+
OnEvent func(StreamEvent)
|
|
350
|
+
Text strings.Builder
|
|
351
|
+
Thinking strings.Builder
|
|
352
|
+
SessionID string
|
|
353
|
+
Usage codexTurnUsage
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
func (a *codexStreamAcc) emit(ev StreamEvent) {
|
|
357
|
+
if a.OnEvent != nil {
|
|
358
|
+
a.OnEvent(ev)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
func (a *codexStreamAcc) handleLine(line string) error {
|
|
363
|
+
s := strings.TrimSpace(line)
|
|
364
|
+
if s == "" {
|
|
365
|
+
return nil
|
|
366
|
+
}
|
|
367
|
+
var head struct {
|
|
368
|
+
Type string `json:"type"`
|
|
369
|
+
}
|
|
370
|
+
if err := json.Unmarshal([]byte(s), &head); err != nil {
|
|
371
|
+
return nil // 非 JSON 行(CLI 的警告输出)跳过,不打断整条流
|
|
372
|
+
}
|
|
373
|
+
switch head.Type {
|
|
374
|
+
case "thread.started":
|
|
375
|
+
var ts codexThreadStarted
|
|
376
|
+
if json.Unmarshal([]byte(s), &ts) == nil && ts.ThreadID != "" {
|
|
377
|
+
a.SessionID = ts.ThreadID
|
|
378
|
+
}
|
|
379
|
+
case "item.started", "item.completed":
|
|
380
|
+
a.handleItem(s, head.Type)
|
|
381
|
+
case "turn.completed":
|
|
382
|
+
var tc codexTurnCompleted
|
|
383
|
+
if json.Unmarshal([]byte(s), &tc) == nil {
|
|
384
|
+
a.Usage = tc.Usage
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return nil
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// handleItem 处理 item.started / item.completed:
|
|
391
|
+
//
|
|
392
|
+
// agent_message 正文(实测只在 completed 出现,整段)
|
|
393
|
+
// reasoning 思考(该版本未观测到,实测兜底)
|
|
394
|
+
// command_execution started → tool_use(Name=command、Args=命令行);
|
|
395
|
+
// completed → tool_result(Name/ID 关联,Text=聚合输出)
|
|
396
|
+
func (a *codexStreamAcc) handleItem(raw, evType string) {
|
|
397
|
+
var ic codexItemCompleted
|
|
398
|
+
if json.Unmarshal([]byte(raw), &ic) != nil {
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
it := ic.Item
|
|
402
|
+
itType, _ := it["type"].(string)
|
|
403
|
+
text, _ := it["text"].(string)
|
|
404
|
+
id, _ := it["id"].(string)
|
|
405
|
+
switch itType {
|
|
406
|
+
case "agent_message":
|
|
407
|
+
if evType == "item.completed" && text != "" {
|
|
408
|
+
a.Text.WriteString(text)
|
|
409
|
+
a.emit(StreamEvent{Kind: KindText, Text: text})
|
|
410
|
+
}
|
|
411
|
+
case "reasoning":
|
|
412
|
+
if evType == "item.completed" && text != "" {
|
|
413
|
+
a.Thinking.WriteString(text)
|
|
414
|
+
a.emit(StreamEvent{Kind: KindThinking, Text: text})
|
|
415
|
+
}
|
|
416
|
+
case "command_execution":
|
|
417
|
+
cmd, _ := it["command"].(string)
|
|
418
|
+
if evType == "item.started" {
|
|
419
|
+
a.emit(StreamEvent{Kind: KindToolUse, Name: "command", ID: id, Text: cmd})
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
out, _ := it["aggregated_output"].(string)
|
|
423
|
+
if out == "" {
|
|
424
|
+
out = cmd
|
|
425
|
+
}
|
|
426
|
+
a.emit(StreamEvent{Kind: KindToolResult, Name: id, ID: id, Text: out})
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// reqModelForCodex 统一决定模型来源:引擎构造注入 > Request.Model > 留空(用 CLI 默认)。
|
|
431
|
+
func reqModelForCodex(e *CodexEngine, fallback string) string {
|
|
432
|
+
if m := e.Model; m != "" {
|
|
433
|
+
return m
|
|
434
|
+
}
|
|
435
|
+
return fallback
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// writeJSONSchemaFile 序列化 JSONSchema 到磁盘(codex 读 TOML 格式,但
|
|
439
|
+
// --output-schema 接受任意 JSON Schema,这里走 JSON 序列化即可)。
|
|
440
|
+
func writeJSONSchemaFile(path string, s *JSONSchema) error {
|
|
441
|
+
doc := map[string]any{
|
|
442
|
+
"type": s.Type,
|
|
443
|
+
"properties": s.Properties,
|
|
444
|
+
"required": s.Required,
|
|
445
|
+
"additionalProperties": false,
|
|
446
|
+
}
|
|
447
|
+
if s.Type == "" {
|
|
448
|
+
doc["type"] = "object"
|
|
449
|
+
}
|
|
450
|
+
b, err := json.MarshalIndent(doc, "", " ")
|
|
451
|
+
if err != nil {
|
|
452
|
+
return err
|
|
453
|
+
}
|
|
454
|
+
return os.WriteFile(path, b, 0o600)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// codexPromptWithAttachments 把「没走 -i 的附件」补进提示词正文:
|
|
458
|
+
//
|
|
459
|
+
// 非图片附件 -i 只收图片,只能给路径(靠 codex 的读文件能力)
|
|
460
|
+
// resume 轮 `codex exec resume` 不接受 -i,图片也退回路径
|
|
461
|
+
//
|
|
462
|
+
// 新会话的图片附件走原生 -i(见 buildArgs),不进提示词。
|
|
463
|
+
func codexPromptWithAttachments(prompt string, req Request) string {
|
|
464
|
+
if len(req.Attachments) == 0 {
|
|
465
|
+
return prompt
|
|
466
|
+
}
|
|
467
|
+
if req.SessionID != "" || req.Continue {
|
|
468
|
+
return appendAttachmentSection(prompt, req.Attachments)
|
|
469
|
+
}
|
|
470
|
+
var rest []Attachment
|
|
471
|
+
for _, a := range req.Attachments {
|
|
472
|
+
if !a.IsImage() {
|
|
473
|
+
rest = append(rest, a)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return appendAttachmentSection(prompt, rest)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// buildArgs 构造 codex exec 参数(Complete 与可能的后续 Stream 复用)。
|
|
480
|
+
//
|
|
481
|
+
// args 形态(典型 new session):
|
|
482
|
+
// codex exec --skip-git-repo-check --json -m <model>
|
|
483
|
+
// [-c developer_instructions="<text>"]
|
|
484
|
+
// [-s read-only | --dangerously-bypass-approvals-and-sandbox]
|
|
485
|
+
// [-o <tmpfile>] [--output-schema <tmpfile>] [--ephemeral]
|
|
486
|
+
// <prompt>
|
|
487
|
+
//
|
|
488
|
+
// 续接变体:codex exec resume <id>|--last ...
|
|
489
|
+
func (e *CodexEngine) buildArgs(req Request, prompt, outFile, schemaFile string) []string {
|
|
490
|
+
// off 模式:不要 full-access(与「禁工具」冲突),用 read-only 沙箱压制
|
|
491
|
+
// 写入与命令执行;on / 白名单 模式:全放行,让 agent 真能跑。
|
|
492
|
+
tools := toolsOrDefault(req.Tools)
|
|
493
|
+
|
|
494
|
+
var args []string
|
|
495
|
+
// 子命令前缀(续接路径:codex exec resume [id|--last] ...)
|
|
496
|
+
switch {
|
|
497
|
+
case req.SessionID != "":
|
|
498
|
+
args = append(args, "exec", "resume", req.SessionID)
|
|
499
|
+
case req.Continue:
|
|
500
|
+
args = append(args, "exec", "resume", "--last")
|
|
501
|
+
default:
|
|
502
|
+
args = append(args, "exec")
|
|
503
|
+
}
|
|
504
|
+
// 通用 flags(放在子命令之后,prompt 之前)
|
|
505
|
+
args = append(args, "--skip-git-repo-check", "--json")
|
|
506
|
+
// 工作目录(workspace):codex 有原生 `-C/--cd`(working root),优先用它 ——
|
|
507
|
+
// 它同时决定 sandbox 的可写根与相对路径解析;子进程 cwd 也一并设置(见调用点)。
|
|
508
|
+
if ws := strings.TrimSpace(req.Workspace); ws != "" {
|
|
509
|
+
args = append(args, "-C", ws)
|
|
510
|
+
}
|
|
511
|
+
// 注意:不能加 --ephemeral。codex 0.133 在 --ephemeral 模式下不会把
|
|
512
|
+
// session rollout 落盘,后续 --session 续接时报 "no rollout found for
|
|
513
|
+
// thread id"(实测 2026-09-16)。为保持与 claude 一致的"默认新会话可续接"策略,
|
|
514
|
+
// 始终不传 --ephemeral。
|
|
515
|
+
// 模型
|
|
516
|
+
if m := stripModelPrefix(req.Model); m != "" {
|
|
517
|
+
args = append(args, "-m", m)
|
|
518
|
+
} else if e.Model != "" {
|
|
519
|
+
args = append(args, "-m", stripModelPrefix(e.Model))
|
|
520
|
+
}
|
|
521
|
+
// SystemPrompt → -c developer_instructions=<TOML 字符串>
|
|
522
|
+
if req.SystemPrompt != "" {
|
|
523
|
+
args = append(args, "-c", "developer_instructions="+tomlBasicString(req.SystemPrompt))
|
|
524
|
+
}
|
|
525
|
+
// 附件 → codex 原生 `-i/--image <FILE>...`(用户「文件+提示词 比如截图加提示词」)。
|
|
526
|
+
// 只对图片生效(-i 的语义就是 image),且**只在非 resume 轮**注入 ——
|
|
527
|
+
// `codex exec resume` 只接受有限的覆写参数(实测 -s / bypass 会被 exit 2 拒绝),
|
|
528
|
+
// 续接轮的附件改由 codexPromptWithAttachments 写进提示词。
|
|
529
|
+
if req.SessionID == "" && !req.Continue {
|
|
530
|
+
for _, a := range req.Attachments {
|
|
531
|
+
if a.IsImage() {
|
|
532
|
+
args = append(args, "-i", a.Path)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// 工具/沙箱:off 模式不调用工具,read-only 沙箱最契合(on/白名单需要工具,放行)。
|
|
537
|
+
// 仅在非 resume 子命令下注入;codex 0.133 的 `exec resume` 子命令不接受
|
|
538
|
+
// -s/--dangerously-bypass-approvals-and-sandbox(--help 列表无此 flag),
|
|
539
|
+
// 传了直接 exit 2 "unexpected argument '-s' found"(实测 2026-09-16)。
|
|
540
|
+
// 续接时延用首次会话的 sandbox 设置。
|
|
541
|
+
if req.SessionID == "" && !req.Continue {
|
|
542
|
+
if tools.IsOff() {
|
|
543
|
+
args = append(args, "-s", "read-only")
|
|
544
|
+
} else {
|
|
545
|
+
args = append(args, "--dangerously-bypass-approvals-and-sandbox")
|
|
546
|
+
}
|
|
547
|
+
// web_search 覆写(MAGIC_AGENT_CODEX_WEBSEARCH=live|cached|indexed):
|
|
548
|
+
// 用户 config 常配 disabled;联网类查询需在此放开。resume 不注入。
|
|
549
|
+
if v := codexWebSearchOverride(); v != "" {
|
|
550
|
+
args = append(args, "-c", "web_search="+v)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// -o 与 --output-schema 落盘文件
|
|
554
|
+
if outFile != "" {
|
|
555
|
+
args = append(args, "-o", outFile)
|
|
556
|
+
}
|
|
557
|
+
if schemaFile != "" {
|
|
558
|
+
args = append(args, "--output-schema", schemaFile)
|
|
559
|
+
}
|
|
560
|
+
// 统一参数矩阵:MaxTokens / Temperature 静默忽略(见 engine_base.go 矩阵②)
|
|
561
|
+
_ = req.MaxTokens
|
|
562
|
+
_ = req.Temperature
|
|
563
|
+
|
|
564
|
+
// prompt 必须放最后(codex exec [PROMPT] 位置参数)
|
|
565
|
+
args = append(args, prompt)
|
|
566
|
+
return args
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// codexWebSearchOverride 读 MAGIC_AGENT_CODEX_WEBSEARCH 覆写值。
|
|
570
|
+
// 仅接受 0.154 实测合法值域(disabled/cached/indexed/live),其余忽略。
|
|
571
|
+
func codexWebSearchOverride() string {
|
|
572
|
+
switch v := os.Getenv("MAGIC_AGENT_CODEX_WEBSEARCH"); v {
|
|
573
|
+
case "disabled", "cached", "indexed", "live":
|
|
574
|
+
return v
|
|
575
|
+
}
|
|
576
|
+
return ""
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// tomlBasicString 把任意字符串编码为 TOML basic string 的等价形式。
|
|
580
|
+
// TOML basic string 与 JSON 字符串的转义集完全一致(\b \t \n \f \r \" \\ \uXXXX),
|
|
581
|
+
// 直接走 json.Marshal + 去掉外层引号即可。
|
|
582
|
+
func tomlBasicString(s string) string {
|
|
583
|
+
b, err := json.Marshal(s)
|
|
584
|
+
if err != nil {
|
|
585
|
+
// 兜底:无法 marshal 时(基本不可能,因为 json 接受任意字符串)用双引号包裹
|
|
586
|
+
// 并手动转义引号/反斜杠。
|
|
587
|
+
var sb strings.Builder
|
|
588
|
+
sb.WriteByte('"')
|
|
589
|
+
for _, r := range s {
|
|
590
|
+
if r == '"' || r == '\\' {
|
|
591
|
+
sb.WriteByte('\\')
|
|
592
|
+
}
|
|
593
|
+
sb.WriteRune(r)
|
|
594
|
+
}
|
|
595
|
+
sb.WriteByte('"')
|
|
596
|
+
return sb.String()
|
|
597
|
+
}
|
|
598
|
+
return string(b)
|
|
599
|
+
}
|