返回 DeepSeek-Reasonix
run_output.go
根目录 / internal / cli / run_output.go
1 package cli
2
3 import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "strings"
8 "sync"
9 "time"
10
11 "reasonix/internal/event"
12 "reasonix/internal/eventwire"
13 )
14
15 type runOutputFormat string
16
17 const (
18 runOutputText runOutputFormat = "text"
19 runOutputJSON runOutputFormat = "json"
20 runOutputStreamJSON runOutputFormat = "stream-json"
21 runOutputEventsJSONL runOutputFormat = "events-jsonl"
22 )
23
24 func parseRunOutputFormat(value string) (runOutputFormat, error) {
25 switch runOutputFormat(strings.ToLower(strings.TrimSpace(value))) {
26 case runOutputText:
27 return runOutputText, nil
28 case runOutputJSON:
29 return runOutputJSON, nil
30 case runOutputStreamJSON:
31 return runOutputStreamJSON, nil
32 default:
33 return "", fmt.Errorf("unknown output format %q (want text, json, or stream-json)", value)
34 }
35 }
36
37 // runOutputSessionID preserves the established json/stream-json contract while
38 // keeping the redacted events-jsonl surface independent from transcript names.
39 func runOutputSessionID(format runOutputFormat, rawSessionID string, identityKey []byte) string {
40 if format == runOutputEventsJSONL {
41 return machineSessionIDWithKey(rawSessionID, identityKey)
42 }
43 return rawSessionID
44 }
45
46 type runResultUsage struct {
47 InputTokens int `json:"input_tokens"`
48 OutputTokens int `json:"output_tokens"`
49 CacheReadInputTokens int `json:"cache_read_input_tokens"`
50 CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
51 Estimated bool `json:"estimated,omitempty"`
52 }
53
54 type runResult struct {
55 Type string `json:"type"`
56 Subtype string `json:"subtype"`
57 IsError bool `json:"is_error"`
58 DurationMS int64 `json:"duration_ms"`
59 NumTurns int `json:"num_turns"`
60 Result string `json:"result"`
61 SessionID string `json:"session_id,omitempty"`
62 TotalCost float64 `json:"total_cost"`
63 Currency string `json:"currency,omitempty"`
64 // TotalCostUSD is the released compatibility alias. It mirrors TotalCost;
65 // new consumers must pair TotalCost with Currency instead of assuming USD.
66 TotalCostUSD float64 `json:"total_cost_usd"`
67 Usage runResultUsage `json:"usage"`
68 }
69
70 type machineEventUsage struct {
71 InputTokens int `json:"input_tokens"`
72 OutputTokens int `json:"output_tokens"`
73 CacheHitTokens int `json:"cache_hit_tokens"`
74 CacheMissTokens int `json:"cache_miss_tokens"`
75 Estimated bool `json:"estimated,omitempty"`
76 }
77
78 // machineEventRecord is deliberately content-free. The existing stream-json
79 // format is a rich UI transport and includes prompts, tool arguments, results,
80 // and reasoning; this contract is for automation that must not receive them.
81 type machineEventRecord struct {
82 SchemaVersion int `json:"schema_version"`
83 Sequence uint64 `json:"sequence"`
84 Kind string `json:"kind"`
85 Code string `json:"code,omitempty"`
86 Level string `json:"level,omitempty"`
87 ToolID string `json:"tool_id,omitempty"`
88 ToolName string `json:"tool_name,omitempty"`
89 ToolReadOnly bool `json:"tool_read_only,omitempty"`
90 ToolError bool `json:"tool_error,omitempty"`
91 ToolTruncated bool `json:"tool_truncated,omitempty"`
92 ToolDurationMS int64 `json:"tool_duration_ms,omitempty"`
93 Usage *machineEventUsage `json:"usage,omitempty"`
94 ApprovalID string `json:"approval_id,omitempty"`
95 ApprovalKind string `json:"approval_kind,omitempty"`
96 AskID string `json:"ask_id,omitempty"`
97 Outcome string `json:"outcome,omitempty"`
98 Cancelled bool `json:"cancelled,omitempty"`
99 Error bool `json:"error,omitempty"`
100 RetryAttempt int `json:"retry_attempt,omitempty"`
101 RetryMax int `json:"retry_max,omitempty"`
102 CompactionType string `json:"compaction_type,omitempty"`
103 CompactionMsgs int `json:"compaction_messages,omitempty"`
104 GuardianResult string `json:"guardian_result,omitempty"`
105 GuardianRisk string `json:"guardian_risk,omitempty"`
106 }
107
108 type machineRunDone struct {
109 SchemaVersion int `json:"schema_version"`
110 Sequence uint64 `json:"sequence"`
111 Kind string `json:"kind"`
112 SessionID string `json:"session_id,omitempty"`
113 OK bool `json:"ok"`
114 DurationMS int64 `json:"duration_ms"`
115 NumTurns int `json:"num_turns"`
116 Usage machineEventUsage `json:"usage"`
117 }
118
119 type runOutputSink struct {
120 mu sync.Mutex
121 format runOutputFormat
122 out io.Writer
123 encoder *json.Encoder
124 final string
125 usage runResultUsage
126 cost float64
127 currency string
128 mixedCurrencies bool
129 turns int
130 sequence uint64
131 machineToolIDs map[string]string
132 machineToolNames map[string]string
133 nextMachineToolID uint64
134 nextMachineToolName uint64
135 err error
136 }
137
138 func newRunOutputSink(out io.Writer, format runOutputFormat) *runOutputSink {
139 return &runOutputSink{
140 format: format,
141 out: out,
142 encoder: json.NewEncoder(out),
143 machineToolIDs: make(map[string]string),
144 machineToolNames: make(map[string]string),
145 }
146 }
147
148 func (s *runOutputSink) Emit(e event.Event) {
149 s.mu.Lock()
150 defer s.mu.Unlock()
151 if e.Kind == event.Message {
152 s.final = e.Text
153 }
154 if e.Kind == event.Usage && e.Usage != nil {
155 s.usage.InputTokens += e.Usage.PromptTokens
156 s.usage.OutputTokens += e.Usage.CompletionTokens
157 s.usage.CacheReadInputTokens += e.Usage.CacheHitTokens
158 s.usage.CacheCreationInputTokens += e.Usage.CacheMissTokens
159 s.usage.Estimated = s.usage.Estimated || e.Usage.Estimated
160 if e.Pricing != nil {
161 s.cost += e.Pricing.Cost(e.Usage)
162 currency := pricingCurrencyCode(e.Pricing.Currency)
163 if s.currency == "" {
164 s.currency = currency
165 } else if currency != s.currency {
166 s.mixedCurrencies = true
167 }
168 }
169 }
170 if e.Kind == event.TurnDone {
171 s.turns++
172 }
173 if s.format == runOutputStreamJSON && s.err == nil {
174 s.err = s.encoder.Encode(eventwire.ToWire(e))
175 } else if s.format == runOutputEventsJSONL && s.err == nil {
176 s.sequence++
177 s.err = s.encoder.Encode(s.machineEventRecordFor(e, s.sequence))
178 }
179 }
180
181 func (s *runOutputSink) Finalize(sessionID string, started time.Time, runErr error) error {
182 s.mu.Lock()
183 defer s.mu.Unlock()
184 if s.err != nil {
185 return s.err
186 }
187 if s.mixedCurrencies && s.format != runOutputText && s.format != runOutputEventsJSONL {
188 return fmt.Errorf("cannot total costs across mixed pricing currencies")
189 }
190 if s.format == runOutputText {
191 if s.final != "" {
192 _, s.err = fmt.Fprintln(s.out, s.final)
193 }
194 return s.err
195 }
196 completion := classifyRunCompletion(runErr)
197 if s.format == runOutputEventsJSONL {
198 s.sequence++
199 turns := s.turns
200 if turns == 0 && !completion.isError {
201 turns = 1
202 }
203 return s.encoder.Encode(machineRunDone{
204 SchemaVersion: machineSchemaVersion,
205 Sequence: s.sequence,
206 Kind: "run_done",
207 SessionID: sessionID,
208 OK: !completion.isError,
209 DurationMS: time.Since(started).Milliseconds(),
210 NumTurns: turns,
211 Usage: machineEventUsage{InputTokens: s.usage.InputTokens, OutputTokens: s.usage.OutputTokens, CacheHitTokens: s.usage.CacheReadInputTokens, CacheMissTokens: s.usage.CacheCreationInputTokens},
212 })
213 }
214 resultText := s.final
215 if runErr != nil {
216 if resultText == "" {
217 resultText = runErr.Error()
218 }
219 }
220 turns := s.turns
221 if turns == 0 && !completion.isError {
222 turns = 1
223 }
224 return s.encoder.Encode(runResult{
225 Type: "result",
226 Subtype: completion.subtype,
227 IsError: completion.isError,
228 DurationMS: time.Since(started).Milliseconds(),
229 NumTurns: turns,
230 Result: resultText,
231 SessionID: sessionID,
232 TotalCost: s.cost,
233 Currency: s.currency,
234 TotalCostUSD: s.cost,
235 Usage: s.usage,
236 })
237 }
238
239 func pricingCurrencyCode(value string) string {
240 value = strings.TrimSpace(value)
241 switch strings.ToUpper(value) {
242 case "", "CNY", "RMB", "CNH", "¥", "¥":
243 return "CNY"
244 case "USD", "$", "US$":
245 return "USD"
246 default:
247 if len(value) == 3 {
248 return strings.ToUpper(value)
249 }
250 return value
251 }
252 }
253
254 func (s *runOutputSink) machineEventRecordFor(e event.Event, sequence uint64) machineEventRecord {
255 record := machineEventRecord{SchemaVersion: machineSchemaVersion, Sequence: sequence, Kind: machineEventKind(e.Kind)}
256 switch e.Kind {
257 case event.Notice:
258 record.Code = e.Code
259 if e.Level == event.LevelWarn {
260 record.Level = "warn"
261 } else {
262 record.Level = "info"
263 }
264 case event.ToolDispatch, event.ToolResult, event.ToolProgress:
265 // Tool-call IDs and names originate in the provider stream. Treat both as
266 // untrusted content: an OpenAI-compatible endpoint may put arbitrary prompt
267 // or argument text in either field. Per-run opaque aliases preserve event
268 // correlation without exposing the provider-controlled values.
269 record.ToolID = machineOpaqueValue(s.machineToolIDs, &s.nextMachineToolID, "tool", e.Tool.ID)
270 record.ToolName = machineOpaqueValue(s.machineToolNames, &s.nextMachineToolName, "tool_name", e.Tool.Name)
271 record.ToolReadOnly = e.Tool.ReadOnly
272 record.ToolError = e.Tool.Err != ""
273 record.ToolTruncated = e.Tool.Truncated
274 record.ToolDurationMS = e.Tool.DurationMs
275 case event.Usage:
276 if e.Usage != nil {
277 record.Usage = &machineEventUsage{InputTokens: e.Usage.PromptTokens, OutputTokens: e.Usage.CompletionTokens, CacheHitTokens: e.Usage.CacheHitTokens, CacheMissTokens: e.Usage.CacheMissTokens, Estimated: e.Usage.Estimated}
278 }
279 case event.ApprovalRequest:
280 record.ApprovalID = e.Approval.ID
281 record.ApprovalKind = e.Approval.Kind
282 case event.AskRequest:
283 record.AskID = e.Ask.ID
284 case event.TurnDone:
285 record.Outcome = e.Outcome
286 record.Cancelled = e.Cancelled
287 record.Error = e.Err != nil
288 case event.CompactionStarted, event.CompactionDone:
289 record.CompactionType = e.Compaction.Trigger
290 record.CompactionMsgs = e.Compaction.Messages
291 case event.GuardianAssessment:
292 record.GuardianResult = e.Guardian.Outcome
293 record.GuardianRisk = e.Guardian.RiskLevel
294 case event.Retrying:
295 record.RetryAttempt = e.RetryAttempt
296 record.RetryMax = e.RetryMax
297 }
298 return record
299 }
300
301 func machineOpaqueValue(values map[string]string, next *uint64, prefix, value string) string {
302 if value == "" {
303 return ""
304 }
305 if opaque := values[value]; opaque != "" {
306 return opaque
307 }
308 (*next)++
309 opaque := fmt.Sprintf("%s_%d", prefix, *next)
310 values[value] = opaque
311 return opaque
312 }
313
314 func machineEventKind(kind event.Kind) string {
315 names := eventwire.KindNames()
316 if int(kind) >= 0 && int(kind) < len(names) {
317 return names[kind]
318 }
319 return "unknown"
320 }
321
321 lines GO