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
-
.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.
- .assistant_frame(msg) ⇒ Object
-
.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.
-
.error_result(recorder:, session:, duration_ms:, model:, error:) ⇒ Object
The failure result object.
- .exit_reason(stop_reason) ⇒ Object
-
.message_frames(messages) ⇒ Object
Translates the session messages persisted DURING this turn into the ordered stream-json assistant/user frames.
-
.result(recorder:, final_text:, session:, duration_ms:, model:) ⇒ Object
The success result object.
-
.system_init(session:, model:, tools:) ⇒ Object
The opening system/init line.
- .tool_calls_of(msg) ⇒ Object
- .tool_name(tool) ⇒ Object
- .tool_result_frame(msg) ⇒ Object
- .usage(recorder) ⇒ Object
- .zero_usage ⇒ Object
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: .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.
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 () Array().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) = msg. return [] unless .is_a?(Hash) [:tool_calls] || ["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_usage ⇒ Object
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 |