Module: AgentCliRuntime::OpenCode::ResultParser

Defined in:
lib/agent_cli_runtime/opencode/result_parser.rb

Constant Summary collapse

MAX_RUN_BYTES =
4 * 1024 * 1024
MAX_EXPORT_BYTES =
4 * 1024 * 1024
MAX_FINAL_MESSAGE_BYTES =
1024 * 1024
MAX_EVENTS =
10_000
MAX_UNKNOWN_EVENTS =
16
TERMINAL_REASONS =
%w[stop length content-filter].freeze

Class Method Summary collapse

Class Method Details

.normalize(captured, requested_route:, profile:) ⇒ Object



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/agent_cli_runtime/opencode/result_parser.rb', line 109

def normalize(captured, requested_route:, profile:)
  unless captured.is_a?(CapturedResult)
    raise ArgumentError, "captured must be an AgentCliRuntime::CapturedResult"
  end
  route = requested_route.is_a?(Route) ?
    requested_route : Route.parse(requested_route)
  termination = captured.termination
  return failure_outcome(
    profile, route, termination, :timed_out, "OpenCode run timed out"
  ) if termination.timed_out
  return failure_outcome(
    profile, route, termination, :cancelled, "OpenCode run was cancelled"
  ) if termination.cancelled
  unless termination.success?
    kind, diagnostic = classify_failure(captured)
    return failure_outcome(
      profile, route, termination, kind, diagnostic
    )
  end

  parsed = parse_run(captured.stdout)
  inspection = parse_inspection(
    captured.inspection_output,
    session_id: parsed.session_id,
    message_id: parsed.terminal_message_id
  )
  actual = inspection.fetch(:route)
  NormalizedOutcome.new(
    provider: profile.name,
    launcher_identity: profile.launcher_identity,
    kind: :completed,
    termination: termination,
    final_message: parsed.final_message,
    final_message_truncated: parsed.final_message_truncated,
    identity: RouteIdentity.new(requested: route, actual: actual),
    usage: inspection.fetch(:usage),
    diagnostic: nil,
    unknown_events: parsed.unknown_events,
    session_id: parsed.session_id,
    message_id: parsed.terminal_message_id
  )
rescue MalformedOutput => e
  malformed_outcome(profile, requested_route, captured, e)
end

.parse_inspection(output, session_id:, message_id:) ⇒ Object



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/agent_cli_runtime/opencode/result_parser.rb', line 154

def parse_inspection(output, session_id:, message_id:)
  if output.nil?
    malformed!("OpenCode sanitized export evidence is required")
  end
  bounded_input!(output, MAX_EXPORT_BYTES, "OpenCode sanitized export")
  export = JSON.parse(output)
  malformed!("OpenCode sanitized export must be an object") unless
    export.is_a?(Hash)
  info = required_hash(export, "info", "export info")
  unless required_string(info, "id", "export session id") == session_id
    malformed!("OpenCode sanitized export session does not match the run")
  end
  messages = export["messages"]
  malformed!("OpenCode sanitized export messages must be an array") unless
    messages.is_a?(Array)
  matches = messages.filter_map do |message|
    next unless message.is_a?(Hash) && message["info"].is_a?(Hash)

    record = message.fetch("info")
    next unless record["id"] == message_id

    record
  end
  unless matches.one?
    malformed!("OpenCode sanitized export must contain one terminal assistant record")
  end
  assistant = matches.fetch(0)
  unless assistant["role"] == "assistant" &&
         assistant["sessionID"] == session_id
    malformed!("OpenCode sanitized export terminal record is not correlated")
  end
  unless TERMINAL_REASONS.include?(assistant["finish"])
    malformed!("OpenCode sanitized export terminal record is incomplete")
  end
  provider = required_string(
    assistant, "providerID", "assistant providerID"
  )
  model = required_string(assistant, "modelID", "assistant modelID")
  tokens = assistant["tokens"]
  unless tokens.nil? || tokens.is_a?(Hash)
    malformed!("OpenCode assistant tokens must be an object")
  end
  tokens ||= {}
  cache = tokens["cache"]
  unless cache.nil? || cache.is_a?(Hash)
    malformed!("OpenCode assistant cache tokens must be an object")
  end
  cache ||= {}

  {
    route: Route.new(provider:, model:),
    usage: NormalizedUsage.new(
      input: numeric(tokens, "input", integer: true),
      output: numeric(tokens, "output", integer: true),
      cache_read: numeric(cache, "read", integer: true),
      cache_write: numeric(cache, "write", integer: true),
      reasoning: numeric(tokens, "reasoning", integer: true),
      cost: numeric(assistant, "cost", integer: false)
    )
  }.freeze
rescue JSON::ParserError => e
  raise MalformedOutput,
        Redactor.diagnostic("OpenCode sanitized export is malformed: #{e.message}")
rescue ArgumentError => e
  raise MalformedOutput, Redactor.diagnostic(e)
end

.parse_run(stdout) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/agent_cli_runtime/opencode/result_parser.rb', line 30

def parse_run(stdout)
  bounded_input!(stdout, MAX_RUN_BYTES, "OpenCode run output")
  session_id = nil
  terminal = nil
  texts = []
  unknown = []
  error_seen = false
  event_count = 0

  stdout.each_line.with_index(1) do |line, line_number|
    next if line.strip.empty?

    event_count += 1
    malformed!("OpenCode run output contains too many events") if
      event_count > MAX_EVENTS
    event = parse_json_line(line, line_number)
    type = required_string(event, "type", "event type")
    unless KNOWN_EVENT_TYPES.include?(type)
      additive_session = validate_additive_session!(event, session_id)
      session_id ||= additive_session
      if unknown.length < MAX_UNKNOWN_EVENTS
        unknown << Redactor.diagnostic(
          "unknown OpenCode event #{type}", bytes: 128
        )
      end
      next
    end

    event_session = required_string(event, "sessionID", "event sessionID")
    session_id ||= event_session
    malformed!("OpenCode run sessionID changed within one capture") unless
      session_id == event_session

    if type == "error"
      validate_error!(event)
      error_seen = true
      next
    end

    part = required_hash(event, "part", "event part")
    validate_part!(part, type, session_id)
    message_id = required_string(part, "messageID", "part messageID")
    case type
    when "text"
      text = part["text"]
      malformed!("OpenCode text part must contain text") unless
        text.is_a?(String)
      texts << [ message_id, text ]
    when "step_finish"
      terminal = terminal_part(part, message_id)
    end
  end

  malformed!("OpenCode run emitted an error on a zero exit") if error_seen
  malformed!("OpenCode run has no recognized terminal step") unless terminal
  unless TERMINAL_REASONS.include?(terminal.fetch(:reason))
    malformed!("OpenCode terminal step has an unrecognized finish reason")
  end
  message = texts.filter_map do |message_id, text|
    text if message_id == terminal.fetch(:message_id)
  end.join
  malformed!("OpenCode terminal assistant message is empty") if message.empty?

  final_message_truncated = message.bytesize > MAX_FINAL_MESSAGE_BYTES
  ParsedRun.new(
    session_id: session_id,
    terminal_message_id: terminal.fetch(:message_id),
    terminal_reason: terminal.fetch(:reason),
    final_message: bounded_string(message, MAX_FINAL_MESSAGE_BYTES),
    final_message_truncated: final_message_truncated,
    preliminary_usage: terminal.fetch(:usage),
    unknown_events: unknown.compact
  )
rescue MalformedOutput
  raise
rescue StandardError => e
  raise MalformedOutput, Redactor.diagnostic(e)
end