1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8
9 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/provider"
11)
12
13// RunJSON runs the agent to completion, writing one JSON object per
14// AgentEvent as newline-delimited JSON.
15func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error {
16 _, err := RunJSONWithContextRecovery(ctx, ag, prompt, images, out, nil)
17 return err
18}
19
20// RunJSONWithContextRecovery is RunJSON with an optional checkpoint writer
21// for a one-shot context-overflow compaction. It suppresses the recoverable
22// first turn_end so stdout never contains a terminal error frame before the
23// continued turn succeeds. OutputStart identifies the suffix to persist after
24// a successful checkpoint.
25func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {
26 enc := json.NewEncoder(out)
27 write := func(v any) {
28 _ = enc.Encode(v)
29 }
30
31 var runErr error
32 sink := func(ev core.AgentEvent) {
33 write(EventToJSON(ev))
34 }
35
36 recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{
37 PersistCompaction: persistCompaction,
38 SuppressInitialOverflowEvent: true,
39 })
40 if err != nil {
41 runErr = err
42 }
43
44 if runErr != nil {
45 fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`)
46 }
47 return recovery, runErr
48}
49
50// EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
51// schema is deliberately simple and flat. Exported so the RPC mode can
52// reuse the same serialisation as `zut --json`.
53func EventToJSON(ev core.AgentEvent) map[string]any {
54 m := map[string]any{"type": ev.Type()}
55 switch e := ev.(type) {
56 case core.EvTurnStart:
57 m["step"] = e.Step
58 case core.EvRequestStarted:
59 m["provider"] = e.Provider
60 m["model"] = e.Model
61 m["scope"] = string(e.Scope)
62 m["attempt"] = e.Attempt
63 m["max_attempts"] = e.MaxAttempts
64 case core.EvRetryScheduled:
65 m["scope"] = string(e.Scope)
66 m["attempt"] = e.Attempt
67 m["max_attempts"] = e.MaxAttempts
68 m["delay_ms"] = e.Delay.Milliseconds()
69 case core.EvUserMessage:
70 m["content"] = ContentToJSON(e.Message.Content)
71 m["time"] = e.Message.Time
72 case core.EvAssistantMessage:
73 m["content"] = ContentToJSON(e.Message.Content)
74 m["time"] = e.Message.Time
75 case core.EvTextDelta:
76 m["delta"] = e.Delta
77 case core.EvToolUseStart:
78 m["id"] = e.ID
79 m["name"] = e.Name
80 case core.EvToolUseArgs:
81 m["id"] = e.ID
82 m["delta"] = e.Delta
83 case core.EvToolUseEnd:
84 m["id"] = e.ID
85 case core.EvToolCall:
86 m["id"] = e.ID
87 m["name"] = e.Name
88 var args any
89 _ = json.Unmarshal(e.Args, &args)
90 m["args"] = args
91 case core.EvToolExecutionStarted:
92 m["id"] = e.ID
93 m["name"] = e.Name
94 case core.EvToolProgress:
95 m["id"] = e.ID
96 m["text"] = e.Text
97 case core.EvToolResult:
98 m["id"] = e.ID
99 m["is_error"] = e.Result.IsError
100 m["content"] = ContentToJSON(e.Result.Content)
101 case core.EvUsage:
102 m["input"] = e.Usage.InputTokens
103 m["output"] = e.Usage.OutputTokens
104 m["reasoning"] = nullableReasoningTokens(e.Usage)
105 m["cache_read"] = e.Usage.CacheReadTokens
106 m["cache_write"] = e.Usage.CacheWriteTokens
107 m["cost_usd"] = e.Usage.CostUSD
108 m["cumulative"] = map[string]any{
109 "input": e.Cumulative.InputTokens,
110 "output": e.Cumulative.OutputTokens,
111 "reasoning": nullableReasoningTokens(e.Cumulative),
112 "cache_read": e.Cumulative.CacheReadTokens,
113 "cache_write": e.Cumulative.CacheWriteTokens,
114 "cost_usd": e.Cumulative.CostUSD,
115 }
116 case core.EvTurnEnd:
117 m["stop"] = string(e.Stop)
118 if e.Err != nil {
119 m["error"] = e.Err.Error()
120 }
121 }
122 return m
123}
124
125// ContentToJSON serialises a transcript content slice into the same
126// shape used in EventToJSON. Exported alongside EventToJSON for the
127// RPC mode.
128func ContentToJSON(blocks []provider.Content) []map[string]any {
129 out := make([]map[string]any, 0, len(blocks))
130 for _, b := range blocks {
131 switch v := b.(type) {
132 case provider.TextBlock:
133 out = append(out, map[string]any{"type": "text", "text": v.Text})
134 case provider.ImageBlock:
135 out = append(out, map[string]any{"type": "image", "mime_type": v.MimeType, "bytes": len(v.Data)})
136 case provider.ToolCallBlock:
137 var args any
138 _ = json.Unmarshal(v.Arguments, &args)
139 out = append(out, map[string]any{"type": "tool_call", "id": v.ID, "name": v.Name, "args": args})
140 case provider.ToolResultBlock:
141 out = append(out, map[string]any{
142 "type": "tool_result",
143 "call_id": v.CallID,
144 "is_error": v.IsError,
145 "content": ContentToJSON(v.Content),
146 })
147 }
148 }
149 return out
150}
151
152func nullableReasoningTokens(usage provider.Usage) any {
153 if !usage.ReasoningTokensKnown {
154 return nil
155 }
156 return usage.ReasoningTokens
157}
158
159func jsonString(s string) string {
160 b, _ := json.Marshal(s)
161 return string(b)
162}