Module: Rubino::Output::ResultSerializer

Defined in:
lib/rubino/output/result_serializer.rb

Overview

The SINGLE schema home for rubino’s machine-readable output. Both the CLI one-shot path (‘rubino prompt –output-format json|stream-json`) and any server/automation surface call THESE builders, so the field names and shapes never drift between two hand-maintained copies (“less code, less bugs”). Field names follow Claude Code’s so existing tooling transfers:

result frame (json mode, and the terminal line of stream-json):
  {type:"result", subtype:"success"|"error_*", is_error:Bool,
   result:<final text>, session_id:, exit_reason:, num_turns:,
   duration_ms:, usage:{input_tokens, output_tokens,
   cache_creation_input_tokens, cache_read_input_tokens},
   total_cost_usd:, model:}
  On failure it additionally carries top-level error:{type, message}.

stream-json frames (JSONL):
  {type:"system", subtype:"init", session_id:, model:, tools:[...]}
  {type:"assistant", message:{role, content:[ text / tool_use blocks ]}}
  {type:"user", message:{role, content:[ tool_result blocks ]}}
  {type:"result", ...}                     (identical to json mode)

All builders return plain Ruby Hashes; the caller JSON-encodes them. This keeps the module pure and trivially testable.

Constant Summary collapse

EXIT_REASON =

Maps the loop’s normalized stop_reason (Symbol|nil) to Claude Code’s exit_reason vocabulary. nil ⇒ “end_turn” (a clean text completion that never surfaced an explicit finish reason on the streaming path).

{
  stop: "end_turn",
  end_turn: "end_turn",
  length: "max_tokens",
  tool_calls: "tool_use",
  max_iterations: "max_turns"
}.freeze

Class Method Summary collapse

Class Method Details

.arg_error(message:, subtype: "error_invalid_argument", model: nil) ⇒ Object

A self-contained error result for a failure that happens BEFORE any run exists (#327): a bad CLI argument (empty prompt, invalid –output-format) in a json/stream-json mode. There is no recorder/session/usage yet, so this builds the same is_error:true, … shape with zeroed usage and a nil session, so automation parsing ‘–output-format json` gets a JSON error envelope on stdout instead of a bare plain-text line.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/rubino/output/result_serializer.rb', line 114

def arg_error(message:, subtype: "error_invalid_argument", model: nil)
  {
    type: "result",
    subtype: subtype,
    is_error: true,
    result: "",
    session_id: nil,
    exit_reason: subtype,
    num_turns: 0,
    duration_ms: 0,
    usage: zero_usage,
    total_cost_usd: 0.0,
    model: model,
    error: { type: "argument_error", message: message.to_s }
  }
end

.assistant_frame(msg) ⇒ Object



181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/rubino/output/result_serializer.rb', line 181

def assistant_frame(msg)
  content = []
  text = msg.content.to_s
  content << { type: "text", text: text } unless text.empty?
  Array(tool_calls_of(msg)).each do |tc|
    content << {
      type: "tool_use",
      id: tc[:id] || tc["id"],
      name: tc[:name] || tc["name"],
      input: tc[:arguments] || tc["arguments"] || {}
    }
  end
  { type: "assistant", message: { role: "assistant", content: content } }
end

.budget_exhausted?(stop_reason) ⇒ Boolean

True when the loop terminated by EXHAUSTING its tool-iteration budget (–max-turns / agent.max_iterations), not by the model finishing. Such a run is TRUNCATED — the answer is a forced “here’s what I got to” summary, not a real completion — so headless output must flag it is_error:true and exit non-zero (STRUCT-F1), matching Claude Code marking a turn-limit hit as an error rather than success. The Loop emits stop_reason: :max_iterations on the forced-summary MODEL_CALL_FINISHED; the recorder latches it.

Returns:

  • (Boolean)


54
55
56
# File 'lib/rubino/output/result_serializer.rb', line 54

def budget_exhausted?(stop_reason)
  stop_reason&.to_sym == :max_iterations
end

.error_result(recorder:, session:, duration_ms:, model:, error:) ⇒ Object

The failure result object. error carries the error detail:

{ message:, type: "execution_error", subtype: "error_during_execution",
  result_text: "" }

usage/turns reflect whatever was recorded before the failure. session may be nil if the run died before one was built.



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/rubino/output/result_serializer.rb', line 86

def error_result(recorder:, session:, duration_ms:, model:, error:)
  usage   = usage(recorder)
  subtype = error[:subtype] || "error_during_execution"
  {
    type: "result",
    subtype: subtype,
    is_error: true,
    result: error[:result_text].to_s,
    session_id: session && session[:id],
    exit_reason: subtype,
    num_turns: recorder.num_turns,
    duration_ms: duration_ms,
    usage: usage,
    total_cost_usd: Cost.for_usage(
      model_id: model, input_tokens: usage[:input_tokens],
      output_tokens: usage[:output_tokens]
    ),
    model: model,
    error: { type: error[:type] || "execution_error", message: error[:message].to_s }
  }
end

.exit_reason(stop_reason) ⇒ Object



43
44
45
# File 'lib/rubino/output/result_serializer.rb', line 43

def exit_reason(stop_reason)
  EXIT_REASON.fetch(stop_reason&.to_sym, "end_turn")
end

.message_frames(messages) ⇒ Object

Translates the session messages persisted DURING this turn into the ordered stream-json assistant/user frames. Reusing the stored transcript (rather than reconstructing from lossy tool events) gives full, untruncated tool output and correct tool_use↔tool_result call-id pairing.

An assistant row becomes message:{role, content:} with a leading text block (when it has prose) followed by one tool_use block per persisted tool call. Each ‘tool` row becomes a message:{role:“user”, content:[tool_result block]} — mirroring the Messages API, where tool results are user-role content.

messages is the array of Session::Message for the turn, in order.



172
173
174
175
176
177
178
179
# File 'lib/rubino/output/result_serializer.rb', line 172

def message_frames(messages)
  Array(messages).filter_map do |msg|
    case msg.role
    when "assistant" then assistant_frame(msg)
    when "tool"      then tool_result_frame(msg)
    end
  end
end

.result(recorder:, final_text:, session:, duration_ms:, model:) ⇒ Object

The success result object. recorder is a TurnRecorder (usage/turns/ stop_reason); final_text the assistant’s final answer; session the runner’s session hash; duration_ms the wall-clock turn time.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/rubino/output/result_serializer.rb', line 61

def result(recorder:, final_text:, session:, duration_ms:, model:)
  usage = usage(recorder)
  {
    type: "result",
    subtype: "success",
    is_error: false,
    result: final_text.to_s,
    session_id: session && session[:id],
    exit_reason: exit_reason(recorder.stop_reason),
    num_turns: recorder.num_turns,
    duration_ms: duration_ms,
    usage: usage,
    total_cost_usd: Cost.for_usage(
      model_id: model, input_tokens: usage[:input_tokens],
      output_tokens: usage[:output_tokens]
    ),
    model: model
  }
end

.system_init(session:, model:, tools:) ⇒ Object

The opening system/init line.



150
151
152
153
154
155
156
157
158
# File 'lib/rubino/output/result_serializer.rb', line 150

def system_init(session:, model:, tools:)
  {
    type: "system",
    subtype: "init",
    session_id: session && session[:id],
    model: model,
    tools: Array(tools).map { |t| tool_name(t) }
  }
end

.tool_calls_of(msg) ⇒ Object



210
211
212
213
214
215
# File 'lib/rubino/output/result_serializer.rb', line 210

def tool_calls_of(msg)
  meta = msg.
  return [] unless meta.is_a?(Hash)

  meta[:tool_calls] || meta["tool_calls"] || []
end

.tool_name(tool) ⇒ Object



217
218
219
# File 'lib/rubino/output/result_serializer.rb', line 217

def tool_name(tool)
  tool.respond_to?(:name) ? tool.name.to_s : tool.to_s
end

.tool_result_frame(msg) ⇒ Object



196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/rubino/output/result_serializer.rb', line 196

def tool_result_frame(msg)
  {
    type: "user",
    message: {
      role: "user",
      content: [{
        type: "tool_result",
        tool_use_id: msg.tool_call_id,
        content: msg.content.to_s
      }]
    }
  }
end

.usage(recorder) ⇒ Object



138
139
140
141
142
143
144
145
# File 'lib/rubino/output/result_serializer.rb', line 138

def usage(recorder)
  {
    input_tokens: recorder.input_tokens,
    output_tokens: recorder.output_tokens,
    cache_creation_input_tokens: recorder.cache_creation_input_tokens,
    cache_read_input_tokens: recorder.cache_read_input_tokens
  }
end

.zero_usageObject



131
132
133
134
135
136
# File 'lib/rubino/output/result_serializer.rb', line 131

def zero_usage
  {
    input_tokens: 0, output_tokens: 0,
    cache_creation_input_tokens: 0, cache_read_input_tokens: 0
  }
end