agent-cli-runtime 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +139 -7
- data/agent-cli-runtime.gemspec +3 -2
- data/lib/agent_cli_runtime/errors.rb +7 -0
- data/lib/agent_cli_runtime/opencode/inspection.rb +32 -0
- data/lib/agent_cli_runtime/opencode/overlay.rb +573 -0
- data/lib/agent_cli_runtime/opencode/probe.rb +280 -0
- data/lib/agent_cli_runtime/opencode/result_parser.rb +402 -0
- data/lib/agent_cli_runtime/profile.rb +36 -2
- data/lib/agent_cli_runtime/profiles.rb +70 -1
- data/lib/agent_cli_runtime/runtime.rb +16 -2
- data/lib/agent_cli_runtime/values.rb +432 -0
- data/lib/agent_cli_runtime/version.rb +1 -1
- data/lib/agent_cli_runtime.rb +23 -3
- metadata +8 -3
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module AgentCliRuntime
|
|
4
|
+
module OpenCode
|
|
5
|
+
module Probe
|
|
6
|
+
REQUIRED_RUN_FLAGS = %w[
|
|
7
|
+
--model --variant --format --dir --pure --auto
|
|
8
|
+
].freeze
|
|
9
|
+
SAFE_ENVIRONMENT_KEYS = %w[
|
|
10
|
+
HOME LANG LC_ALL LOGNAME PATH SHELL SSL_CERT_DIR SSL_CERT_FILE
|
|
11
|
+
TMPDIR USER
|
|
12
|
+
].freeze
|
|
13
|
+
ANSI_PATTERN = /\e\[[0-?]*[ -\/]*[@-~]/
|
|
14
|
+
private_constant :SAFE_ENVIRONMENT_KEYS, :ANSI_PATTERN
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(request, env: ENV)
|
|
19
|
+
call!(request, env:)
|
|
20
|
+
rescue Error => e
|
|
21
|
+
profile = Profiles.resolve(request.profile)
|
|
22
|
+
executable = profile.bin(env:)
|
|
23
|
+
RouteProbeResult.new(
|
|
24
|
+
provider: profile.name,
|
|
25
|
+
ready: false,
|
|
26
|
+
installed: profile.binary_installed?(env:),
|
|
27
|
+
executable: executable,
|
|
28
|
+
version: nil,
|
|
29
|
+
minimum_version: profile.min_version,
|
|
30
|
+
auth_configuration: AuthConfiguration.new(status: :not_checked),
|
|
31
|
+
route: request.route,
|
|
32
|
+
route_available: false,
|
|
33
|
+
available_variants: [],
|
|
34
|
+
capability_evidence: [
|
|
35
|
+
CapabilityEvidence.new(
|
|
36
|
+
capability: error_capability(e), supported: false,
|
|
37
|
+
provider: profile.name,
|
|
38
|
+
launcher_identity: profile.launcher_identity,
|
|
39
|
+
diagnostic: Redactor.diagnostic(e)
|
|
40
|
+
)
|
|
41
|
+
],
|
|
42
|
+
diagnostic: Redactor.diagnostic(e)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call!(request, env: ENV)
|
|
47
|
+
unless request.is_a?(ProbeRequest)
|
|
48
|
+
raise ArgumentError,
|
|
49
|
+
"request must be an AgentCliRuntime::ProbeRequest"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
profile = Profiles.resolve(request.profile)
|
|
53
|
+
unless profile.name == :opencode
|
|
54
|
+
raise ArgumentError,
|
|
55
|
+
"route-aware ProbeRequest currently requires profile :opencode"
|
|
56
|
+
end
|
|
57
|
+
installed = profile.binary_installed?(env:)
|
|
58
|
+
unless installed
|
|
59
|
+
raise BinaryUnavailable,
|
|
60
|
+
"opencode binary not runnable: #{profile.bin(env:)}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
child_env = child_environment(profile, request, env:)
|
|
64
|
+
version = profile.check_version!(env: child_env)
|
|
65
|
+
evidence = [
|
|
66
|
+
evidence(profile, :installation),
|
|
67
|
+
evidence(profile, :version, [ version ])
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
run_help = capture!(profile, child_env, "run", "--help")
|
|
71
|
+
missing = REQUIRED_RUN_FLAGS.reject { |flag| advertised?(run_help, flag) }
|
|
72
|
+
unless missing.empty?
|
|
73
|
+
raise UnsupportedCapability,
|
|
74
|
+
"OpenCode run is missing required capability #{missing.join(', ')}"
|
|
75
|
+
end
|
|
76
|
+
evidence.concat(REQUIRED_RUN_FLAGS.map do |flag|
|
|
77
|
+
evidence(profile, capability_for(flag), [ flag ])
|
|
78
|
+
end)
|
|
79
|
+
|
|
80
|
+
export_help = capture!(profile, child_env, "export", "--help")
|
|
81
|
+
unless advertised?(export_help, "--sanitize")
|
|
82
|
+
raise UnsupportedCapability,
|
|
83
|
+
"OpenCode export is missing required --sanitize capability"
|
|
84
|
+
end
|
|
85
|
+
evidence << evidence(profile, :sanitized_export, [ "--sanitize" ])
|
|
86
|
+
|
|
87
|
+
auth_output = capture!(profile, child_env, "auth", "list")
|
|
88
|
+
configured_key = request.credential_environment_keys.find do |key|
|
|
89
|
+
!env[key].to_s.empty?
|
|
90
|
+
end
|
|
91
|
+
auth_from_inventory =
|
|
92
|
+
normalized_output(auth_output).match?(
|
|
93
|
+
/(?:\A|[^A-Za-z0-9_.-])#{Regexp.escape(request.route.provider)}(?:\z|[^A-Za-z0-9_.-])/
|
|
94
|
+
)
|
|
95
|
+
unless configured_key || request.credential_file_staged ||
|
|
96
|
+
auth_from_inventory
|
|
97
|
+
raise AuthenticationError,
|
|
98
|
+
"OpenCode authentication source is missing for requested provider"
|
|
99
|
+
end
|
|
100
|
+
auth = AuthConfiguration.new(
|
|
101
|
+
status: :configured,
|
|
102
|
+
source:
|
|
103
|
+
configured_key ? "selected environment" :
|
|
104
|
+
(request.credential_file_staged ? "staged auth file" :
|
|
105
|
+
"local auth inventory")
|
|
106
|
+
)
|
|
107
|
+
evidence << evidence(profile, :auth_configuration)
|
|
108
|
+
|
|
109
|
+
models_output = capture!(
|
|
110
|
+
profile, child_env, "models", request.route.provider, "--verbose"
|
|
111
|
+
)
|
|
112
|
+
variants = variants_for(models_output, request.route.to_s)
|
|
113
|
+
if variants.nil?
|
|
114
|
+
raise RouteUnavailable,
|
|
115
|
+
"requested OpenCode route is unavailable in the local model inventory"
|
|
116
|
+
end
|
|
117
|
+
if request.variant && !variants.include?(request.variant)
|
|
118
|
+
raise RouteUnavailable,
|
|
119
|
+
"requested OpenCode variant is unavailable for the exact route"
|
|
120
|
+
end
|
|
121
|
+
evidence << evidence(profile, :model_route, [ request.route.to_s ])
|
|
122
|
+
evidence << evidence(profile, :model_variant, [ request.variant ]) if
|
|
123
|
+
request.variant
|
|
124
|
+
|
|
125
|
+
RouteProbeResult.new(
|
|
126
|
+
provider: profile.name,
|
|
127
|
+
ready: true,
|
|
128
|
+
installed: true,
|
|
129
|
+
executable: profile.bin(env: child_env),
|
|
130
|
+
version: version,
|
|
131
|
+
minimum_version: profile.min_version,
|
|
132
|
+
auth_configuration: auth,
|
|
133
|
+
route: request.route,
|
|
134
|
+
route_available: true,
|
|
135
|
+
available_variants: variants,
|
|
136
|
+
capability_evidence: evidence,
|
|
137
|
+
diagnostic: nil
|
|
138
|
+
)
|
|
139
|
+
rescue UnknownProvider
|
|
140
|
+
raise
|
|
141
|
+
rescue Error
|
|
142
|
+
raise
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
raise ProbeError, Redactor.diagnostic(e)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def child_environment(profile, request, env:)
|
|
148
|
+
selected = (ENV.keys | env.keys).to_h { |key| [ key, nil ] }
|
|
149
|
+
env.each do |key, value|
|
|
150
|
+
if SAFE_ENVIRONMENT_KEYS.include?(key) || key.start_with?("LC_") ||
|
|
151
|
+
key.start_with?("MISE_")
|
|
152
|
+
selected[key] = value.to_s
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
%w[
|
|
156
|
+
BUNDLE_BIN_PATH BUNDLE_GEMFILE GEM_HOME GEM_PATH RUBYLIB RUBYOPT
|
|
157
|
+
].each { |key| selected[key] = nil }
|
|
158
|
+
profile.env_bin_override_keys.each do |key|
|
|
159
|
+
selected[key] = env[key].to_s unless env[key].to_s.empty?
|
|
160
|
+
end
|
|
161
|
+
request.credential_environment_keys.each do |key|
|
|
162
|
+
selected[key] = env[key].to_s unless env[key].to_s.empty?
|
|
163
|
+
end
|
|
164
|
+
selected.merge(request.environment).freeze
|
|
165
|
+
end
|
|
166
|
+
private_class_method :child_environment
|
|
167
|
+
|
|
168
|
+
def capture!(profile, environment, *arguments)
|
|
169
|
+
out, err, status = profile.capture_local(*arguments, env: environment)
|
|
170
|
+
unless status.success?
|
|
171
|
+
diagnostic = normalized_output("#{err}\n#{out}")
|
|
172
|
+
raise ConfigurationError,
|
|
173
|
+
diagnostic.empty? ?
|
|
174
|
+
"OpenCode local inspection command failed" : diagnostic
|
|
175
|
+
end
|
|
176
|
+
"#{out}\n#{err}"
|
|
177
|
+
rescue Errno::ENOENT, Errno::EACCES, Timeout::Error => e
|
|
178
|
+
raise BinaryUnavailable,
|
|
179
|
+
"OpenCode local inspection command could not run (#{e.class.name.split('::').last})"
|
|
180
|
+
end
|
|
181
|
+
private_class_method :capture!
|
|
182
|
+
|
|
183
|
+
def advertised?(help, flag)
|
|
184
|
+
help.match?(/(?:\A|[\s,])#{Regexp.escape(flag)}(?:[\s,=\[]|$)/)
|
|
185
|
+
end
|
|
186
|
+
private_class_method :advertised?
|
|
187
|
+
|
|
188
|
+
def variants_for(output, route)
|
|
189
|
+
text = normalized_output(output)
|
|
190
|
+
lines = text.lines
|
|
191
|
+
index = lines.index { |line| line.strip == route }
|
|
192
|
+
return nil unless index
|
|
193
|
+
|
|
194
|
+
suffix = lines.drop(index + 1).join
|
|
195
|
+
object = first_json_object(suffix)
|
|
196
|
+
return [] unless object
|
|
197
|
+
|
|
198
|
+
parsed = JSON.parse(object)
|
|
199
|
+
variants = parsed["variants"]
|
|
200
|
+
variants.is_a?(Hash) ? variants.keys.sort.freeze : [].freeze
|
|
201
|
+
rescue JSON::ParserError
|
|
202
|
+
raise ConfigurationError,
|
|
203
|
+
"OpenCode local model inventory is malformed"
|
|
204
|
+
end
|
|
205
|
+
private_class_method :variants_for
|
|
206
|
+
|
|
207
|
+
def first_json_object(text)
|
|
208
|
+
start = text.index("{")
|
|
209
|
+
return nil unless start
|
|
210
|
+
|
|
211
|
+
depth = 0
|
|
212
|
+
quoted = false
|
|
213
|
+
escaped = false
|
|
214
|
+
text.each_char.with_index do |character, index|
|
|
215
|
+
next if index < start
|
|
216
|
+
|
|
217
|
+
if quoted
|
|
218
|
+
if escaped
|
|
219
|
+
escaped = false
|
|
220
|
+
elsif character == "\\"
|
|
221
|
+
escaped = true
|
|
222
|
+
elsif character == '"'
|
|
223
|
+
quoted = false
|
|
224
|
+
end
|
|
225
|
+
next
|
|
226
|
+
end
|
|
227
|
+
if character == '"'
|
|
228
|
+
quoted = true
|
|
229
|
+
elsif character == "{"
|
|
230
|
+
depth += 1
|
|
231
|
+
elsif character == "}"
|
|
232
|
+
depth -= 1
|
|
233
|
+
return text[start..index] if depth.zero?
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
private_class_method :first_json_object
|
|
239
|
+
|
|
240
|
+
def normalized_output(value)
|
|
241
|
+
value.to_s.gsub(ANSI_PATTERN, "").strip
|
|
242
|
+
end
|
|
243
|
+
private_class_method :normalized_output
|
|
244
|
+
|
|
245
|
+
def evidence(profile, capability, arguments = [])
|
|
246
|
+
CapabilityEvidence.new(
|
|
247
|
+
capability: capability,
|
|
248
|
+
supported: true,
|
|
249
|
+
provider: profile.name,
|
|
250
|
+
launcher_identity: profile.launcher_identity,
|
|
251
|
+
arguments: arguments.compact
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
private_class_method :evidence
|
|
255
|
+
|
|
256
|
+
def capability_for(flag)
|
|
257
|
+
{
|
|
258
|
+
"--model" => :model,
|
|
259
|
+
"--variant" => :model_variant,
|
|
260
|
+
"--format" => :json_events,
|
|
261
|
+
"--dir" => :working_directory,
|
|
262
|
+
"--pure" => :pure,
|
|
263
|
+
"--auto" => :permission_enforcement
|
|
264
|
+
}.fetch(flag)
|
|
265
|
+
end
|
|
266
|
+
private_class_method :capability_for
|
|
267
|
+
|
|
268
|
+
def error_capability(error)
|
|
269
|
+
case error
|
|
270
|
+
when AuthenticationError then :auth_configuration
|
|
271
|
+
when RouteUnavailable then :model_route
|
|
272
|
+
when UnsupportedCapability then :cli_capability
|
|
273
|
+
when BinaryUnavailable then :installation
|
|
274
|
+
else :probe
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
private_class_method :error_capability
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module AgentCliRuntime
|
|
4
|
+
module OpenCode
|
|
5
|
+
module ResultParser
|
|
6
|
+
MAX_RUN_BYTES = 4 * 1024 * 1024
|
|
7
|
+
MAX_EXPORT_BYTES = 4 * 1024 * 1024
|
|
8
|
+
MAX_FINAL_MESSAGE_BYTES = 1024 * 1024
|
|
9
|
+
MAX_EVENTS = 10_000
|
|
10
|
+
MAX_UNKNOWN_EVENTS = 16
|
|
11
|
+
TERMINAL_REASONS = %w[stop length content-filter].freeze
|
|
12
|
+
KNOWN_EVENT_TYPES = %w[
|
|
13
|
+
step_start step_finish text reasoning tool_use error
|
|
14
|
+
].freeze
|
|
15
|
+
EVENT_PART_TYPES = {
|
|
16
|
+
"step_start" => "step-start",
|
|
17
|
+
"step_finish" => "step-finish",
|
|
18
|
+
"text" => "text",
|
|
19
|
+
"reasoning" => "reasoning",
|
|
20
|
+
"tool_use" => "tool"
|
|
21
|
+
}.freeze
|
|
22
|
+
AUTH_PATTERN = /auth|credential|api[ _-]?key|unauthorized|forbidden/i
|
|
23
|
+
CONFIGURATION_PATTERN =
|
|
24
|
+
/\b(?:Config(?:uration)?Error|UnknownProvider|UnknownModel|ModelNotFound|RouteUnavailable|VariantUnavailable)\b|\b(?:invalid|unknown|unsupported) (?:configuration|provider|model|route|variant)\b|\b(?:provider|model|route|variant)(?: [^\n]+)? (?:not found|unavailable)\b/i
|
|
25
|
+
private_constant :KNOWN_EVENT_TYPES, :EVENT_PART_TYPES,
|
|
26
|
+
:AUTH_PATTERN, :CONFIGURATION_PATTERN
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def parse_run(stdout)
|
|
31
|
+
bounded_input!(stdout, MAX_RUN_BYTES, "OpenCode run output")
|
|
32
|
+
session_id = nil
|
|
33
|
+
terminal = nil
|
|
34
|
+
texts = []
|
|
35
|
+
unknown = []
|
|
36
|
+
error_seen = false
|
|
37
|
+
event_count = 0
|
|
38
|
+
|
|
39
|
+
stdout.each_line.with_index(1) do |line, line_number|
|
|
40
|
+
next if line.strip.empty?
|
|
41
|
+
|
|
42
|
+
event_count += 1
|
|
43
|
+
malformed!("OpenCode run output contains too many events") if
|
|
44
|
+
event_count > MAX_EVENTS
|
|
45
|
+
event = parse_json_line(line, line_number)
|
|
46
|
+
type = required_string(event, "type", "event type")
|
|
47
|
+
unless KNOWN_EVENT_TYPES.include?(type)
|
|
48
|
+
additive_session = validate_additive_session!(event, session_id)
|
|
49
|
+
session_id ||= additive_session
|
|
50
|
+
if unknown.length < MAX_UNKNOWN_EVENTS
|
|
51
|
+
unknown << Redactor.diagnostic(
|
|
52
|
+
"unknown OpenCode event #{type}", bytes: 128
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
event_session = required_string(event, "sessionID", "event sessionID")
|
|
59
|
+
session_id ||= event_session
|
|
60
|
+
malformed!("OpenCode run sessionID changed within one capture") unless
|
|
61
|
+
session_id == event_session
|
|
62
|
+
|
|
63
|
+
if type == "error"
|
|
64
|
+
validate_error!(event)
|
|
65
|
+
error_seen = true
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
part = required_hash(event, "part", "event part")
|
|
70
|
+
validate_part!(part, type, session_id)
|
|
71
|
+
message_id = required_string(part, "messageID", "part messageID")
|
|
72
|
+
case type
|
|
73
|
+
when "text"
|
|
74
|
+
text = part["text"]
|
|
75
|
+
malformed!("OpenCode text part must contain text") unless
|
|
76
|
+
text.is_a?(String)
|
|
77
|
+
texts << [ message_id, text ]
|
|
78
|
+
when "step_finish"
|
|
79
|
+
terminal = terminal_part(part, message_id)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
malformed!("OpenCode run emitted an error on a zero exit") if error_seen
|
|
84
|
+
malformed!("OpenCode run has no recognized terminal step") unless terminal
|
|
85
|
+
unless TERMINAL_REASONS.include?(terminal.fetch(:reason))
|
|
86
|
+
malformed!("OpenCode terminal step has an unrecognized finish reason")
|
|
87
|
+
end
|
|
88
|
+
message = texts.filter_map do |message_id, text|
|
|
89
|
+
text if message_id == terminal.fetch(:message_id)
|
|
90
|
+
end.join
|
|
91
|
+
malformed!("OpenCode terminal assistant message is empty") if message.empty?
|
|
92
|
+
|
|
93
|
+
final_message_truncated = message.bytesize > MAX_FINAL_MESSAGE_BYTES
|
|
94
|
+
ParsedRun.new(
|
|
95
|
+
session_id: session_id,
|
|
96
|
+
terminal_message_id: terminal.fetch(:message_id),
|
|
97
|
+
terminal_reason: terminal.fetch(:reason),
|
|
98
|
+
final_message: bounded_string(message, MAX_FINAL_MESSAGE_BYTES),
|
|
99
|
+
final_message_truncated: final_message_truncated,
|
|
100
|
+
preliminary_usage: terminal.fetch(:usage),
|
|
101
|
+
unknown_events: unknown.compact
|
|
102
|
+
)
|
|
103
|
+
rescue MalformedOutput
|
|
104
|
+
raise
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
raise MalformedOutput, Redactor.diagnostic(e)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def normalize(captured, requested_route:, profile:)
|
|
110
|
+
unless captured.is_a?(CapturedResult)
|
|
111
|
+
raise ArgumentError, "captured must be an AgentCliRuntime::CapturedResult"
|
|
112
|
+
end
|
|
113
|
+
route = requested_route.is_a?(Route) ?
|
|
114
|
+
requested_route : Route.parse(requested_route)
|
|
115
|
+
termination = captured.termination
|
|
116
|
+
return failure_outcome(
|
|
117
|
+
profile, route, termination, :timed_out, "OpenCode run timed out"
|
|
118
|
+
) if termination.timed_out
|
|
119
|
+
return failure_outcome(
|
|
120
|
+
profile, route, termination, :cancelled, "OpenCode run was cancelled"
|
|
121
|
+
) if termination.cancelled
|
|
122
|
+
unless termination.success?
|
|
123
|
+
kind, diagnostic = classify_failure(captured)
|
|
124
|
+
return failure_outcome(
|
|
125
|
+
profile, route, termination, kind, diagnostic
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
parsed = parse_run(captured.stdout)
|
|
130
|
+
inspection = parse_inspection(
|
|
131
|
+
captured.inspection_output,
|
|
132
|
+
session_id: parsed.session_id,
|
|
133
|
+
message_id: parsed.terminal_message_id
|
|
134
|
+
)
|
|
135
|
+
actual = inspection.fetch(:route)
|
|
136
|
+
NormalizedOutcome.new(
|
|
137
|
+
provider: profile.name,
|
|
138
|
+
launcher_identity: profile.launcher_identity,
|
|
139
|
+
kind: :completed,
|
|
140
|
+
termination: termination,
|
|
141
|
+
final_message: parsed.final_message,
|
|
142
|
+
final_message_truncated: parsed.final_message_truncated,
|
|
143
|
+
identity: RouteIdentity.new(requested: route, actual: actual),
|
|
144
|
+
usage: inspection.fetch(:usage),
|
|
145
|
+
diagnostic: nil,
|
|
146
|
+
unknown_events: parsed.unknown_events,
|
|
147
|
+
session_id: parsed.session_id,
|
|
148
|
+
message_id: parsed.terminal_message_id
|
|
149
|
+
)
|
|
150
|
+
rescue MalformedOutput => e
|
|
151
|
+
malformed_outcome(profile, requested_route, captured, e)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_inspection(output, session_id:, message_id:)
|
|
155
|
+
if output.nil?
|
|
156
|
+
malformed!("OpenCode sanitized export evidence is required")
|
|
157
|
+
end
|
|
158
|
+
bounded_input!(output, MAX_EXPORT_BYTES, "OpenCode sanitized export")
|
|
159
|
+
export = JSON.parse(output)
|
|
160
|
+
malformed!("OpenCode sanitized export must be an object") unless
|
|
161
|
+
export.is_a?(Hash)
|
|
162
|
+
info = required_hash(export, "info", "export info")
|
|
163
|
+
unless required_string(info, "id", "export session id") == session_id
|
|
164
|
+
malformed!("OpenCode sanitized export session does not match the run")
|
|
165
|
+
end
|
|
166
|
+
messages = export["messages"]
|
|
167
|
+
malformed!("OpenCode sanitized export messages must be an array") unless
|
|
168
|
+
messages.is_a?(Array)
|
|
169
|
+
matches = messages.filter_map do |message|
|
|
170
|
+
next unless message.is_a?(Hash) && message["info"].is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
record = message.fetch("info")
|
|
173
|
+
next unless record["id"] == message_id
|
|
174
|
+
|
|
175
|
+
record
|
|
176
|
+
end
|
|
177
|
+
unless matches.one?
|
|
178
|
+
malformed!("OpenCode sanitized export must contain one terminal assistant record")
|
|
179
|
+
end
|
|
180
|
+
assistant = matches.fetch(0)
|
|
181
|
+
unless assistant["role"] == "assistant" &&
|
|
182
|
+
assistant["sessionID"] == session_id
|
|
183
|
+
malformed!("OpenCode sanitized export terminal record is not correlated")
|
|
184
|
+
end
|
|
185
|
+
unless TERMINAL_REASONS.include?(assistant["finish"])
|
|
186
|
+
malformed!("OpenCode sanitized export terminal record is incomplete")
|
|
187
|
+
end
|
|
188
|
+
provider = required_string(
|
|
189
|
+
assistant, "providerID", "assistant providerID"
|
|
190
|
+
)
|
|
191
|
+
model = required_string(assistant, "modelID", "assistant modelID")
|
|
192
|
+
tokens = assistant["tokens"]
|
|
193
|
+
unless tokens.nil? || tokens.is_a?(Hash)
|
|
194
|
+
malformed!("OpenCode assistant tokens must be an object")
|
|
195
|
+
end
|
|
196
|
+
tokens ||= {}
|
|
197
|
+
cache = tokens["cache"]
|
|
198
|
+
unless cache.nil? || cache.is_a?(Hash)
|
|
199
|
+
malformed!("OpenCode assistant cache tokens must be an object")
|
|
200
|
+
end
|
|
201
|
+
cache ||= {}
|
|
202
|
+
|
|
203
|
+
{
|
|
204
|
+
route: Route.new(provider:, model:),
|
|
205
|
+
usage: NormalizedUsage.new(
|
|
206
|
+
input: numeric(tokens, "input", integer: true),
|
|
207
|
+
output: numeric(tokens, "output", integer: true),
|
|
208
|
+
cache_read: numeric(cache, "read", integer: true),
|
|
209
|
+
cache_write: numeric(cache, "write", integer: true),
|
|
210
|
+
reasoning: numeric(tokens, "reasoning", integer: true),
|
|
211
|
+
cost: numeric(assistant, "cost", integer: false)
|
|
212
|
+
)
|
|
213
|
+
}.freeze
|
|
214
|
+
rescue JSON::ParserError => e
|
|
215
|
+
raise MalformedOutput,
|
|
216
|
+
Redactor.diagnostic("OpenCode sanitized export is malformed: #{e.message}")
|
|
217
|
+
rescue ArgumentError => e
|
|
218
|
+
raise MalformedOutput, Redactor.diagnostic(e)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def terminal_part(part, message_id)
|
|
222
|
+
reason = required_string(part, "reason", "terminal reason")
|
|
223
|
+
tokens = required_hash(part, "tokens", "terminal tokens")
|
|
224
|
+
cache = required_hash(tokens, "cache", "terminal cache tokens")
|
|
225
|
+
{
|
|
226
|
+
message_id: message_id,
|
|
227
|
+
reason: reason,
|
|
228
|
+
usage: NormalizedUsage.new(
|
|
229
|
+
input: required_numeric(tokens, "input", integer: true),
|
|
230
|
+
output: required_numeric(tokens, "output", integer: true),
|
|
231
|
+
cache_read: required_numeric(cache, "read", integer: true),
|
|
232
|
+
cache_write: required_numeric(cache, "write", integer: true),
|
|
233
|
+
reasoning: required_numeric(tokens, "reasoning", integer: true),
|
|
234
|
+
cost: required_numeric(part, "cost", integer: false)
|
|
235
|
+
)
|
|
236
|
+
}.freeze
|
|
237
|
+
rescue ArgumentError => e
|
|
238
|
+
malformed!(e.message)
|
|
239
|
+
end
|
|
240
|
+
private_class_method :terminal_part
|
|
241
|
+
|
|
242
|
+
def validate_part!(part, event_type, session_id)
|
|
243
|
+
expected = EVENT_PART_TYPES.fetch(event_type)
|
|
244
|
+
unless part["type"] == expected && part["sessionID"] == session_id
|
|
245
|
+
malformed!("OpenCode #{event_type} part is not correlated")
|
|
246
|
+
end
|
|
247
|
+
required_string(part, "id", "part id")
|
|
248
|
+
end
|
|
249
|
+
private_class_method :validate_part!
|
|
250
|
+
|
|
251
|
+
def validate_error!(event)
|
|
252
|
+
error = required_hash(event, "error", "error event payload")
|
|
253
|
+
required_string(error, "name", "error name")
|
|
254
|
+
data = required_hash(error, "data", "error data")
|
|
255
|
+
required_string(data, "message", "error message")
|
|
256
|
+
end
|
|
257
|
+
private_class_method :validate_error!
|
|
258
|
+
|
|
259
|
+
def validate_additive_session!(event, session_id)
|
|
260
|
+
value = event["sessionID"]
|
|
261
|
+
return nil if value.nil?
|
|
262
|
+
unless value.is_a?(String) && !value.empty? && !value.include?("\0")
|
|
263
|
+
malformed!("OpenCode additive event sessionID must be a non-empty string")
|
|
264
|
+
end
|
|
265
|
+
return value if session_id.nil? || value == session_id
|
|
266
|
+
|
|
267
|
+
malformed!("OpenCode additive event session does not match the run")
|
|
268
|
+
end
|
|
269
|
+
private_class_method :validate_additive_session!
|
|
270
|
+
|
|
271
|
+
def classify_failure(captured)
|
|
272
|
+
details = error_details(captured.stdout)
|
|
273
|
+
diagnostic = Redactor.diagnostic(
|
|
274
|
+
[ *details, captured.stderr ].reject(&:empty?).join("\n")
|
|
275
|
+
) || "OpenCode CLI exited without diagnostic evidence"
|
|
276
|
+
corpus = [ *details, captured.stderr ].join(" ")
|
|
277
|
+
kind =
|
|
278
|
+
if corpus.match?(AUTH_PATTERN)
|
|
279
|
+
:authentication_failure
|
|
280
|
+
elsif corpus.match?(CONFIGURATION_PATTERN)
|
|
281
|
+
:configuration_failure
|
|
282
|
+
else
|
|
283
|
+
:cli_failure
|
|
284
|
+
end
|
|
285
|
+
[ kind, diagnostic ]
|
|
286
|
+
end
|
|
287
|
+
private_class_method :classify_failure
|
|
288
|
+
|
|
289
|
+
def error_details(stdout)
|
|
290
|
+
return [] if stdout.bytesize > MAX_RUN_BYTES
|
|
291
|
+
|
|
292
|
+
stdout.each_line.filter_map do |line|
|
|
293
|
+
event = JSON.parse(line)
|
|
294
|
+
next unless event.is_a?(Hash) && event["type"] == "error"
|
|
295
|
+
|
|
296
|
+
error = event["error"]
|
|
297
|
+
next unless error.is_a?(Hash)
|
|
298
|
+
|
|
299
|
+
name = error["name"].to_s
|
|
300
|
+
data = error["data"]
|
|
301
|
+
message = data.is_a?(Hash) ? data["message"].to_s : ""
|
|
302
|
+
[ name, message ].reject(&:empty?).join(": ")
|
|
303
|
+
rescue JSON::ParserError
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
private_class_method :error_details
|
|
308
|
+
|
|
309
|
+
def failure_outcome(profile, route, termination, kind, diagnostic)
|
|
310
|
+
NormalizedOutcome.new(
|
|
311
|
+
provider: profile.name,
|
|
312
|
+
launcher_identity: profile.launcher_identity,
|
|
313
|
+
kind: kind,
|
|
314
|
+
termination: termination,
|
|
315
|
+
identity: RouteIdentity.new(requested: route),
|
|
316
|
+
diagnostic: Redactor.diagnostic(diagnostic)
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
private_class_method :failure_outcome
|
|
320
|
+
|
|
321
|
+
def malformed_outcome(profile, requested_route, captured, error)
|
|
322
|
+
route = requested_route.is_a?(Route) ?
|
|
323
|
+
requested_route : Route.parse(requested_route)
|
|
324
|
+
NormalizedOutcome.new(
|
|
325
|
+
provider: profile.name,
|
|
326
|
+
launcher_identity: profile.launcher_identity,
|
|
327
|
+
kind: :malformed_output,
|
|
328
|
+
termination: captured.termination,
|
|
329
|
+
identity: RouteIdentity.new(requested: route),
|
|
330
|
+
diagnostic: Redactor.diagnostic(error)
|
|
331
|
+
)
|
|
332
|
+
end
|
|
333
|
+
private_class_method :malformed_outcome
|
|
334
|
+
|
|
335
|
+
def parse_json_line(line, line_number)
|
|
336
|
+
value = JSON.parse(line)
|
|
337
|
+
malformed!("OpenCode event on line #{line_number} must be an object") unless
|
|
338
|
+
value.is_a?(Hash)
|
|
339
|
+
value
|
|
340
|
+
rescue JSON::ParserError
|
|
341
|
+
malformed!("OpenCode run output has malformed JSON on line #{line_number}")
|
|
342
|
+
end
|
|
343
|
+
private_class_method :parse_json_line
|
|
344
|
+
|
|
345
|
+
def required_hash(hash, key, label)
|
|
346
|
+
value = hash[key]
|
|
347
|
+
malformed!("OpenCode #{label} must be an object") unless value.is_a?(Hash)
|
|
348
|
+
value
|
|
349
|
+
end
|
|
350
|
+
private_class_method :required_hash
|
|
351
|
+
|
|
352
|
+
def required_string(hash, key, label)
|
|
353
|
+
value = hash[key]
|
|
354
|
+
unless value.is_a?(String) && !value.empty? && !value.include?("\0")
|
|
355
|
+
malformed!("OpenCode #{label} must be a non-empty string")
|
|
356
|
+
end
|
|
357
|
+
value
|
|
358
|
+
end
|
|
359
|
+
private_class_method :required_string
|
|
360
|
+
|
|
361
|
+
def required_numeric(hash, key, integer:)
|
|
362
|
+
malformed!("OpenCode #{key} is required") unless hash.key?(key)
|
|
363
|
+
numeric(hash, key, integer:)
|
|
364
|
+
end
|
|
365
|
+
private_class_method :required_numeric
|
|
366
|
+
|
|
367
|
+
def numeric(hash, key, integer:)
|
|
368
|
+
return nil unless hash.key?(key)
|
|
369
|
+
|
|
370
|
+
value = hash[key]
|
|
371
|
+
valid = integer ? value.is_a?(Integer) : value.is_a?(Numeric)
|
|
372
|
+
valid &&= value.finite? if value.respond_to?(:finite?)
|
|
373
|
+
unless valid && value >= 0
|
|
374
|
+
malformed!("OpenCode #{key} must be a non-negative number")
|
|
375
|
+
end
|
|
376
|
+
value
|
|
377
|
+
end
|
|
378
|
+
private_class_method :numeric
|
|
379
|
+
|
|
380
|
+
def bounded_input!(value, bytes, label)
|
|
381
|
+
unless value.is_a?(String) && value.bytesize <= bytes
|
|
382
|
+
malformed!("#{label} exceeds the bounded input size")
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
private_class_method :bounded_input!
|
|
386
|
+
|
|
387
|
+
def bounded_string(value, bytes)
|
|
388
|
+
return value if value.bytesize <= bytes
|
|
389
|
+
|
|
390
|
+
value.byteslice(0, bytes).to_s
|
|
391
|
+
.force_encoding(Encoding::UTF_8)
|
|
392
|
+
.scrub("?")
|
|
393
|
+
end
|
|
394
|
+
private_class_method :bounded_string
|
|
395
|
+
|
|
396
|
+
def malformed!(message)
|
|
397
|
+
raise MalformedOutput, Redactor.diagnostic(message)
|
|
398
|
+
end
|
|
399
|
+
private_class_method :malformed!
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|