agent-cli-runtime 0.1.1 → 0.2.4
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 +87 -0
- data/README.md +177 -9
- data/agent-cli-runtime.gemspec +7 -5
- data/lib/agent_cli_runtime/error_extractors.rb +95 -0
- 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 +488 -0
- data/lib/agent_cli_runtime/opencode/permissions.rb +144 -0
- data/lib/agent_cli_runtime/opencode/probe.rb +304 -0
- data/lib/agent_cli_runtime/opencode/result_parser.rb +416 -0
- data/lib/agent_cli_runtime/profile.rb +60 -11
- data/lib/agent_cli_runtime/profiles.rb +98 -3
- data/lib/agent_cli_runtime/runtime.rb +118 -13
- data/lib/agent_cli_runtime/usage_extractors.rb +119 -32
- data/lib/agent_cli_runtime/values.rb +478 -0
- data/lib/agent_cli_runtime/version.rb +1 -1
- data/lib/agent_cli_runtime.rb +29 -3
- metadata +13 -6
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module AgentCliRuntime
|
|
4
|
+
module OpenCode
|
|
5
|
+
module ResultParser
|
|
6
|
+
MAX_RUN_BYTES = 4 * 1024 * 1024
|
|
7
|
+
# Sanitized exports contain the complete session and every tool result,
|
|
8
|
+
# so their bounded limit must accommodate long implementation sessions.
|
|
9
|
+
MAX_EXPORT_BYTES = 64 * 1024 * 1024
|
|
10
|
+
MAX_FINAL_MESSAGE_BYTES = 1024 * 1024
|
|
11
|
+
MAX_EVENTS = 10_000
|
|
12
|
+
MAX_UNKNOWN_EVENTS = 16
|
|
13
|
+
TERMINAL_REASONS = %w[stop length content-filter].freeze
|
|
14
|
+
KNOWN_EVENT_TYPES = %w[
|
|
15
|
+
step_start step_finish text reasoning tool_use error
|
|
16
|
+
].freeze
|
|
17
|
+
EVENT_PART_TYPES = {
|
|
18
|
+
"step_start" => "step-start",
|
|
19
|
+
"step_finish" => "step-finish",
|
|
20
|
+
"text" => "text",
|
|
21
|
+
"reasoning" => "reasoning",
|
|
22
|
+
"tool_use" => "tool"
|
|
23
|
+
}.freeze
|
|
24
|
+
AUTH_PATTERN = /auth|credential|api[ _-]?key|unauthorized|forbidden/i
|
|
25
|
+
CONFIGURATION_PATTERN =
|
|
26
|
+
/\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
|
|
27
|
+
UPSTREAM_TIMEOUT_PATTERN =
|
|
28
|
+
/\b(?:upstream\s+)?(?:idle\s+)?timeout\b|\btimed out\b|error_type['"\s:=>]+timeout\b|\bcode['"\s:=>]+504\b/i
|
|
29
|
+
private_constant :KNOWN_EVENT_TYPES, :EVENT_PART_TYPES,
|
|
30
|
+
:AUTH_PATTERN, :CONFIGURATION_PATTERN,
|
|
31
|
+
:UPSTREAM_TIMEOUT_PATTERN
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def parse_run(stdout)
|
|
36
|
+
bounded_input!(stdout, MAX_RUN_BYTES, "OpenCode run output")
|
|
37
|
+
session_id = nil
|
|
38
|
+
terminal = nil
|
|
39
|
+
texts = []
|
|
40
|
+
unknown = []
|
|
41
|
+
error_seen = false
|
|
42
|
+
event_count = 0
|
|
43
|
+
|
|
44
|
+
stdout.each_line.with_index(1) do |line, line_number|
|
|
45
|
+
next if line.strip.empty?
|
|
46
|
+
|
|
47
|
+
event_count += 1
|
|
48
|
+
malformed!("OpenCode run output contains too many events") if
|
|
49
|
+
event_count > MAX_EVENTS
|
|
50
|
+
event = parse_json_line(line, line_number)
|
|
51
|
+
type = required_string(event, "type", "event type")
|
|
52
|
+
unless KNOWN_EVENT_TYPES.include?(type)
|
|
53
|
+
additive_session = validate_additive_session!(event, session_id)
|
|
54
|
+
session_id ||= additive_session
|
|
55
|
+
if unknown.length < MAX_UNKNOWN_EVENTS
|
|
56
|
+
unknown << Redactor.diagnostic(
|
|
57
|
+
"unknown OpenCode event #{type}", bytes: 128
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
event_session = required_string(event, "sessionID", "event sessionID")
|
|
64
|
+
session_id ||= event_session
|
|
65
|
+
malformed!("OpenCode run sessionID changed within one capture") unless
|
|
66
|
+
session_id == event_session
|
|
67
|
+
|
|
68
|
+
if type == "error"
|
|
69
|
+
validate_error!(event)
|
|
70
|
+
error_seen = true
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
part = required_hash(event, "part", "event part")
|
|
75
|
+
validate_part!(part, type, session_id)
|
|
76
|
+
message_id = required_string(part, "messageID", "part messageID")
|
|
77
|
+
case type
|
|
78
|
+
when "text"
|
|
79
|
+
text = part["text"]
|
|
80
|
+
malformed!("OpenCode text part must contain text") unless
|
|
81
|
+
text.is_a?(String)
|
|
82
|
+
texts << [ message_id, text ]
|
|
83
|
+
when "step_finish"
|
|
84
|
+
terminal = terminal_part(part, message_id)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
malformed!("OpenCode run emitted an error on a zero exit") if error_seen
|
|
89
|
+
malformed!("OpenCode run has no recognized terminal step") unless terminal
|
|
90
|
+
unless TERMINAL_REASONS.include?(terminal.fetch(:reason))
|
|
91
|
+
malformed!("OpenCode terminal step has an unrecognized finish reason")
|
|
92
|
+
end
|
|
93
|
+
message = texts.filter_map do |message_id, text|
|
|
94
|
+
text if message_id == terminal.fetch(:message_id)
|
|
95
|
+
end.join
|
|
96
|
+
final_message_truncated = message.bytesize > MAX_FINAL_MESSAGE_BYTES
|
|
97
|
+
ParsedRun.new(
|
|
98
|
+
session_id: session_id,
|
|
99
|
+
terminal_message_id: terminal.fetch(:message_id),
|
|
100
|
+
terminal_reason: terminal.fetch(:reason),
|
|
101
|
+
final_message: bounded_string(message, MAX_FINAL_MESSAGE_BYTES),
|
|
102
|
+
final_message_truncated: final_message_truncated,
|
|
103
|
+
preliminary_usage: terminal.fetch(:usage),
|
|
104
|
+
unknown_events: unknown.compact
|
|
105
|
+
)
|
|
106
|
+
rescue MalformedOutput
|
|
107
|
+
raise
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
raise MalformedOutput, Redactor.diagnostic(e)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normalize(captured, requested_route:, profile:)
|
|
113
|
+
unless captured.is_a?(CapturedResult)
|
|
114
|
+
raise ArgumentError, "captured must be an AgentCliRuntime::CapturedResult"
|
|
115
|
+
end
|
|
116
|
+
route = requested_route.is_a?(Route) ?
|
|
117
|
+
requested_route : Route.parse(requested_route)
|
|
118
|
+
termination = captured.termination
|
|
119
|
+
return failure_outcome(
|
|
120
|
+
profile, route, termination, :timed_out, "OpenCode run timed out"
|
|
121
|
+
) if termination.timed_out
|
|
122
|
+
return failure_outcome(
|
|
123
|
+
profile, route, termination, :cancelled, "OpenCode run was cancelled"
|
|
124
|
+
) if termination.cancelled
|
|
125
|
+
unless termination.success?
|
|
126
|
+
kind, diagnostic = classify_failure(captured)
|
|
127
|
+
return failure_outcome(
|
|
128
|
+
profile, route, termination, kind, diagnostic
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
parsed = parse_run(captured.stdout)
|
|
133
|
+
inspection = parse_inspection(
|
|
134
|
+
captured.inspection_output,
|
|
135
|
+
session_id: parsed.session_id,
|
|
136
|
+
message_id: parsed.terminal_message_id
|
|
137
|
+
)
|
|
138
|
+
actual = inspection.fetch(:route)
|
|
139
|
+
NormalizedOutcome.new(
|
|
140
|
+
provider: profile.name,
|
|
141
|
+
launcher_identity: profile.launcher_identity,
|
|
142
|
+
kind: :completed,
|
|
143
|
+
termination: termination,
|
|
144
|
+
final_message: parsed.final_message,
|
|
145
|
+
final_message_truncated: parsed.final_message_truncated,
|
|
146
|
+
identity: RouteIdentity.new(requested: route, actual: actual),
|
|
147
|
+
usage: inspection.fetch(:usage),
|
|
148
|
+
diagnostic: nil,
|
|
149
|
+
unknown_events: parsed.unknown_events,
|
|
150
|
+
session_id: parsed.session_id,
|
|
151
|
+
message_id: parsed.terminal_message_id
|
|
152
|
+
)
|
|
153
|
+
rescue MalformedOutput => e
|
|
154
|
+
malformed_outcome(profile, requested_route, captured, e)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def parse_inspection(output, session_id:, message_id:)
|
|
158
|
+
if output.nil?
|
|
159
|
+
malformed!("OpenCode sanitized export evidence is required")
|
|
160
|
+
end
|
|
161
|
+
bounded_input!(output, MAX_EXPORT_BYTES, "OpenCode sanitized export")
|
|
162
|
+
export = JSON.parse(output)
|
|
163
|
+
malformed!("OpenCode sanitized export must be an object") unless
|
|
164
|
+
export.is_a?(Hash)
|
|
165
|
+
info = required_hash(export, "info", "export info")
|
|
166
|
+
unless required_string(info, "id", "export session id") == session_id
|
|
167
|
+
malformed!("OpenCode sanitized export session does not match the run")
|
|
168
|
+
end
|
|
169
|
+
messages = export["messages"]
|
|
170
|
+
malformed!("OpenCode sanitized export messages must be an array") unless
|
|
171
|
+
messages.is_a?(Array)
|
|
172
|
+
assistant = nil
|
|
173
|
+
messages.each do |message|
|
|
174
|
+
next unless message.is_a?(Hash) && message["info"].is_a?(Hash)
|
|
175
|
+
|
|
176
|
+
record = message.fetch("info")
|
|
177
|
+
next unless record["id"] == message_id
|
|
178
|
+
|
|
179
|
+
if assistant
|
|
180
|
+
malformed!("OpenCode sanitized export must contain one terminal assistant record")
|
|
181
|
+
end
|
|
182
|
+
assistant = record
|
|
183
|
+
end
|
|
184
|
+
unless assistant
|
|
185
|
+
malformed!("OpenCode sanitized export must contain one terminal assistant record")
|
|
186
|
+
end
|
|
187
|
+
unless assistant["role"] == "assistant" &&
|
|
188
|
+
assistant["sessionID"] == session_id
|
|
189
|
+
malformed!("OpenCode sanitized export terminal record is not correlated")
|
|
190
|
+
end
|
|
191
|
+
unless TERMINAL_REASONS.include?(assistant["finish"])
|
|
192
|
+
malformed!("OpenCode sanitized export terminal record is incomplete")
|
|
193
|
+
end
|
|
194
|
+
provider = required_string(
|
|
195
|
+
assistant, "providerID", "assistant providerID"
|
|
196
|
+
)
|
|
197
|
+
model = required_string(assistant, "modelID", "assistant modelID")
|
|
198
|
+
tokens = assistant["tokens"]
|
|
199
|
+
unless tokens.nil? || tokens.is_a?(Hash)
|
|
200
|
+
malformed!("OpenCode assistant tokens must be an object")
|
|
201
|
+
end
|
|
202
|
+
tokens ||= {}
|
|
203
|
+
cache = tokens["cache"]
|
|
204
|
+
unless cache.nil? || cache.is_a?(Hash)
|
|
205
|
+
malformed!("OpenCode assistant cache tokens must be an object")
|
|
206
|
+
end
|
|
207
|
+
cache ||= {}
|
|
208
|
+
|
|
209
|
+
{
|
|
210
|
+
route: Route.new(provider:, model:),
|
|
211
|
+
usage: NormalizedUsage.new(
|
|
212
|
+
input: numeric(tokens, "input", integer: true),
|
|
213
|
+
output: numeric(tokens, "output", integer: true),
|
|
214
|
+
cache_read: numeric(cache, "read", integer: true),
|
|
215
|
+
cache_write: numeric(cache, "write", integer: true),
|
|
216
|
+
reasoning: numeric(tokens, "reasoning", integer: true),
|
|
217
|
+
input_includes_cache_read: cache.key?("read") ? true : nil,
|
|
218
|
+
input_includes_cache_write: cache.key?("write") ? true : nil,
|
|
219
|
+
output_includes_reasoning: tokens.key?("reasoning") ? false : nil,
|
|
220
|
+
provider_reported_cost: numeric(assistant, "cost", integer: false)
|
|
221
|
+
)
|
|
222
|
+
}.freeze
|
|
223
|
+
rescue JSON::ParserError => e
|
|
224
|
+
raise MalformedOutput,
|
|
225
|
+
Redactor.diagnostic("OpenCode sanitized export is malformed: #{e.message}")
|
|
226
|
+
rescue ArgumentError => e
|
|
227
|
+
raise MalformedOutput, Redactor.diagnostic(e)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def terminal_part(part, message_id)
|
|
231
|
+
reason = required_string(part, "reason", "terminal reason")
|
|
232
|
+
tokens = required_hash(part, "tokens", "terminal tokens")
|
|
233
|
+
cache = required_hash(tokens, "cache", "terminal cache tokens")
|
|
234
|
+
{
|
|
235
|
+
message_id: message_id,
|
|
236
|
+
reason: reason,
|
|
237
|
+
usage: NormalizedUsage.new(
|
|
238
|
+
input: required_numeric(tokens, "input", integer: true),
|
|
239
|
+
output: required_numeric(tokens, "output", integer: true),
|
|
240
|
+
cache_read: required_numeric(cache, "read", integer: true),
|
|
241
|
+
cache_write: required_numeric(cache, "write", integer: true),
|
|
242
|
+
reasoning: required_numeric(tokens, "reasoning", integer: true),
|
|
243
|
+
input_includes_cache_read: true,
|
|
244
|
+
input_includes_cache_write: true,
|
|
245
|
+
output_includes_reasoning: false,
|
|
246
|
+
provider_reported_cost: required_numeric(part, "cost", integer: false)
|
|
247
|
+
)
|
|
248
|
+
}.freeze
|
|
249
|
+
rescue ArgumentError => e
|
|
250
|
+
malformed!(e.message)
|
|
251
|
+
end
|
|
252
|
+
private_class_method :terminal_part
|
|
253
|
+
|
|
254
|
+
def validate_part!(part, event_type, session_id)
|
|
255
|
+
expected = EVENT_PART_TYPES.fetch(event_type)
|
|
256
|
+
unless part["type"] == expected && part["sessionID"] == session_id
|
|
257
|
+
malformed!("OpenCode #{event_type} part is not correlated")
|
|
258
|
+
end
|
|
259
|
+
required_string(part, "id", "part id")
|
|
260
|
+
end
|
|
261
|
+
private_class_method :validate_part!
|
|
262
|
+
|
|
263
|
+
def validate_error!(event)
|
|
264
|
+
error = required_hash(event, "error", "error event payload")
|
|
265
|
+
required_string(error, "name", "error name")
|
|
266
|
+
data = required_hash(error, "data", "error data")
|
|
267
|
+
required_string(data, "message", "error message")
|
|
268
|
+
end
|
|
269
|
+
private_class_method :validate_error!
|
|
270
|
+
|
|
271
|
+
def validate_additive_session!(event, session_id)
|
|
272
|
+
value = event["sessionID"]
|
|
273
|
+
return nil if value.nil?
|
|
274
|
+
unless value.is_a?(String) && !value.empty? && !value.include?("\0")
|
|
275
|
+
malformed!("OpenCode additive event sessionID must be a non-empty string")
|
|
276
|
+
end
|
|
277
|
+
return value if session_id.nil? || value == session_id
|
|
278
|
+
|
|
279
|
+
malformed!("OpenCode additive event session does not match the run")
|
|
280
|
+
end
|
|
281
|
+
private_class_method :validate_additive_session!
|
|
282
|
+
|
|
283
|
+
def classify_failure(captured)
|
|
284
|
+
details = error_details(captured.stdout)
|
|
285
|
+
diagnostic = Redactor.diagnostic(
|
|
286
|
+
[ *details, captured.stderr ].reject(&:empty?).join("\n")
|
|
287
|
+
) || "OpenCode CLI exited without diagnostic evidence"
|
|
288
|
+
corpus = [ *details, captured.stderr ].join(" ")
|
|
289
|
+
kind =
|
|
290
|
+
if corpus.match?(AUTH_PATTERN)
|
|
291
|
+
:authentication_failure
|
|
292
|
+
elsif corpus.match?(CONFIGURATION_PATTERN)
|
|
293
|
+
:configuration_failure
|
|
294
|
+
elsif corpus.match?(UPSTREAM_TIMEOUT_PATTERN)
|
|
295
|
+
:timed_out
|
|
296
|
+
else
|
|
297
|
+
:cli_failure
|
|
298
|
+
end
|
|
299
|
+
[ kind, diagnostic ]
|
|
300
|
+
end
|
|
301
|
+
private_class_method :classify_failure
|
|
302
|
+
|
|
303
|
+
def error_details(stdout)
|
|
304
|
+
return [] if stdout.bytesize > MAX_RUN_BYTES
|
|
305
|
+
|
|
306
|
+
stdout.each_line.filter_map do |line|
|
|
307
|
+
event = JSON.parse(line)
|
|
308
|
+
next unless event.is_a?(Hash) && event["type"] == "error"
|
|
309
|
+
|
|
310
|
+
error = event["error"]
|
|
311
|
+
next unless error.is_a?(Hash)
|
|
312
|
+
|
|
313
|
+
name = error["name"].to_s
|
|
314
|
+
data = error["data"]
|
|
315
|
+
message = data.is_a?(Hash) ? data["message"].to_s : ""
|
|
316
|
+
[ name, message ].reject(&:empty?).join(": ")
|
|
317
|
+
rescue JSON::ParserError
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
private_class_method :error_details
|
|
322
|
+
|
|
323
|
+
def failure_outcome(profile, route, termination, kind, diagnostic)
|
|
324
|
+
NormalizedOutcome.new(
|
|
325
|
+
provider: profile.name,
|
|
326
|
+
launcher_identity: profile.launcher_identity,
|
|
327
|
+
kind: kind,
|
|
328
|
+
termination: termination,
|
|
329
|
+
identity: RouteIdentity.new(requested: route),
|
|
330
|
+
diagnostic: Redactor.diagnostic(diagnostic)
|
|
331
|
+
)
|
|
332
|
+
end
|
|
333
|
+
private_class_method :failure_outcome
|
|
334
|
+
|
|
335
|
+
def malformed_outcome(profile, requested_route, captured, error)
|
|
336
|
+
route = requested_route.is_a?(Route) ?
|
|
337
|
+
requested_route : Route.parse(requested_route)
|
|
338
|
+
NormalizedOutcome.new(
|
|
339
|
+
provider: profile.name,
|
|
340
|
+
launcher_identity: profile.launcher_identity,
|
|
341
|
+
kind: :malformed_output,
|
|
342
|
+
termination: captured.termination,
|
|
343
|
+
identity: RouteIdentity.new(requested: route),
|
|
344
|
+
diagnostic: Redactor.diagnostic(error)
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
private_class_method :malformed_outcome
|
|
348
|
+
|
|
349
|
+
def parse_json_line(line, line_number)
|
|
350
|
+
value = JSON.parse(line)
|
|
351
|
+
malformed!("OpenCode event on line #{line_number} must be an object") unless
|
|
352
|
+
value.is_a?(Hash)
|
|
353
|
+
value
|
|
354
|
+
rescue JSON::ParserError
|
|
355
|
+
malformed!("OpenCode run output has malformed JSON on line #{line_number}")
|
|
356
|
+
end
|
|
357
|
+
private_class_method :parse_json_line
|
|
358
|
+
|
|
359
|
+
def required_hash(hash, key, label)
|
|
360
|
+
value = hash[key]
|
|
361
|
+
malformed!("OpenCode #{label} must be an object") unless value.is_a?(Hash)
|
|
362
|
+
value
|
|
363
|
+
end
|
|
364
|
+
private_class_method :required_hash
|
|
365
|
+
|
|
366
|
+
def required_string(hash, key, label)
|
|
367
|
+
value = hash[key]
|
|
368
|
+
unless value.is_a?(String) && !value.empty? && !value.include?("\0")
|
|
369
|
+
malformed!("OpenCode #{label} must be a non-empty string")
|
|
370
|
+
end
|
|
371
|
+
value
|
|
372
|
+
end
|
|
373
|
+
private_class_method :required_string
|
|
374
|
+
|
|
375
|
+
def required_numeric(hash, key, integer:)
|
|
376
|
+
malformed!("OpenCode #{key} is required") unless hash.key?(key)
|
|
377
|
+
numeric(hash, key, integer:)
|
|
378
|
+
end
|
|
379
|
+
private_class_method :required_numeric
|
|
380
|
+
|
|
381
|
+
def numeric(hash, key, integer:)
|
|
382
|
+
return nil unless hash.key?(key)
|
|
383
|
+
|
|
384
|
+
value = hash[key]
|
|
385
|
+
valid = integer ? value.is_a?(Integer) : value.is_a?(Numeric)
|
|
386
|
+
valid &&= value.finite? if value.respond_to?(:finite?)
|
|
387
|
+
unless valid && value >= 0
|
|
388
|
+
malformed!("OpenCode #{key} must be a non-negative number")
|
|
389
|
+
end
|
|
390
|
+
value
|
|
391
|
+
end
|
|
392
|
+
private_class_method :numeric
|
|
393
|
+
|
|
394
|
+
def bounded_input!(value, bytes, label)
|
|
395
|
+
unless value.is_a?(String) && value.bytesize <= bytes
|
|
396
|
+
malformed!("#{label} exceeds the bounded input size")
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
private_class_method :bounded_input!
|
|
400
|
+
|
|
401
|
+
def bounded_string(value, bytes)
|
|
402
|
+
return value if value.bytesize <= bytes
|
|
403
|
+
|
|
404
|
+
value.byteslice(0, bytes).to_s
|
|
405
|
+
.force_encoding(Encoding::UTF_8)
|
|
406
|
+
.scrub("?")
|
|
407
|
+
end
|
|
408
|
+
private_class_method :bounded_string
|
|
409
|
+
|
|
410
|
+
def malformed!(message)
|
|
411
|
+
raise MalformedOutput, Redactor.diagnostic(message)
|
|
412
|
+
end
|
|
413
|
+
private_class_method :malformed!
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -4,10 +4,10 @@ require "rubygems/version"
|
|
|
4
4
|
|
|
5
5
|
module AgentCliRuntime
|
|
6
6
|
class Profile
|
|
7
|
-
PROMPT_STYLES = %i[positional headless_flag_value stdin].freeze
|
|
7
|
+
PROMPT_STYLES = %i[positional headless_flag_value stdin piped_stdin].freeze
|
|
8
8
|
WORKSPACE_WRITE_PERMISSION_MODE = "workspace-write".freeze
|
|
9
9
|
READ_ONLY_PERMISSION_MODE = "read-only".freeze
|
|
10
|
-
CAPTURE_TIMEOUT_SECONDS =
|
|
10
|
+
CAPTURE_TIMEOUT_SECONDS = 120
|
|
11
11
|
CAPTURE_POLL_SECONDS = 0.01
|
|
12
12
|
CAPTURE_TERM_GRACE_SECONDS = 0.2
|
|
13
13
|
CAPTURE_REAP_GRACE_SECONDS = 0.2
|
|
@@ -27,7 +27,9 @@ module AgentCliRuntime
|
|
|
27
27
|
:effort_argument_builder, :launcher_identity,
|
|
28
28
|
:cli_capabilities, :declared_capability_support,
|
|
29
29
|
:credential_environment_keys, :configuration_environment_key,
|
|
30
|
-
:default_configuration_directory
|
|
30
|
+
:default_configuration_directory,
|
|
31
|
+
:permission_policy_required, :result_parser,
|
|
32
|
+
:version_check_timeout_sec
|
|
31
33
|
|
|
32
34
|
def initialize(name:, bin_default:, headless_flag:, version_flag:,
|
|
33
35
|
env_bin_override_keys: [], permission_skip_flag: nil,
|
|
@@ -36,10 +38,13 @@ module AgentCliRuntime
|
|
|
36
38
|
output_format_flags: [], min_version: nil,
|
|
37
39
|
prompt_style: :positional, model_argument_builder: nil,
|
|
38
40
|
effort_argument_builder: nil, launcher_identity: nil,
|
|
39
|
-
usage_extractor: nil,
|
|
41
|
+
usage_extractor: nil, error_extractor: nil,
|
|
42
|
+
auth_configuration_probe: nil,
|
|
40
43
|
cli_capabilities: {}, raw_cli_arguments_supported: false,
|
|
41
44
|
credential_environment_keys: [], configuration_environment_key: nil,
|
|
42
|
-
default_configuration_directory: nil
|
|
45
|
+
default_configuration_directory: nil,
|
|
46
|
+
permission_policy_required: false, result_parser: nil,
|
|
47
|
+
version_check_timeout_sec: CAPTURE_TIMEOUT_SECONDS)
|
|
43
48
|
normalized_prompt_style = prompt_style.to_sym
|
|
44
49
|
unless PROMPT_STYLES.include?(normalized_prompt_style)
|
|
45
50
|
raise ArgumentError,
|
|
@@ -66,6 +71,7 @@ module AgentCliRuntime
|
|
|
66
71
|
@launcher_identity =
|
|
67
72
|
immutable_string(launcher_identity || "agent-cli-runtime/v1:#{@name}")
|
|
68
73
|
@usage_extractor = usage_extractor || ->(_event) { nil }
|
|
74
|
+
@error_extractor = error_extractor || ErrorExtractors::DEFAULT
|
|
69
75
|
@auth_configuration_probe = auth_configuration_probe
|
|
70
76
|
@cli_capabilities = normalize_cli_capabilities(cli_capabilities)
|
|
71
77
|
@raw_cli_arguments_supported = raw_cli_arguments_supported == true
|
|
@@ -78,6 +84,12 @@ module AgentCliRuntime
|
|
|
78
84
|
@default_configuration_directory = optional_relative_directory(
|
|
79
85
|
default_configuration_directory
|
|
80
86
|
)
|
|
87
|
+
@permission_policy_required = permission_policy_required == true
|
|
88
|
+
@result_parser = result_parser
|
|
89
|
+
@version_check_timeout_sec = Float(version_check_timeout_sec)
|
|
90
|
+
unless @version_check_timeout_sec.positive?
|
|
91
|
+
raise ArgumentError, "version_check_timeout_sec must be positive"
|
|
92
|
+
end
|
|
81
93
|
@declared_capability_support = build_declared_capability_support
|
|
82
94
|
freeze
|
|
83
95
|
end
|
|
@@ -102,6 +114,10 @@ module AgentCliRuntime
|
|
|
102
114
|
raise ArgumentError,
|
|
103
115
|
"agent profile #{@name.inspect} cannot enforce read-only sandboxing"
|
|
104
116
|
end
|
|
117
|
+
if permission_mode.nil? && @permission_policy_required
|
|
118
|
+
raise ConfigurationError,
|
|
119
|
+
"agent profile #{@name.inspect} requires an explicit OpenCode permission policy"
|
|
120
|
+
end
|
|
105
121
|
return [] unless @permission_skip_flag
|
|
106
122
|
return [ @permission_skip_flag ] unless @name == :claude && permission_mode
|
|
107
123
|
return [ @permission_skip_flag ] if permission_mode == "bypassPermissions"
|
|
@@ -145,8 +161,8 @@ module AgentCliRuntime
|
|
|
145
161
|
@raw_cli_arguments_supported
|
|
146
162
|
end
|
|
147
163
|
|
|
148
|
-
def binary_installed?(env: ENV)
|
|
149
|
-
executable
|
|
164
|
+
def binary_installed?(env: ENV, executable: nil)
|
|
165
|
+
executable ||= bin(env:)
|
|
150
166
|
return File.file?(executable) && File.executable?(executable) if executable.include?(File::SEPARATOR)
|
|
151
167
|
|
|
152
168
|
env.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
|
|
@@ -157,10 +173,10 @@ module AgentCliRuntime
|
|
|
157
173
|
false
|
|
158
174
|
end
|
|
159
175
|
|
|
160
|
-
def check_version!(env: ENV)
|
|
161
|
-
executable
|
|
176
|
+
def check_version!(env: ENV, executable: nil)
|
|
177
|
+
executable ||= bin(env:)
|
|
162
178
|
out, _err, status = bounded_capture3(
|
|
163
|
-
executable, @version_flag, timeout_sec:
|
|
179
|
+
executable, @version_flag, timeout_sec: @version_check_timeout_sec, env: env
|
|
164
180
|
)
|
|
165
181
|
unless status.success?
|
|
166
182
|
raise BinaryUnavailable,
|
|
@@ -189,7 +205,7 @@ module AgentCliRuntime
|
|
|
189
205
|
"#{@name} binary not runnable: #{executable} (#{e.class.name.split('::').last})"
|
|
190
206
|
rescue Timeout::Error
|
|
191
207
|
raise BinaryUnavailable,
|
|
192
|
-
"#{@name} version check timed out after #{
|
|
208
|
+
"#{@name} version check timed out after #{@version_check_timeout_sec}s: #{executable}"
|
|
193
209
|
end
|
|
194
210
|
|
|
195
211
|
def auth_configuration(home: nil, env: ENV)
|
|
@@ -213,6 +229,32 @@ module AgentCliRuntime
|
|
|
213
229
|
nil
|
|
214
230
|
end
|
|
215
231
|
|
|
232
|
+
def extract_error_event(event)
|
|
233
|
+
@error_extractor.call(event)
|
|
234
|
+
rescue StandardError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def parse_run(stdout)
|
|
239
|
+
unless @result_parser
|
|
240
|
+
raise UnsupportedCapability,
|
|
241
|
+
"agent profile #{@name.inspect} has no strict result parser"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
@result_parser.parse_run(stdout)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def normalize_captured_result(captured, requested_route:)
|
|
248
|
+
unless @result_parser
|
|
249
|
+
raise UnsupportedCapability,
|
|
250
|
+
"agent profile #{@name.inspect} has no strict result parser"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
@result_parser.normalize(
|
|
254
|
+
captured, requested_route: requested_route, profile: self
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
216
258
|
def configuration_directory(home: nil, env: ENV)
|
|
217
259
|
if @configuration_environment_key
|
|
218
260
|
configured = env[@configuration_environment_key].to_s
|
|
@@ -250,6 +292,13 @@ module AgentCliRuntime
|
|
|
250
292
|
flags.dup
|
|
251
293
|
end
|
|
252
294
|
|
|
295
|
+
def capture_local(*arguments, env: ENV, timeout_sec: CAPTURE_TIMEOUT_SECONDS,
|
|
296
|
+
executable: nil)
|
|
297
|
+
bounded_capture3(
|
|
298
|
+
executable || bin(env:), *arguments, timeout_sec: timeout_sec, env: env
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
253
302
|
private
|
|
254
303
|
|
|
255
304
|
def immutable_string(value)
|