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,882 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// stream.go - 流式调用抽象(含思考过程与工具调用)。
|
|
4
|
+
//
|
|
5
|
+
// Streamer 是 Engine 的流式形态:CLI 以 stream-json 逐行输出,
|
|
6
|
+
// magic-agent 实时解析并把四类增量转发给回调:
|
|
7
|
+
//
|
|
8
|
+
// KindThinking — 模型思考过程(reasoning / thinking delta)
|
|
9
|
+
// KindText — 正文增量
|
|
10
|
+
// KindToolUse — 模型发起工具调用(name + args;args 在收尾一次性发出,
|
|
11
|
+
// 由 input_json_delta 累积而成,最稳定)
|
|
12
|
+
// KindToolResult — 工具调用产出(id 关联回 tool_use)
|
|
13
|
+
//
|
|
14
|
+
// 流式模式语义与 Complete 不同,刻意不共享 Runner:
|
|
15
|
+
// - 超时仍然生效(每次尝试独立进程组,超时杀整组);
|
|
16
|
+
// - 不做自动重试——增量已实时发往 stdout,重放会造成重复消费;
|
|
17
|
+
// 需要重试语义的调用方用非流式 Run。
|
|
18
|
+
//
|
|
19
|
+
// 三家 CLI 的流式协议(2026-09-15 实测):
|
|
20
|
+
//
|
|
21
|
+
// claude / codebuddy(同源 CodeBuddy Code 系):
|
|
22
|
+
// --output-format stream-json --include-partial-messages --verbose
|
|
23
|
+
// NDJSON 行 {"type":"stream_event","event":{"type":"content_block_delta",
|
|
24
|
+
// "delta":{"type":"thinking_delta","thinking":"..."}}} / text_delta
|
|
25
|
+
// 工具:content_block_start{type:tool_use} → content_block_delta{input_json_delta} → content_block_stop
|
|
26
|
+
// 后续 content_block_start{type:tool_result} → text 字段即产出文本
|
|
27
|
+
// 收尾 {"type":"result","subtype":"success","result":"全文",...}
|
|
28
|
+
//
|
|
29
|
+
// trae:
|
|
30
|
+
// -p --output-format stream-json --include-partial-messages
|
|
31
|
+
// NDJSON 行 {"type":"stream_event","delta":{"role":"assistant",
|
|
32
|
+
// "content":"增量"}};无 thinking 通道(模型侧不开 reasoning);
|
|
33
|
+
// trae 的 stream-json 协议**不暴露独立工具事件通道**,KindToolUse/KindToolResult
|
|
34
|
+
// 在 trae 下恒为空——这是协议限制,不是解析缺陷。
|
|
35
|
+
|
|
36
|
+
import (
|
|
37
|
+
"bufio"
|
|
38
|
+
"bytes"
|
|
39
|
+
"context"
|
|
40
|
+
"encoding/json"
|
|
41
|
+
"fmt"
|
|
42
|
+
"io"
|
|
43
|
+
"os"
|
|
44
|
+
"strings"
|
|
45
|
+
"time"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// StreamEventKind 增量类型。
|
|
49
|
+
type StreamEventKind string
|
|
50
|
+
|
|
51
|
+
const (
|
|
52
|
+
// KindThinking 思考过程增量。
|
|
53
|
+
KindThinking StreamEventKind = "thinking"
|
|
54
|
+
// KindText 正文增量。
|
|
55
|
+
KindText StreamEventKind = "text"
|
|
56
|
+
// KindToolUse 模型发起工具调用。
|
|
57
|
+
//
|
|
58
|
+
// Name: 工具名(如 "Bash" / "Read" / "Edit")。
|
|
59
|
+
// ID: 引擎分配的工具调用 id(claude/codebuddy: tool_use_id;trae: 空串)。
|
|
60
|
+
// Args: 完整参数 JSON 字符串(由 input_json_delta 累积而成;args 字段在
|
|
61
|
+
// content_block_stop 时一次性填入并 emit,消费方无需自己拼装)。
|
|
62
|
+
// StreamEvent.Text 字段同步填入 Args,便于通用 NDJSON 序列化。
|
|
63
|
+
KindToolUse StreamEventKind = "tool_use"
|
|
64
|
+
// KindToolResult 工具调用产出。
|
|
65
|
+
//
|
|
66
|
+
// Name: 关联的 tool_use.id(用于反向索引)。
|
|
67
|
+
// Text 字段即工具产出文本(可能是 JSON / 命令 stdout / 文件内容等)。
|
|
68
|
+
KindToolResult StreamEventKind = "tool_result"
|
|
69
|
+
// KindTurnEnd 一轮结束(引擎给出了 result 行)。
|
|
70
|
+
//
|
|
71
|
+
// 只有 claude / codebuddy 的 stream-json 路径会发(它们每轮收尾都有 result 行)。
|
|
72
|
+
// 用途:常驻会话(Request.Append)里告诉调用方「这轮做完了,可以继续追加」——
|
|
73
|
+
// CLI 的 keep-alive 空闲计时就挂在它上面(见 cli/ask.go)。
|
|
74
|
+
// Text 填该轮的最终正文(与 result 行一致)。
|
|
75
|
+
KindTurnEnd StreamEventKind = "turn_end"
|
|
76
|
+
// KindAsk 「需要用户选择」(模型提问 / 工具待授权)。
|
|
77
|
+
//
|
|
78
|
+
// 归一化后的统一格式在 StreamEvent.Ask(跨引擎一致,见 ask.go),Text 是它的一行人话摘要。
|
|
79
|
+
// 目前只有 claude / codebuddy 会发(其余引擎无 AskUserQuestion,也无 can_use_tool 协议)。
|
|
80
|
+
//
|
|
81
|
+
// ⚠️ 消费方注意:headless 下 claude 在模型发出提问后会**立刻自行拒绝**
|
|
82
|
+
//(实测 tool_result "Answer questions?",is_error=true),模型拿不到用户答案。
|
|
83
|
+
// 要真正作答,得把答案作为**后续 user 消息**补进去(见 EncodeAskFollowUp),
|
|
84
|
+
// 正好复用常驻会话的 --append 通道。
|
|
85
|
+
KindAsk StreamEventKind = "ask"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// StreamEvent 一条流式增量。
|
|
89
|
+
type StreamEvent struct {
|
|
90
|
+
Kind StreamEventKind
|
|
91
|
+
Text string
|
|
92
|
+
// Name 工具名(仅 KindToolUse / KindToolResult 携带;其余为空)。
|
|
93
|
+
Name string
|
|
94
|
+
// ID 工具调用 id(仅 KindToolUse / KindToolResult 携带;其余为空)。
|
|
95
|
+
// KindToolResult 的 ID 与之关联的 KindToolUse.ID 相同。
|
|
96
|
+
ID string
|
|
97
|
+
// SessionID 会话 id(目前只有 KindTurnEnd 携带)。
|
|
98
|
+
//
|
|
99
|
+
// 为什么放在轮次事件上:常驻会话(keep-alive)的**最终 result 信封要等会话结束**
|
|
100
|
+
// 才输出(默认空闲 5m),而调用方(观物台)需要在「这一轮刚做完」时就把会话 id
|
|
101
|
+
// 落库/绑定 —— 否则下一轮追问会找不到锚点,只能退化成 `-c` 续接最近会话。
|
|
102
|
+
// 引擎在每轮 result 行就能拿到 session_id(claude/codebuddy 的收尾行自带)。
|
|
103
|
+
SessionID string
|
|
104
|
+
// Ask 归一化后的「需要用户选择」请求(仅 KindAsk 携带;其余为 nil)。
|
|
105
|
+
//
|
|
106
|
+
// 有了它,调用方不必再分辨这次是 claude 的 tool_use 形态还是 control_request 形态
|
|
107
|
+
//(两种形态在 wire 上字段完全不同,见 ask.go 的 ParseAskLine)。
|
|
108
|
+
Ask *AskRequest
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ToolCall 一次完整的工具调用记录(用于收尾汇总)。
|
|
112
|
+
type ToolCall struct {
|
|
113
|
+
// Name 工具名。
|
|
114
|
+
Name string
|
|
115
|
+
// ID 引擎分配的工具调用 id(可用于关联 KindToolResult)。
|
|
116
|
+
ID string
|
|
117
|
+
// Args 完整参数 JSON 字符串。
|
|
118
|
+
Args string
|
|
119
|
+
// Result 工具产出文本(可能为空——某些工具无文本产出)。
|
|
120
|
+
Result string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// StreamResult 流式调用的收尾汇总。
|
|
124
|
+
type StreamResult struct {
|
|
125
|
+
Response
|
|
126
|
+
// Thinking 完整思考过程(各增量拼接)。
|
|
127
|
+
Thinking string
|
|
128
|
+
// Tools 全部工具调用记录(按发生顺序)。
|
|
129
|
+
// 仅 claude/codebuddy 在 --tools on / 白名单模式下非空;
|
|
130
|
+
// trae 协议不暴露工具事件通道,恒为 nil。
|
|
131
|
+
Tools []ToolCall
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Streamer 流式引擎接口。实现负责单次流式尝试;
|
|
135
|
+
// 超时由 StreamRunner 外层控制。
|
|
136
|
+
type Streamer interface {
|
|
137
|
+
// Stream 发起一次流式调用,实时把增量写入 onEvent,
|
|
138
|
+
// 返回收尾结果(含全文与思考过程)。
|
|
139
|
+
Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// AsStreamer 把 Engine 升级为 Streamer(不支持流式的引擎返回 nil)。
|
|
143
|
+
func AsStreamer(e Engine) Streamer {
|
|
144
|
+
s, _ := e.(Streamer)
|
|
145
|
+
return s
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// streamArgsFor 返回引擎的流式支持标记(用于 SupportsStream 快速判定)。
|
|
149
|
+
// 返回 nil 表示该引擎不支持流式。
|
|
150
|
+
func streamArgsFor(e Engine) []string {
|
|
151
|
+
switch e.(type) {
|
|
152
|
+
case *ClaudeEngine:
|
|
153
|
+
return []string{"stream-json"}
|
|
154
|
+
case *CodeBuddyEngine:
|
|
155
|
+
return []string{"stream-json"}
|
|
156
|
+
case *CodeBuddyAIEngine:
|
|
157
|
+
return []string{"stream-json"}
|
|
158
|
+
case *TraeEngine:
|
|
159
|
+
return []string{"stream-json"}
|
|
160
|
+
case *LLMEngine:
|
|
161
|
+
return []string{"stream"} // llm prompt 默认流式(纯文本 stdout)
|
|
162
|
+
case *DshEngine:
|
|
163
|
+
// dsh 默认走 SDK profile 的 stdio JSON-RPC(session.event 帧):推理、正文
|
|
164
|
+
// 逐 step、工具调用都有;该 profile 不可用时回退 headless(只有推理增量走
|
|
165
|
+
// stderr、正文一次性走 stdout)。标 sdk-jsonrpc 便于排查时一眼看出这一族。
|
|
166
|
+
return []string{"sdk-jsonrpc"}
|
|
167
|
+
case *OpenClawEngine:
|
|
168
|
+
// openclaw 的流式走 **ACP**(`openclaw acp`,stdio + JSON-RPC,背后接本地 Gateway):
|
|
169
|
+
// 正文逐字增量 + tool_call/tool_call_update(思考流协议侧不支持)。
|
|
170
|
+
// ⚠️ 它比别的引擎多一个外部依赖(Gateway 在跑 + ACP 桥的 scope 已批):
|
|
171
|
+
// 桥不可用时 Stream 会**自动回退**内嵌一次性调用(见 openclaw_acp.go 文件头)。
|
|
172
|
+
return []string{"acp"}
|
|
173
|
+
case *ArkClawEngine:
|
|
174
|
+
// arkclaw 的流式走 A2A 官方的 **SSE** 通道(`message/stream`,text/event-stream)。
|
|
175
|
+
// ⚠️ 与「逐字」不是一回事:实测本网关只发「受理帧(working,空正文)+ 终帧
|
|
176
|
+
//(completed,整段正文)」两帧 —— 正文仍是一次性到达,见 arkclaw_stream.go 文件头。
|
|
177
|
+
// 标 sse 便于排查时一眼看出这一族(也是 WebSocket 走不通后的唯一正路)。
|
|
178
|
+
return []string{"sse"}
|
|
179
|
+
case *CodeBuddyGatewayEngine:
|
|
180
|
+
// codebuddy-gateway 也走 SSE(`GET /api/v1/runs/{runId}/stream`),但它同时是
|
|
181
|
+
// **非流式路径的唯一取文通道**(投递响应里没有正文),所以 --stream 与非 --stream
|
|
182
|
+
// 走的都是这条路。⚠️ 实测(2026-09-24)该网关目前只推终帧、**不发增量帧** ——
|
|
183
|
+
// 「流式」的成色与 arkclaw 一样是「整段到达」,别当成打字机;
|
|
184
|
+
// 增量解析已按协议实现,网关将来吐 chunk 时零改动即可接住。
|
|
185
|
+
// 见 codebuddy_gateway_stream.go 文件头。
|
|
186
|
+
return []string{"sse"}
|
|
187
|
+
}
|
|
188
|
+
return nil
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// SupportsStream 报告引擎是否支持流式。
|
|
192
|
+
func SupportsStream(e Engine) bool {
|
|
193
|
+
return streamArgsFor(e) != nil && AsStreamer(e) != nil
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// runStreamCLI 启动 CLI 流式进程,逐行回调,收尾返回全文与思考。
|
|
197
|
+
// lineHandler 返回非 nil error 时立即杀进程组并中止(解析致命错误)。
|
|
198
|
+
//
|
|
199
|
+
// 管道为何自建而不用 StdoutPipe:StdoutPipe 的读端会在 cmd.Wait() 里被
|
|
200
|
+
// 提前关闭——进程先退而 reader 尚有管道积压时,余量被内核丢弃、reader
|
|
201
|
+
// 读到 "file already closed",把一次成功的流式调用变成报错(实测复现)。
|
|
202
|
+
// 自建管道的读端归 reader 独有、Wait 不触碰;父进程写端在 Start 后立即
|
|
203
|
+
// 关闭,EOF 语义 = 进程退出(含所有继承写端的后代)。
|
|
204
|
+
func runStreamCLI(ctx context.Context, bin string, args []string, lineHandler func(line string) error) error {
|
|
205
|
+
return runStreamCLIIn(ctx, "", bin, args, lineHandler)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// runStreamCLIIn 同 runStreamCLI,可指定工作目录(workspace,见 runcmd.go runCLIIn 注释)。
|
|
209
|
+
func runStreamCLIIn(ctx context.Context, dir string, bin string, args []string, lineHandler func(line string) error) error {
|
|
210
|
+
return runStreamCLIEnvIn(ctx, dir, nil, bin, args, lineHandler)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// runStreamCLIEnvIn = runStreamCLIIn + 追加环境变量(如 codex 的 CODEX_HOME 隔离)。
|
|
214
|
+
func runStreamCLIEnvIn(ctx context.Context, dir string, extraEnv []string, bin string, args []string, lineHandler func(line string) error) error {
|
|
215
|
+
return runStreamCLIStdinIn(ctx, dir, extraEnv, bin, args, "", lineHandler)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// runStreamCLIStdinIn = runStreamCLIEnvIn + 可给子进程喂 stdin 内容(一次性字符串)。
|
|
219
|
+
func runStreamCLIStdinIn(ctx context.Context, dir string, extraEnv []string, bin string, args []string, stdin string, lineHandler func(line string) error) error {
|
|
220
|
+
if stdin == "" {
|
|
221
|
+
return runStreamCLIStdinReaderIn(ctx, dir, extraEnv, bin, args, nil, lineHandler)
|
|
222
|
+
}
|
|
223
|
+
return runStreamCLIStdinReaderIn(ctx, dir, extraEnv, bin, args, strings.NewReader(stdin), lineHandler)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// runStreamCLIStdinReaderIn = 上面那个 + stdin 可以是**流**(常驻会话靠它持续喂消息)。
|
|
227
|
+
//
|
|
228
|
+
// 为什么需要流:claude/codebuddy 的 `--input-format stream-json` 允许在一个进程里
|
|
229
|
+
// 持续接收 user 消息(「会话中追加需求」)。调用方给的 reader 不 EOF,子进程就一直活着;
|
|
230
|
+
// reader EOF(或关闭)→ 子进程收尾退出。
|
|
231
|
+
func runStreamCLIStdinReaderIn(ctx context.Context, dir string, extraEnv []string, bin string, args []string, stdin io.Reader, lineHandler func(line string) error) error {
|
|
232
|
+
cmd := newStreamCmdIn(dir, bin, args)
|
|
233
|
+
if len(extraEnv) > 0 {
|
|
234
|
+
// Go exec 语义:重复 key 取最后一个 → extraEnv 覆盖继承值
|
|
235
|
+
cmd.Env = append(cmd.Env, extraEnv...)
|
|
236
|
+
}
|
|
237
|
+
if stdin != nil {
|
|
238
|
+
cmd.Stdin = stdin
|
|
239
|
+
}
|
|
240
|
+
stdoutR, stdoutW, err := os.Pipe()
|
|
241
|
+
if err != nil {
|
|
242
|
+
return fmt.Errorf("stdout pipe: %w", err)
|
|
243
|
+
}
|
|
244
|
+
cmd.Stdout = stdoutW
|
|
245
|
+
/* stderr 也自建管道 + 自己 drain(不能用 &streamStderrBuf{} 直挂):
|
|
246
|
+
Go 只在 Stderr 是 *os.File 时才不做「内部拷贝 goroutine」,而 **cmd.Wait() 会等那个
|
|
247
|
+
goroutine 结束** —— 只要引擎还有后代进程(逃出进程组的 node worker 之类)握着 stderr
|
|
248
|
+
写端,Wait 就永远不返回。实测症状(2026-09-18,常驻会话 + `--stop`):引擎已被杀、
|
|
249
|
+
magic-agent 却一直挂着不退(goroutine dump 显示主 goroutine 卡在 `<-waitErr`),
|
|
250
|
+
观物台那边就是「点了停止,界面一直转圈」。自建管道后 Wait 只等进程本身。 */
|
|
251
|
+
stderrR, stderrW, err := os.Pipe()
|
|
252
|
+
if err != nil {
|
|
253
|
+
stdoutR.Close()
|
|
254
|
+
stdoutW.Close()
|
|
255
|
+
return fmt.Errorf("stderr pipe: %w", err)
|
|
256
|
+
}
|
|
257
|
+
stderrBuf := &streamStderrBuf{}
|
|
258
|
+
cmd.Stderr = stderrW
|
|
259
|
+
if err := cmd.Start(); err != nil {
|
|
260
|
+
stdoutR.Close()
|
|
261
|
+
stdoutW.Close()
|
|
262
|
+
stderrR.Close()
|
|
263
|
+
stderrW.Close()
|
|
264
|
+
return err
|
|
265
|
+
}
|
|
266
|
+
notifySpawn(ctx, cmd.Process.Pid) // 会话登记表据此记住引擎子进程 pid
|
|
267
|
+
stdoutW.Close() // 父进程不再持有写端;EOF 只取决于子进程一侧
|
|
268
|
+
stderrW.Close()
|
|
269
|
+
go func() { defer stderrR.Close(); _, _ = io.Copy(stderrBuf, stderrR) }()
|
|
270
|
+
|
|
271
|
+
// 看门狗:ctx 结束 → 杀整组;否则等进程自然退出。
|
|
272
|
+
done := make(chan error, 1)
|
|
273
|
+
go func() {
|
|
274
|
+
defer stdoutR.Close()
|
|
275
|
+
sc := bufio.NewScanner(stdoutR)
|
|
276
|
+
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) // 16MB 行上限(result 行可能很大)
|
|
277
|
+
for sc.Scan() {
|
|
278
|
+
if line := sc.Text(); line != "" {
|
|
279
|
+
if err := lineHandler(line); err != nil {
|
|
280
|
+
killProcessGroup(cmd)
|
|
281
|
+
done <- err // 让主 select 把 handler 错误带回(reader 是唯一发送方,不能在此收)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if err := sc.Err(); err != nil {
|
|
287
|
+
done <- fmt.Errorf("read stream: %w", err)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
done <- nil
|
|
291
|
+
}()
|
|
292
|
+
|
|
293
|
+
waitErr := make(chan error, 1)
|
|
294
|
+
go func() { waitErr <- cmd.Wait() }()
|
|
295
|
+
|
|
296
|
+
select {
|
|
297
|
+
case err := <-done:
|
|
298
|
+
// 读端已 EOF(引擎 stdout 关闭 = 引擎这一侧结束了)→ **不再死等 Wait**:
|
|
299
|
+
// 后代进程可能还握着 stderr 写端,硬等会把「引擎已死」变成「magic-agent 挂着不退」
|
|
300
|
+
//(见上面 stderr 管道的注释)。让 Wait 在后台自行回收即可 —— 本进程随后就退出,
|
|
301
|
+
// 内核会把子进程交给 init 收尸。
|
|
302
|
+
go func() { <-waitErr }()
|
|
303
|
+
return err
|
|
304
|
+
case err := <-waitErr:
|
|
305
|
+
// 进程先退(正常退出时 reader 也会很快结束;先等 reader 把余量读完)。
|
|
306
|
+
// 注意:waitErr 已在本 case 消费过,下方任何路径都不得再收,
|
|
307
|
+
// 否则永久阻塞(waitErr 只有一个发送方,第二次接收必然死锁)。
|
|
308
|
+
if err == nil {
|
|
309
|
+
// 正常退出 → 等 reader 收尾(有限等待防挂死)。
|
|
310
|
+
select {
|
|
311
|
+
case rerr := <-done:
|
|
312
|
+
return rerr
|
|
313
|
+
case <-time.After(10 * time.Second):
|
|
314
|
+
killProcessGroup(cmd)
|
|
315
|
+
return fmt.Errorf("stream reader did not finish after process exit")
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
killProcessGroup(cmd)
|
|
319
|
+
select {
|
|
320
|
+
case <-done:
|
|
321
|
+
case <-time.After(10 * time.Second):
|
|
322
|
+
return fmt.Errorf("cli exited: %w(reader 未收尾:后代进程可能仍持有 stdout)", err)
|
|
323
|
+
}
|
|
324
|
+
return fmt.Errorf("cli exited: %w", err)
|
|
325
|
+
case <-ctx.Done():
|
|
326
|
+
killProcessGroup(cmd)
|
|
327
|
+
select {
|
|
328
|
+
case <-done:
|
|
329
|
+
case <-time.After(10 * time.Second):
|
|
330
|
+
// 同 done 分支:后代进程可能还握着管道,不能让「杀组」变成永久挂起。
|
|
331
|
+
return fmt.Errorf("process group killed: %w(reader 未收尾)", ctx.Err())
|
|
332
|
+
}
|
|
333
|
+
select {
|
|
334
|
+
case <-waitErr:
|
|
335
|
+
case <-time.After(10 * time.Second):
|
|
336
|
+
}
|
|
337
|
+
return fmt.Errorf("process group killed: %w", ctx.Err())
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// runStreamStderrIn 启动 CLI 并把它的 **stderr 逐行回调**,收尾返回 stdout 全文与 stderr 全文。
|
|
342
|
+
//
|
|
343
|
+
// 为什么需要它(与上面几个 helper 正好相反):其余引擎的增量走 stdout,
|
|
344
|
+
// 而 dsh 的 headless 把**推理增量写到 stderr**("dsh: reasoning:" 起头),
|
|
345
|
+
// 最终正文才一次性打到 stdout。要把它接成 Streamer,就必须扫 stderr 而不是 stdout。
|
|
346
|
+
//
|
|
347
|
+
// 管道与 Wait 的处理沿用上面同一条经验(自建管道 + 自己 drain,不让 cmd.Wait 等
|
|
348
|
+
// Go 的内部拷贝 goroutine,否则后代进程握着写端时 Wait 永不返回)。
|
|
349
|
+
func runStreamStderrIn(ctx context.Context, dir string, bin string, args []string, onStderrLine func(line string) error) (string, string, error) {
|
|
350
|
+
cmd := newStreamCmdIn(dir, bin, args)
|
|
351
|
+
|
|
352
|
+
stdoutR, stdoutW, err := os.Pipe()
|
|
353
|
+
if err != nil {
|
|
354
|
+
return "", "", fmt.Errorf("stdout pipe: %w", err)
|
|
355
|
+
}
|
|
356
|
+
stderrR, stderrW, err := os.Pipe()
|
|
357
|
+
if err != nil {
|
|
358
|
+
stdoutR.Close()
|
|
359
|
+
stdoutW.Close()
|
|
360
|
+
return "", "", fmt.Errorf("stderr pipe: %w", err)
|
|
361
|
+
}
|
|
362
|
+
var stdoutBuf bytes.Buffer
|
|
363
|
+
var stderrBuf bytes.Buffer
|
|
364
|
+
cmd.Stdout = stdoutW
|
|
365
|
+
cmd.Stderr = stderrW
|
|
366
|
+
|
|
367
|
+
if err := cmd.Start(); err != nil {
|
|
368
|
+
stdoutR.Close()
|
|
369
|
+
stdoutW.Close()
|
|
370
|
+
stderrR.Close()
|
|
371
|
+
stderrW.Close()
|
|
372
|
+
return "", "", err
|
|
373
|
+
}
|
|
374
|
+
notifySpawn(ctx, cmd.Process.Pid) // 会话登记表据此记住引擎子进程 pid
|
|
375
|
+
stdoutW.Close() // 父进程不再持有写端;EOF 只取决于子进程一侧
|
|
376
|
+
stderrW.Close()
|
|
377
|
+
|
|
378
|
+
// stdout 只做收集(最终正文),不逐行回调。
|
|
379
|
+
stdoutDone := make(chan error, 1)
|
|
380
|
+
go func() {
|
|
381
|
+
defer stdoutR.Close()
|
|
382
|
+
_, cerr := io.Copy(&stdoutBuf, stdoutR)
|
|
383
|
+
stdoutDone <- cerr
|
|
384
|
+
}()
|
|
385
|
+
|
|
386
|
+
// stderr 逐行扫 + 回调(推理增量)。
|
|
387
|
+
stderrDone := make(chan error, 1)
|
|
388
|
+
go func() {
|
|
389
|
+
defer stderrR.Close()
|
|
390
|
+
sc := bufio.NewScanner(stderrR)
|
|
391
|
+
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
392
|
+
for sc.Scan() {
|
|
393
|
+
line := sc.Text()
|
|
394
|
+
stderrBuf.WriteString(line)
|
|
395
|
+
stderrBuf.WriteByte('\n')
|
|
396
|
+
if onStderrLine == nil {
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
if herr := onStderrLine(line); herr != nil {
|
|
400
|
+
killProcessGroup(cmd)
|
|
401
|
+
stderrDone <- herr
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
stderrDone <- sc.Err()
|
|
406
|
+
}()
|
|
407
|
+
|
|
408
|
+
waitErr := make(chan error, 1)
|
|
409
|
+
go func() { waitErr <- cmd.Wait() }()
|
|
410
|
+
|
|
411
|
+
// result 收尾:把两条管道读干净(都限时,防后代进程握着写端永久挂起),
|
|
412
|
+
// 再把缓冲里的内容交出去。
|
|
413
|
+
result := func(err error) (string, string, error) {
|
|
414
|
+
waitDrainedBounded(stderrDone, 5*time.Second)
|
|
415
|
+
waitDrainedBounded(stdoutDone, 5*time.Second)
|
|
416
|
+
return stdoutBuf.String(), stderrBuf.String(), err
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
select {
|
|
420
|
+
case werr := <-waitErr:
|
|
421
|
+
return result(werr)
|
|
422
|
+
case serr := <-stderrDone:
|
|
423
|
+
if serr == nil {
|
|
424
|
+
// stderr 先 EOF(罕见):仍要等进程退出才算收尾。
|
|
425
|
+
select {
|
|
426
|
+
case werr := <-waitErr:
|
|
427
|
+
return result(werr)
|
|
428
|
+
case <-ctx.Done():
|
|
429
|
+
killProcessGroup(cmd)
|
|
430
|
+
return result(fmt.Errorf("process group killed: %w", ctx.Err()))
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// 回调报错(或读流失败):杀组后收尾。
|
|
434
|
+
killProcessGroup(cmd)
|
|
435
|
+
select {
|
|
436
|
+
case <-waitErr:
|
|
437
|
+
case <-time.After(10 * time.Second):
|
|
438
|
+
}
|
|
439
|
+
return result(serr)
|
|
440
|
+
case <-ctx.Done():
|
|
441
|
+
killProcessGroup(cmd)
|
|
442
|
+
select {
|
|
443
|
+
case <-waitErr:
|
|
444
|
+
case <-time.After(10 * time.Second):
|
|
445
|
+
}
|
|
446
|
+
return result(fmt.Errorf("process group killed: %w", ctx.Err()))
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// waitDrainedBounded 等一个 drain goroutine 收尾,最多等 d(超时即放弃,防挂死)。
|
|
451
|
+
func waitDrainedBounded(ch <-chan error, d time.Duration) {
|
|
452
|
+
select {
|
|
453
|
+
case <-ch:
|
|
454
|
+
case <-time.After(d):
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// streamAccumulator 聚合增量与收尾 result 行。
|
|
459
|
+
type streamAccumulator struct {
|
|
460
|
+
Text strings.Builder
|
|
461
|
+
Thinking strings.Builder
|
|
462
|
+
OnEvent func(StreamEvent)
|
|
463
|
+
|
|
464
|
+
// Tools 已完成(收到 content_block_stop)的工具调用记录,按发生顺序追加。
|
|
465
|
+
// 仅 claude/codebuddy 在 --tools on / 白名单模式下非空;
|
|
466
|
+
// trae 协议不暴露工具事件通道,恒为 nil。
|
|
467
|
+
Tools []ToolCall
|
|
468
|
+
|
|
469
|
+
// 工具调用累积状态(仅 claude/codebuddy 用到;trae 协议不暴露这些字段)。
|
|
470
|
+
//
|
|
471
|
+
// 累积策略:
|
|
472
|
+
// content_block_start{type:tool_use, name, id} → 记录当前 open 的 tool_use
|
|
473
|
+
// content_block_delta{input_json_delta.partial_json} → 累积到 argsBuilder
|
|
474
|
+
// content_block_stop → 把累积结果 emit 为 KindToolUse,加入 Tools 列表
|
|
475
|
+
// content_block_start{type:tool_result} → 切换到结果累积模式
|
|
476
|
+
// (后续 text_delta → emit 为 KindToolResult,并把产出回填到对应 ToolCall.Result)
|
|
477
|
+
//
|
|
478
|
+
// 同一时刻只有一个 open tool_use;trae 下此状态恒为空。
|
|
479
|
+
openToolName string // 当前 open tool_use 的工具名
|
|
480
|
+
openToolID string // 当前 open tool_use 的 id
|
|
481
|
+
openToolArgs strings.Builder
|
|
482
|
+
expectingToolStop bool // 下一个 stop 应触发 KindToolUse emit
|
|
483
|
+
|
|
484
|
+
openResultID string // 当前 open tool_result 关联的 id
|
|
485
|
+
expectingResultStart bool // 等待下一个 content_block_start{type:tool_result}
|
|
486
|
+
|
|
487
|
+
// Engine 引擎名(各引擎构造累加器时填入),归一化「需要用户选择」时回填 AskRequest.Engine。
|
|
488
|
+
// 留空也能跑:解析本身与引擎无关,只有 header 上限之类的归一化需要它。
|
|
489
|
+
Engine string
|
|
490
|
+
|
|
491
|
+
// SessionID 流里最近见到的 session_id(system/init、assistant、result 行都带)。
|
|
492
|
+
//
|
|
493
|
+
// 为什么要它:KindAsk 事件必须能**直接接住** —— 宿主看到提问后要把用户的答案
|
|
494
|
+
// 送回同一个会话(`--append <session_id>`),而 json 模式下 stderr 保持干净、
|
|
495
|
+
// 拿不到启动提示里的 run_id。init 行在流的最开头就给了 session_id,
|
|
496
|
+
// 所以提问出现时这里一定已经有值(与 KindTurnEnd 带 SessionID 同一取舍)。
|
|
497
|
+
//
|
|
498
|
+
// 顺带也用它给 `OnSessionID` 兜底(见 stream.go handleNDJSONLine 里 OnSessionID 回调)——
|
|
499
|
+
// 在 handleNDJSONLine 收到第一条含 session_id 的 NDJSON 行时就触发,开文件早于第一条事件。
|
|
500
|
+
SessionID string
|
|
501
|
+
|
|
502
|
+
// OnSessionID 见 Request.OnSessionID:可选回调,nil 跳过。
|
|
503
|
+
// CLI 在 runStreamAsk 里 wire 到 sessionWriter 的开文件动作上。
|
|
504
|
+
OnSessionID func(id string)
|
|
505
|
+
|
|
506
|
+
// askSeen 已发过 KindAsk 的请求 key(tool_use_id / request_id / 工具名+入参),用于去重。
|
|
507
|
+
//
|
|
508
|
+
// 为什么必须去重:claude 在 --include-partial-messages 下,同一个提问会**走两条路**
|
|
509
|
+
// 被识别到 —— 一次是 content_block_start/stop 累积出的 tool_use(含完整 questions),
|
|
510
|
+
// 一次是随后那份聚合 assistant 消息(也带完整 input)。只该报一次。
|
|
511
|
+
askSeen map[string]bool
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// toolUseOrphanGuard 安全释放:若 content_block_stop 到来但 openToolName 为空,
|
|
515
|
+
// 不发事件也不入 Tools 列表(防御协议异常)。
|
|
516
|
+
func (a *streamAccumulator) toolUseOrphanGuard() {
|
|
517
|
+
if !a.expectingToolStop || a.openToolName == "" {
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
args := a.openToolArgs.String()
|
|
521
|
+
a.emitKindWithExtras(KindToolUse, args, a.openToolName, a.openToolID)
|
|
522
|
+
a.Tools = append(a.Tools, ToolCall{
|
|
523
|
+
Name: a.openToolName,
|
|
524
|
+
ID: a.openToolID,
|
|
525
|
+
Args: args,
|
|
526
|
+
})
|
|
527
|
+
// 「需要用户选择」:claude/codebuddy 的提问(AskUserQuestion)在 wire 上就是一个
|
|
528
|
+
// tool_use 块,参数(questions)到 stop 时才完整 —— 就在这里归一化并发 KindAsk。
|
|
529
|
+
a.maybeEmitAsk(a.openToolName, json.RawMessage(args), a.openToolID)
|
|
530
|
+
a.openToolName = ""
|
|
531
|
+
a.openToolID = ""
|
|
532
|
+
a.openToolArgs.Reset()
|
|
533
|
+
a.expectingToolStop = false
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// flushToolResult 把累积的 result 文本 emit 为 KindToolResult 并回填 ToolCall。
|
|
537
|
+
func (a *streamAccumulator) flushToolResult(text string) {
|
|
538
|
+
if a.openResultID == "" {
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
a.emitKindWithExtras(KindToolResult, text, "", a.openResultID)
|
|
542
|
+
// 回填到最近一条匹配的 ToolCall。
|
|
543
|
+
for i := len(a.Tools) - 1; i >= 0; i-- {
|
|
544
|
+
if a.Tools[i].ID == a.openResultID && a.Tools[i].Result == "" {
|
|
545
|
+
a.Tools[i].Result = text
|
|
546
|
+
break
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
a.openResultID = ""
|
|
550
|
+
a.expectingResultStart = false
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// emitKindWithExtras 带 Name / ID 字段的 emit 入口(工具事件专用)。
|
|
554
|
+
func (a *streamAccumulator) emitKindWithExtras(kind StreamEventKind, text, name, id string) {
|
|
555
|
+
if a.OnEvent != nil {
|
|
556
|
+
a.OnEvent(StreamEvent{Kind: kind, Text: text, Name: name, ID: id})
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// maybeEmitAsk 若刚完成的工具调用就是「需要用户选择」(AskUserQuestion),
|
|
561
|
+
// 归一化后发一条 KindAsk。非该工具直接返回(绝大多数工具走这里)。
|
|
562
|
+
func (a *streamAccumulator) maybeEmitAsk(toolName string, input json.RawMessage, toolUseID string) {
|
|
563
|
+
if !isAskUserQuestion(toolName) {
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
if req, ok := NormalizeAskToolUse(a.Engine, toolName, input, toolUseID); ok {
|
|
567
|
+
a.emitAsk(req)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// emitAsk 去重后发出 KindAsk 事件(统一格式在 StreamEvent.Ask)。
|
|
572
|
+
//
|
|
573
|
+
// 去重键取 tool_use_id(claude/codebuddy 必有);没有 id 时退到 request_id,
|
|
574
|
+
// 再退到「工具名 + 入参」。同一个提问在 partial 与聚合两条路上各出现一次,只报一次。
|
|
575
|
+
func (a *streamAccumulator) emitAsk(req *AskRequest) {
|
|
576
|
+
if req == nil {
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
key := req.ToolUseID
|
|
580
|
+
if key == "" {
|
|
581
|
+
key = req.RequestID
|
|
582
|
+
}
|
|
583
|
+
if key == "" {
|
|
584
|
+
key = req.ToolName + "|" + string(req.ToolInput)
|
|
585
|
+
}
|
|
586
|
+
if a.askSeen == nil {
|
|
587
|
+
a.askSeen = make(map[string]bool)
|
|
588
|
+
}
|
|
589
|
+
if a.askSeen[key] {
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
a.askSeen[key] = true
|
|
593
|
+
if a.OnEvent != nil {
|
|
594
|
+
a.OnEvent(StreamEvent{
|
|
595
|
+
Kind: KindAsk,
|
|
596
|
+
Text: askSummaryText(req),
|
|
597
|
+
Name: req.ToolName,
|
|
598
|
+
ID: req.ToolUseID,
|
|
599
|
+
// 带上会话 id:宿主据此把用户的答案 `--append` 回同一会话(唯一的作答通道,
|
|
600
|
+
// 见 EncodeAskFollowUp 与 README「需要用户选择」一节)。
|
|
601
|
+
SessionID: a.SessionID,
|
|
602
|
+
Ask: req,
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// handleNDJSONLine 解析一行 NDJSON 流事件,返回是否为 result 收尾行。
|
|
608
|
+
// 兼容 claude/codebuddy(content_block_delta)与 trae(delta.content)两种族谱。
|
|
609
|
+
// claude/codebuddy 同时识别工具事件(content_block_start{type:tool_use|tool_result} +
|
|
610
|
+
// input_json_delta + content_block_stop);trae 协议不暴露这些字段,本分支天然空转。
|
|
611
|
+
func (a *streamAccumulator) handleNDJSONLine(line string) (isResult bool, err error) {
|
|
612
|
+
var probe struct {
|
|
613
|
+
Type string `json:"type"`
|
|
614
|
+
Subtype string `json:"subtype"`
|
|
615
|
+
SessionID string `json:"session_id"`
|
|
616
|
+
}
|
|
617
|
+
if jerr := json.Unmarshal([]byte(line), &probe); jerr != nil {
|
|
618
|
+
return false, nil // 非 JSON 行(杂讯)忽略
|
|
619
|
+
}
|
|
620
|
+
// 顺手记住 session_id(init / assistant / result 行都带):KindAsk 要靠它让宿主
|
|
621
|
+
// 直接把答案追加回同一会话,见 streamAccumulator.SessionID 的说明。
|
|
622
|
+
if probe.SessionID != "" {
|
|
623
|
+
a.SessionID = probe.SessionID
|
|
624
|
+
/* 通知 CLI 「会话 id 已出现」:让 sessionWriter 在写第一条事件之前就能
|
|
625
|
+
开好文件,避免「打开文件前的那条 init 行(带 thinking 起点)全丢」。
|
|
626
|
+
OnSessionID 是可选字段(nil 跳过),老调用方无需改。 */
|
|
627
|
+
if a.OnSessionID != nil {
|
|
628
|
+
a.OnSessionID(probe.SessionID)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
switch probe.Type {
|
|
633
|
+
case "result":
|
|
634
|
+
// 收尾前若有残留 open tool_use(协议异常:没收到 stop),做兜底回收。
|
|
635
|
+
a.toolUseOrphanGuard()
|
|
636
|
+
return true, nil // 全文在 result.result,由调用方自行解析
|
|
637
|
+
|
|
638
|
+
case "stream_event":
|
|
639
|
+
// claude / codebuddy: event.content_block_delta.delta.{thinking_delta,text_delta}
|
|
640
|
+
// + event.content_block_start.content_block.{type,name,id}
|
|
641
|
+
// + event.content_block_stop
|
|
642
|
+
// trae: delta.content 直出增量
|
|
643
|
+
var se struct {
|
|
644
|
+
Event struct {
|
|
645
|
+
Type string `json:"type"`
|
|
646
|
+
ContentBlock struct {
|
|
647
|
+
Type string `json:"type"`
|
|
648
|
+
Name string `json:"name"`
|
|
649
|
+
ID string `json:"id"`
|
|
650
|
+
ToolUseID string `json:"tool_use_id"`
|
|
651
|
+
} `json:"content_block"`
|
|
652
|
+
Index int `json:"index"`
|
|
653
|
+
Delta struct {
|
|
654
|
+
Type string `json:"type"`
|
|
655
|
+
Thinking string `json:"thinking"`
|
|
656
|
+
Text string `json:"text"`
|
|
657
|
+
PartialJSON string `json:"partial_json"`
|
|
658
|
+
} `json:"delta"`
|
|
659
|
+
} `json:"event"`
|
|
660
|
+
// trae: delta.content 直出增量
|
|
661
|
+
Delta2 struct {
|
|
662
|
+
Role string `json:"role"`
|
|
663
|
+
Content string `json:"content"`
|
|
664
|
+
} `json:"delta"`
|
|
665
|
+
}
|
|
666
|
+
if jerr := json.Unmarshal([]byte(line), &se); jerr != nil {
|
|
667
|
+
return false, nil
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
evtType := se.Event.Type
|
|
671
|
+
cbType := se.Event.ContentBlock.Type
|
|
672
|
+
|
|
673
|
+
switch evtType {
|
|
674
|
+
case "content_block_start":
|
|
675
|
+
switch cbType {
|
|
676
|
+
case "tool_use":
|
|
677
|
+
// 防御性:上一个 tool_use 没收到 stop 就来了新的 → 兜底收尾。
|
|
678
|
+
a.toolUseOrphanGuard()
|
|
679
|
+
a.openToolName = se.Event.ContentBlock.Name
|
|
680
|
+
a.openToolID = se.Event.ContentBlock.ID
|
|
681
|
+
a.openToolArgs.Reset()
|
|
682
|
+
a.expectingToolStop = true
|
|
683
|
+
case "tool_result":
|
|
684
|
+
// 收尾当前 tool_use(标准顺序:use → stop → result → stop)。
|
|
685
|
+
a.toolUseOrphanGuard()
|
|
686
|
+
// tool_result 块 ID 字段是 tool_use_id(关联前一个 tool_use)。
|
|
687
|
+
a.openResultID = se.Event.ContentBlock.ToolUseID
|
|
688
|
+
if a.openResultID == "" {
|
|
689
|
+
a.openResultID = se.Event.ContentBlock.ID
|
|
690
|
+
}
|
|
691
|
+
a.expectingResultStart = true
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
case "content_block_delta":
|
|
695
|
+
// 注意:content_block_delta 这一行本身只通过 delta.type 区分
|
|
696
|
+
// (thinking_delta / text_delta / input_json_delta),不会重复
|
|
697
|
+
// 带上 content_block.type。所以路由既要 cbType 也要 d.Type。
|
|
698
|
+
d := se.Event.Delta
|
|
699
|
+
switch {
|
|
700
|
+
case cbType == "tool_use" || d.Type == "input_json_delta":
|
|
701
|
+
// input_json_delta:累积 partial_json 到 argsBuilder。
|
|
702
|
+
if d.PartialJSON != "" {
|
|
703
|
+
a.openToolArgs.WriteString(d.PartialJSON)
|
|
704
|
+
}
|
|
705
|
+
case cbType == "tool_result":
|
|
706
|
+
// tool_result 的 text 字段即工具产出文本。
|
|
707
|
+
if d.Text != "" {
|
|
708
|
+
a.flushToolResult(d.Text)
|
|
709
|
+
}
|
|
710
|
+
case a.openResultID != "" && d.Type == "text_delta" && d.Text != "":
|
|
711
|
+
// tool_result 块由 start/stop 框定,start 后到 stop 前的所有
|
|
712
|
+
// text_delta 都属于 result 产出(cbType 在 delta 行里通常为空,
|
|
713
|
+
// 必须靠 openResultID 状态识别)。
|
|
714
|
+
a.flushToolResult(d.Text)
|
|
715
|
+
case d.Type == "thinking_delta" || d.Thinking != "":
|
|
716
|
+
if d.Thinking != "" {
|
|
717
|
+
a.emit(KindThinking, d.Thinking)
|
|
718
|
+
}
|
|
719
|
+
case d.Type == "text_delta" || d.Text != "":
|
|
720
|
+
if d.Text != "" {
|
|
721
|
+
if cbType == "" || cbType == "text" {
|
|
722
|
+
a.emit(KindText, d.Text)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
case "content_block_stop":
|
|
728
|
+
switch {
|
|
729
|
+
case a.expectingToolStop:
|
|
730
|
+
a.toolUseOrphanGuard()
|
|
731
|
+
case a.expectingResultStart:
|
|
732
|
+
// result 块没有 text_delta(某些实现下)→ 用空串兜底。
|
|
733
|
+
a.flushToolResult("")
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// trae delta.content 直出增量(无 thinking / tool 通道)。
|
|
738
|
+
if c := se.Delta2.Content; c != "" && se.Delta2.Role == "assistant" {
|
|
739
|
+
a.emit(KindText, c)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
case "assistant":
|
|
743
|
+
// 非 partial 的整条 assistant 消息(trae 在 stream_event 之外还会
|
|
744
|
+
// 发一份聚合行;claude 也会)。增量已在 stream_event 覆盖,
|
|
745
|
+
// 这里不重复 emit,只做兜底:全文以 result 行为准。
|
|
746
|
+
//
|
|
747
|
+
// 兜底:assistant 聚合行有时是 tool_use 块(input 已完整)。如果
|
|
748
|
+
// 累积器里还残留 openToolArgs(input_json_delta 累积到 stop 才发),
|
|
749
|
+
// 这里也能补上 id/name(start 已经填了),不会丢工具事件。
|
|
750
|
+
//
|
|
751
|
+
// 另一件只有这里能做的事:**识别「需要用户选择」**。未开
|
|
752
|
+
// --include-partial-messages 时(带附件的非流式路径就是这种)没有
|
|
753
|
+
// content_block 增量,提问只出现在这份聚合行里。两条路都识别 → 靠
|
|
754
|
+
// emitAsk 的 askSeen 去重,不会重复上报。
|
|
755
|
+
if req, ok := ParseAskLine(a.Engine, line); ok {
|
|
756
|
+
a.emitAsk(req)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
case "control_request":
|
|
760
|
+
// SDK 层请求(宿主自己实现了 canUseTool 回调时才会出现):
|
|
761
|
+
// subtype=can_use_tool 即「需要用户选择」——提问或工具待授权,都归一化。
|
|
762
|
+
// 应答要原样带回 request_id(见 EncodeAskControlResponse)。
|
|
763
|
+
// 同层的 rewind / interrupt 等子类型由 ParseAskLine 判掉。
|
|
764
|
+
if req, ok := ParseAskLine(a.Engine, line); ok {
|
|
765
|
+
a.emitAsk(req)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
case "user":
|
|
769
|
+
// claude / codebuddy 的 tool_result 不走独立 content_block,而是包在
|
|
770
|
+
// 一个 user 消息里:
|
|
771
|
+
//
|
|
772
|
+
// {"type":"user","message":{"role":"user","content":[
|
|
773
|
+
// {"tool_use_id":"<id>","type":"tool_result","content":"<text或数组>"}
|
|
774
|
+
// ]}}
|
|
775
|
+
//
|
|
776
|
+
// 这里把 tool_result 文本回填到对应的 ToolCall.Result,并 emit 一条
|
|
777
|
+
// KindToolResult 事件(id 与之关联的 tool_use 相同)。
|
|
778
|
+
a.handleUserToolResult(line)
|
|
779
|
+
}
|
|
780
|
+
return false, nil
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// handleUserToolResult 处理 claude / codebuddy 把 tool_result 嵌入 user 消息的协议路径。
|
|
784
|
+
//
|
|
785
|
+
// 解析 user.message.content 数组,每个元素形如:
|
|
786
|
+
//
|
|
787
|
+
// {"tool_use_id":"<id>","type":"tool_result","content":"<text>"}
|
|
788
|
+
//
|
|
789
|
+
// 或 content 是数组(嵌套 text/image 块);取首段文本作为 result 文本。
|
|
790
|
+
//
|
|
791
|
+
// 命中条件宽松:只要元素里有 tool_use_id + type=tool_result 即视为工具产出。
|
|
792
|
+
// 该路径与 stream_event 里的 content_block{type:tool_result} 互斥(claude 不会
|
|
793
|
+
// 同时走两条路径),故不会重复 emit。
|
|
794
|
+
func (a *streamAccumulator) handleUserToolResult(line string) {
|
|
795
|
+
var msg struct {
|
|
796
|
+
Type string `json:"type"`
|
|
797
|
+
Message struct {
|
|
798
|
+
Role string `json:"role"`
|
|
799
|
+
Content []struct {
|
|
800
|
+
Type string `json:"type"`
|
|
801
|
+
ToolUseID string `json:"tool_use_id"`
|
|
802
|
+
Content json.RawMessage `json:"content"`
|
|
803
|
+
} `json:"content"`
|
|
804
|
+
} `json:"message"`
|
|
805
|
+
}
|
|
806
|
+
if jerr := json.Unmarshal([]byte(line), &msg); jerr != nil {
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
if msg.Type != "user" || msg.Message.Role != "user" {
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
for _, c := range msg.Message.Content {
|
|
813
|
+
if c.Type != "tool_result" || c.ToolUseID == "" {
|
|
814
|
+
continue
|
|
815
|
+
}
|
|
816
|
+
text := extractToolResultText(c.Content)
|
|
817
|
+
// 回填最近一条匹配 id 的 ToolCall.Result。
|
|
818
|
+
for i := len(a.Tools) - 1; i >= 0; i-- {
|
|
819
|
+
if a.Tools[i].ID == c.ToolUseID && a.Tools[i].Result == "" {
|
|
820
|
+
a.Tools[i].Result = text
|
|
821
|
+
break
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// emit KindToolResult 事件(不走基础 emit,必须带 id)。
|
|
825
|
+
a.emitKindWithExtras(KindToolResult, text, "", c.ToolUseID)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// extractToolResultText 从 tool_result.content 字段抽出可读文本。
|
|
830
|
+
//
|
|
831
|
+
// content 可能是:
|
|
832
|
+
// - 字符串:直接返回
|
|
833
|
+
// - 数组:取每个块的 text 字段拼接
|
|
834
|
+
// - null/空:返回空串
|
|
835
|
+
func extractToolResultText(raw json.RawMessage) string {
|
|
836
|
+
if len(raw) == 0 {
|
|
837
|
+
return ""
|
|
838
|
+
}
|
|
839
|
+
var s string
|
|
840
|
+
if err := json.Unmarshal(raw, &s); err == nil {
|
|
841
|
+
return s
|
|
842
|
+
}
|
|
843
|
+
var blocks []struct {
|
|
844
|
+
Type string `json:"type"`
|
|
845
|
+
Text string `json:"text"`
|
|
846
|
+
}
|
|
847
|
+
if err := json.Unmarshal(raw, &blocks); err == nil {
|
|
848
|
+
var sb strings.Builder
|
|
849
|
+
for _, b := range blocks {
|
|
850
|
+
if b.Text != "" {
|
|
851
|
+
if sb.Len() > 0 {
|
|
852
|
+
sb.WriteString("\n")
|
|
853
|
+
}
|
|
854
|
+
sb.WriteString(b.Text)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return sb.String()
|
|
858
|
+
}
|
|
859
|
+
return ""
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// emit 聚合并转发一条增量(基础版本:thinking / text)。
|
|
863
|
+
func (a *streamAccumulator) emit(kind StreamEventKind, text string) {
|
|
864
|
+
switch kind {
|
|
865
|
+
case KindThinking:
|
|
866
|
+
a.Thinking.WriteString(text)
|
|
867
|
+
case KindText:
|
|
868
|
+
a.Text.WriteString(text)
|
|
869
|
+
}
|
|
870
|
+
if a.OnEvent != nil {
|
|
871
|
+
a.OnEvent(StreamEvent{Kind: kind, Text: text})
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// emitTurnEnd 发一轮结束事件:text = 该轮正文(引擎 result 行的 result),
|
|
876
|
+
// sessionID = 该轮的会话 id(见 StreamEvent.SessionID 的说明)。
|
|
877
|
+
func (a *streamAccumulator) emitTurnEnd(text, sessionID string) {
|
|
878
|
+
if a.OnEvent == nil {
|
|
879
|
+
return
|
|
880
|
+
}
|
|
881
|
+
a.OnEvent(StreamEvent{Kind: KindTurnEnd, Text: text, SessionID: sessionID})
|
|
882
|
+
}
|