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.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. 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
+ }