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
|
@@ -27,7 +27,8 @@ 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
|
|
31
32
|
|
|
32
33
|
def initialize(name:, bin_default:, headless_flag:, version_flag:,
|
|
33
34
|
env_bin_override_keys: [], permission_skip_flag: nil,
|
|
@@ -39,7 +40,8 @@ module AgentCliRuntime
|
|
|
39
40
|
usage_extractor: nil, auth_configuration_probe: nil,
|
|
40
41
|
cli_capabilities: {}, raw_cli_arguments_supported: false,
|
|
41
42
|
credential_environment_keys: [], configuration_environment_key: nil,
|
|
42
|
-
default_configuration_directory: nil
|
|
43
|
+
default_configuration_directory: nil,
|
|
44
|
+
permission_policy_required: false, result_parser: nil)
|
|
43
45
|
normalized_prompt_style = prompt_style.to_sym
|
|
44
46
|
unless PROMPT_STYLES.include?(normalized_prompt_style)
|
|
45
47
|
raise ArgumentError,
|
|
@@ -78,6 +80,8 @@ module AgentCliRuntime
|
|
|
78
80
|
@default_configuration_directory = optional_relative_directory(
|
|
79
81
|
default_configuration_directory
|
|
80
82
|
)
|
|
83
|
+
@permission_policy_required = permission_policy_required == true
|
|
84
|
+
@result_parser = result_parser
|
|
81
85
|
@declared_capability_support = build_declared_capability_support
|
|
82
86
|
freeze
|
|
83
87
|
end
|
|
@@ -102,6 +106,10 @@ module AgentCliRuntime
|
|
|
102
106
|
raise ArgumentError,
|
|
103
107
|
"agent profile #{@name.inspect} cannot enforce read-only sandboxing"
|
|
104
108
|
end
|
|
109
|
+
if permission_mode.nil? && @permission_policy_required
|
|
110
|
+
raise ConfigurationError,
|
|
111
|
+
"agent profile #{@name.inspect} requires an explicit OpenCode permission policy"
|
|
112
|
+
end
|
|
105
113
|
return [] unless @permission_skip_flag
|
|
106
114
|
return [ @permission_skip_flag ] unless @name == :claude && permission_mode
|
|
107
115
|
return [ @permission_skip_flag ] if permission_mode == "bypassPermissions"
|
|
@@ -213,6 +221,26 @@ module AgentCliRuntime
|
|
|
213
221
|
nil
|
|
214
222
|
end
|
|
215
223
|
|
|
224
|
+
def parse_run(stdout)
|
|
225
|
+
unless @result_parser
|
|
226
|
+
raise UnsupportedCapability,
|
|
227
|
+
"agent profile #{@name.inspect} has no strict result parser"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
@result_parser.parse_run(stdout)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def normalize_captured_result(captured, requested_route:)
|
|
234
|
+
unless @result_parser
|
|
235
|
+
raise UnsupportedCapability,
|
|
236
|
+
"agent profile #{@name.inspect} has no strict result parser"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
@result_parser.normalize(
|
|
240
|
+
captured, requested_route: requested_route, profile: self
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
216
244
|
def configuration_directory(home: nil, env: ENV)
|
|
217
245
|
if @configuration_environment_key
|
|
218
246
|
configured = env[@configuration_environment_key].to_s
|
|
@@ -250,6 +278,12 @@ module AgentCliRuntime
|
|
|
250
278
|
flags.dup
|
|
251
279
|
end
|
|
252
280
|
|
|
281
|
+
def capture_local(*arguments, env: ENV, timeout_sec: CAPTURE_TIMEOUT_SECONDS)
|
|
282
|
+
bounded_capture3(
|
|
283
|
+
bin(env:), *arguments, timeout_sec: timeout_sec, env: env
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
|
|
253
287
|
private
|
|
254
288
|
|
|
255
289
|
def immutable_string(value)
|
|
@@ -103,6 +103,37 @@ module AgentCliRuntime
|
|
|
103
103
|
home_path(home, ".grok", "auth.json")
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
def opencode_auth_path(home:, env:)
|
|
107
|
+
data_home = env["XDG_DATA_HOME"]
|
|
108
|
+
if !data_home.to_s.empty?
|
|
109
|
+
unless File.absolute_path?(data_home)
|
|
110
|
+
raise ArgumentError, "XDG_DATA_HOME must be absolute"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return File.join(data_home, "opencode", "auth.json")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
home_path(home, ".local", "share", "opencode", "auth.json")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def opencode_model_arguments(model)
|
|
120
|
+
route = Route.parse(model)
|
|
121
|
+
[ "--model", route.to_s ]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
OPENCODE_VARIANTS = %w[minimal low medium high xhigh max].freeze
|
|
125
|
+
|
|
126
|
+
def opencode_variant_arguments(variant)
|
|
127
|
+
normalized = variant.to_s
|
|
128
|
+
unless OPENCODE_VARIANTS.include?(normalized)
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"unsupported OpenCode variant #{variant.inspect}; expected one of " \
|
|
131
|
+
"#{OPENCODE_VARIANTS.join(', ')}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
[ "--variant", normalized ]
|
|
135
|
+
end
|
|
136
|
+
|
|
106
137
|
CLAUDE = Profile.new(
|
|
107
138
|
name: :claude,
|
|
108
139
|
bin_default: "claude",
|
|
@@ -242,7 +273,45 @@ module AgentCliRuntime
|
|
|
242
273
|
end
|
|
243
274
|
)
|
|
244
275
|
|
|
245
|
-
|
|
276
|
+
OPENCODE = Profile.new(
|
|
277
|
+
name: :opencode,
|
|
278
|
+
bin_default: "opencode",
|
|
279
|
+
env_bin_override_keys: %w[
|
|
280
|
+
AGENT_CLI_RUNTIME_OPENCODE_BIN HIVE_OPENCODE_BIN
|
|
281
|
+
],
|
|
282
|
+
headless_flag: "run",
|
|
283
|
+
output_format_flags: [ "--format", "json" ],
|
|
284
|
+
version_flag: "--version",
|
|
285
|
+
min_version: "1.18.16",
|
|
286
|
+
model_argument_builder: ->(model) { opencode_model_arguments(model) },
|
|
287
|
+
effort_argument_builder:
|
|
288
|
+
->(variant) { opencode_variant_arguments(variant) },
|
|
289
|
+
launcher_identity: "opencode-cli/v1",
|
|
290
|
+
credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
|
|
291
|
+
configuration_environment_key: "OPENCODE_CONFIG_DIR",
|
|
292
|
+
default_configuration_directory: ".config/opencode",
|
|
293
|
+
permission_policy_required: true,
|
|
294
|
+
result_parser: OpenCode::ResultParser,
|
|
295
|
+
cli_capabilities: {
|
|
296
|
+
json_events: [ "run", "--format" ],
|
|
297
|
+
working_directory: [ "run", "--dir" ],
|
|
298
|
+
model_variant: [ "run", "--variant" ],
|
|
299
|
+
pure: [ "run", "--pure" ],
|
|
300
|
+
sanitized_export: [ "export", "--sanitize" ]
|
|
301
|
+
},
|
|
302
|
+
auth_configuration_probe: lambda do |home:, env:|
|
|
303
|
+
if env_configured?(env, *PI_CREDENTIAL_ENVIRONMENT_KEYS)
|
|
304
|
+
AuthConfiguration.new(status: :configured, source: "environment")
|
|
305
|
+
else
|
|
306
|
+
auth_from_file(
|
|
307
|
+
opencode_auth_path(home:, env:),
|
|
308
|
+
source: "opencode auth.json"
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
PROFILES = [ CLAUDE, CODEX, PI, GROK, OPENCODE ].to_h do |profile|
|
|
246
315
|
[ profile.name, profile ]
|
|
247
316
|
end.freeze
|
|
248
317
|
PROVIDER_ORDER = PROFILES.keys.freeze
|
|
@@ -63,9 +63,13 @@ module AgentCliRuntime
|
|
|
63
63
|
compilation_error!(profile, e)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
def prepare!(profile)
|
|
66
|
+
def prepare!(profile, env: ENV)
|
|
67
|
+
if profile.is_a?(OpenCodePreparationRequest)
|
|
68
|
+
return OpenCode::Overlay.prepare!(profile, env:)
|
|
69
|
+
end
|
|
70
|
+
|
|
67
71
|
resolved = Profiles.resolve(profile)
|
|
68
|
-
result = Probe.call(resolved)
|
|
72
|
+
result = Probe.call(resolved, env:)
|
|
69
73
|
return result if result.ready
|
|
70
74
|
|
|
71
75
|
evidence = unsupported_evidence(
|
|
@@ -121,6 +125,16 @@ module AgentCliRuntime
|
|
|
121
125
|
)
|
|
122
126
|
end
|
|
123
127
|
|
|
128
|
+
def parse_run(profile, stdout:)
|
|
129
|
+
Profiles.resolve(profile).parse_run(stdout)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def normalize(profile, captured, requested_route:)
|
|
133
|
+
Profiles.resolve(profile).normalize_captured_result(
|
|
134
|
+
captured, requested_route:
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
124
138
|
def supported_evidence(profile, capability, arguments = [])
|
|
125
139
|
CapabilityEvidence.new(
|
|
126
140
|
capability: capability,
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
module AgentCliRuntime
|
|
2
|
+
OPENCODE_OVERLAY_ENVIRONMENT_KEYS = %w[
|
|
3
|
+
XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME TMPDIR
|
|
4
|
+
OPENCODE_CONFIG OPENCODE_DISABLE_PROJECT_CONFIG
|
|
5
|
+
OPENCODE_DISABLE_CLAUDE_CODE OPENCODE_DISABLE_MODELS_FETCH
|
|
6
|
+
OPENCODE_DISABLE_AUTOUPDATE OPENCODE_PURE
|
|
7
|
+
].freeze
|
|
8
|
+
|
|
2
9
|
module Immutable
|
|
3
10
|
module_function
|
|
4
11
|
|
|
@@ -14,6 +21,28 @@ module AgentCliRuntime
|
|
|
14
21
|
def symbols(values)
|
|
15
22
|
Array(values).map(&:to_sym).uniq.freeze
|
|
16
23
|
end
|
|
24
|
+
|
|
25
|
+
def hash(value)
|
|
26
|
+
unless value.is_a?(Hash)
|
|
27
|
+
raise ArgumentError, "value must be a Hash"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
value.each_with_object({}) do |(key, item), result|
|
|
31
|
+
result[string(key)] = deep(item)
|
|
32
|
+
end.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def deep(value)
|
|
36
|
+
case value
|
|
37
|
+
when Hash then hash(value)
|
|
38
|
+
when Array then value.map { |item| deep(item) }.freeze
|
|
39
|
+
when String then string(value)
|
|
40
|
+
when Symbol, Numeric, TrueClass, FalseClass, NilClass then value
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"unsupported immutable value #{value.class}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
17
46
|
end
|
|
18
47
|
private_constant :Immutable
|
|
19
48
|
|
|
@@ -173,4 +202,407 @@ module AgentCliRuntime
|
|
|
173
202
|
)
|
|
174
203
|
end
|
|
175
204
|
end
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
Route = Data.define(:provider, :model) do
|
|
208
|
+
ROUTE_PATTERN = /\A(?<provider>[a-zA-Z0-9][a-zA-Z0-9._-]*)\/(?<model>[^\s\/][^\s]*)\z/
|
|
209
|
+
|
|
210
|
+
def initialize(provider:, model:)
|
|
211
|
+
normalized_provider = Immutable.string(provider)
|
|
212
|
+
normalized_model = Immutable.string(model)
|
|
213
|
+
route = "#{normalized_provider}/#{normalized_model}"
|
|
214
|
+
unless ROUTE_PATTERN.match?(route) && !normalized_model.include?("\0")
|
|
215
|
+
raise ArgumentError,
|
|
216
|
+
"OpenCode route must be a full provider/model value"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
super(provider: normalized_provider, model: normalized_model)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def self.parse(value)
|
|
223
|
+
match = ROUTE_PATTERN.match(value.to_s)
|
|
224
|
+
unless match
|
|
225
|
+
raise ArgumentError,
|
|
226
|
+
"OpenCode route must be a full provider/model value"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
new(provider: match[:provider], model: match[:model])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def to_s
|
|
233
|
+
"#{provider}/#{model}"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
OpenCodePermissionPolicy = Data.define(:rules) do
|
|
238
|
+
ACTIONS = %w[allow ask deny].freeze
|
|
239
|
+
|
|
240
|
+
def initialize(rules = nil, **keywords)
|
|
241
|
+
value = rules || keywords[:rules] || keywords
|
|
242
|
+
normalized = Immutable.hash(value || {})
|
|
243
|
+
raise ArgumentError, "OpenCode permission policy cannot be empty" if normalized.empty?
|
|
244
|
+
|
|
245
|
+
validate_actions!(normalized)
|
|
246
|
+
super(rules: normalized)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def validate_actions!(value)
|
|
252
|
+
value.each_value do |entry|
|
|
253
|
+
if entry.is_a?(Hash)
|
|
254
|
+
validate_actions!(entry)
|
|
255
|
+
elsif !ACTIONS.include?(entry)
|
|
256
|
+
raise ArgumentError,
|
|
257
|
+
"OpenCode permission action must be allow, ask, or deny"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
OpenCodePreparationRequest = Data.define(
|
|
264
|
+
:request, :working_directory, :invocation_root,
|
|
265
|
+
:configuration_path, :configuration,
|
|
266
|
+
:credential_environment_keys, :credential_file,
|
|
267
|
+
:permission_policy, :additional_read_roots,
|
|
268
|
+
:additional_write_roots, :edit_patterns, :plugins, :pure
|
|
269
|
+
) do
|
|
270
|
+
def initialize(request:, working_directory:, invocation_root:,
|
|
271
|
+
configuration_path: nil, configuration: nil,
|
|
272
|
+
credential_environment_keys: [], credential_file: nil,
|
|
273
|
+
permission_policy: nil, additional_read_roots: [],
|
|
274
|
+
additional_write_roots: [], edit_patterns: [], plugins: [], pure: true)
|
|
275
|
+
unless request.is_a?(Request)
|
|
276
|
+
raise ArgumentError, "request must be an AgentCliRuntime::Request"
|
|
277
|
+
end
|
|
278
|
+
if configuration_path && configuration
|
|
279
|
+
raise ArgumentError,
|
|
280
|
+
"choose configuration_path or configuration, not both"
|
|
281
|
+
end
|
|
282
|
+
if permission_policy &&
|
|
283
|
+
!permission_policy.is_a?(OpenCodePermissionPolicy)
|
|
284
|
+
raise ArgumentError,
|
|
285
|
+
"permission_policy must be an OpenCodePermissionPolicy"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
keys = Immutable.strings(credential_environment_keys)
|
|
289
|
+
invalid = keys.find { |key| !key.match?(/\A[A-Z][A-Z0-9_]*\z/) }
|
|
290
|
+
raise ArgumentError, "invalid credential environment key" if invalid
|
|
291
|
+
raise ArgumentError, "credential environment keys must be unique" if keys.uniq.length != keys.length
|
|
292
|
+
reserved = keys & OPENCODE_OVERLAY_ENVIRONMENT_KEYS
|
|
293
|
+
unless reserved.empty?
|
|
294
|
+
raise ArgumentError,
|
|
295
|
+
"credential environment keys cannot override the OpenCode overlay: #{reserved.join(', ')}"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
super(
|
|
299
|
+
request: request,
|
|
300
|
+
working_directory: Immutable.string(working_directory),
|
|
301
|
+
invocation_root: Immutable.string(invocation_root),
|
|
302
|
+
configuration_path:
|
|
303
|
+
configuration_path.nil? ? nil : Immutable.string(configuration_path),
|
|
304
|
+
configuration:
|
|
305
|
+
configuration.nil? ? nil : Immutable.hash(configuration),
|
|
306
|
+
credential_environment_keys: keys,
|
|
307
|
+
credential_file:
|
|
308
|
+
credential_file.nil? ? nil : Immutable.string(credential_file),
|
|
309
|
+
permission_policy: permission_policy,
|
|
310
|
+
additional_read_roots: Immutable.strings(additional_read_roots),
|
|
311
|
+
additional_write_roots: Immutable.strings(additional_write_roots),
|
|
312
|
+
edit_patterns: Immutable.strings(edit_patterns),
|
|
313
|
+
plugins: Immutable.strings(plugins),
|
|
314
|
+
pure: pure != false
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
ProbeRequest = Data.define(
|
|
320
|
+
:profile, :route, :variant, :environment,
|
|
321
|
+
:credential_environment_keys, :credential_file_staged
|
|
322
|
+
) do
|
|
323
|
+
def initialize(profile:, route:, variant: nil, environment: {},
|
|
324
|
+
credential_environment_keys: [],
|
|
325
|
+
credential_file_staged: false)
|
|
326
|
+
parsed_route = route.is_a?(Route) ? route : Route.parse(route)
|
|
327
|
+
super(
|
|
328
|
+
profile: profile,
|
|
329
|
+
route: parsed_route,
|
|
330
|
+
variant: variant.nil? ? nil : Immutable.string(variant),
|
|
331
|
+
environment: Immutable.hash(environment),
|
|
332
|
+
credential_environment_keys:
|
|
333
|
+
Immutable.strings(credential_environment_keys),
|
|
334
|
+
credential_file_staged: credential_file_staged == true
|
|
335
|
+
)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
RouteProbeResult = Data.define(
|
|
340
|
+
:provider, :ready, :installed, :executable, :version,
|
|
341
|
+
:minimum_version, :auth_configuration, :route,
|
|
342
|
+
:route_available, :available_variants,
|
|
343
|
+
:capability_evidence, :diagnostic
|
|
344
|
+
) do
|
|
345
|
+
def initialize(provider:, ready:, installed:, executable:, version:,
|
|
346
|
+
minimum_version:, auth_configuration:, route:,
|
|
347
|
+
route_available:, available_variants:,
|
|
348
|
+
capability_evidence:, diagnostic: nil)
|
|
349
|
+
super(
|
|
350
|
+
provider: provider.to_sym,
|
|
351
|
+
ready: ready == true,
|
|
352
|
+
installed: installed == true,
|
|
353
|
+
executable: Immutable.string(executable),
|
|
354
|
+
version: version.nil? ? nil : Immutable.string(version),
|
|
355
|
+
minimum_version:
|
|
356
|
+
minimum_version.nil? ? nil : Immutable.string(minimum_version),
|
|
357
|
+
auth_configuration: auth_configuration,
|
|
358
|
+
route: route,
|
|
359
|
+
route_available: route_available == true,
|
|
360
|
+
available_variants: Immutable.strings(available_variants),
|
|
361
|
+
capability_evidence: Array(capability_evidence).freeze,
|
|
362
|
+
diagnostic:
|
|
363
|
+
diagnostic.nil? ? nil : Immutable.string(diagnostic)
|
|
364
|
+
)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
class PreparedInvocation
|
|
369
|
+
attr_reader :invocation, :environment, :credential_environment_keys,
|
|
370
|
+
:invocation_root, :generated_paths, :configuration_path,
|
|
371
|
+
:requested_route, :configuration_source, :probe_result,
|
|
372
|
+
:executable
|
|
373
|
+
|
|
374
|
+
def initialize(invocation:, environment:, credential_environment_keys:,
|
|
375
|
+
invocation_root:, generated_paths:, configuration_path:,
|
|
376
|
+
requested_route:, configuration_source:, probe_result:,
|
|
377
|
+
cleanup:, executable: nil)
|
|
378
|
+
@invocation = invocation
|
|
379
|
+
@environment = Immutable.hash(environment)
|
|
380
|
+
@credential_environment_keys =
|
|
381
|
+
Immutable.strings(credential_environment_keys)
|
|
382
|
+
@invocation_root = Immutable.string(invocation_root)
|
|
383
|
+
@generated_paths = Immutable.strings(generated_paths)
|
|
384
|
+
@configuration_path = Immutable.string(configuration_path)
|
|
385
|
+
@requested_route = requested_route
|
|
386
|
+
@configuration_source =
|
|
387
|
+
configuration_source.nil? ? nil : Immutable.string(configuration_source)
|
|
388
|
+
@probe_result = probe_result
|
|
389
|
+
@executable = Immutable.string(executable || invocation.argv.fetch(0))
|
|
390
|
+
@cleanup = cleanup
|
|
391
|
+
freeze
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def environment_for(env: ENV)
|
|
395
|
+
selected = credential_environment_keys.each_with_object({}) do |key, values|
|
|
396
|
+
value = env[key]
|
|
397
|
+
values[key] = value.to_s unless value.to_s.empty?
|
|
398
|
+
end
|
|
399
|
+
selected.merge(environment).freeze
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def cleanup!
|
|
403
|
+
@cleanup.call
|
|
404
|
+
nil
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
TerminationEvidence = Data.define(
|
|
409
|
+
:exit_code, :timed_out, :cancelled, :signal
|
|
410
|
+
) do
|
|
411
|
+
def initialize(exit_code:, timed_out: false, cancelled: false, signal: nil)
|
|
412
|
+
unless exit_code.nil? || exit_code.is_a?(Integer)
|
|
413
|
+
raise ArgumentError, "exit_code must be an Integer or nil"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
super(
|
|
417
|
+
exit_code: exit_code,
|
|
418
|
+
timed_out: timed_out == true,
|
|
419
|
+
cancelled: cancelled == true,
|
|
420
|
+
signal: signal.nil? ? nil : Immutable.string(signal)
|
|
421
|
+
)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def success?
|
|
425
|
+
!timed_out && !cancelled && signal.nil? && exit_code == 0
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
CapturedResult = Data.define(
|
|
430
|
+
:stdout, :stderr, :termination, :inspection_output
|
|
431
|
+
) do
|
|
432
|
+
def initialize(stdout:, stderr:, termination:, inspection_output: nil)
|
|
433
|
+
unless termination.is_a?(TerminationEvidence)
|
|
434
|
+
raise ArgumentError, "termination must be TerminationEvidence"
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
super(
|
|
438
|
+
stdout: Immutable.string(stdout),
|
|
439
|
+
stderr: Immutable.string(stderr),
|
|
440
|
+
termination: termination,
|
|
441
|
+
inspection_output:
|
|
442
|
+
inspection_output.nil? ? nil : Immutable.string(inspection_output)
|
|
443
|
+
)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
NormalizedUsage = Data.define(
|
|
448
|
+
:input, :output, :cache_read, :cache_write, :reasoning, :cost
|
|
449
|
+
) do
|
|
450
|
+
def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil,
|
|
451
|
+
reasoning: nil, cost: nil)
|
|
452
|
+
super(
|
|
453
|
+
input: number(input, :input, integer: true),
|
|
454
|
+
output: number(output, :output, integer: true),
|
|
455
|
+
cache_read: number(cache_read, :cache_read, integer: true),
|
|
456
|
+
cache_write: number(cache_write, :cache_write, integer: true),
|
|
457
|
+
reasoning: number(reasoning, :reasoning, integer: true),
|
|
458
|
+
cost: number(cost, :cost, integer: false)
|
|
459
|
+
)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def cached
|
|
463
|
+
return nil if cache_read.nil? || cache_write.nil?
|
|
464
|
+
|
|
465
|
+
cache_read + cache_write
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
private
|
|
469
|
+
|
|
470
|
+
def number(value, label, integer:)
|
|
471
|
+
return nil if value.nil?
|
|
472
|
+
valid = integer ? value.is_a?(Integer) : value.is_a?(Numeric)
|
|
473
|
+
valid &&= value.finite? if value.respond_to?(:finite?)
|
|
474
|
+
unless valid && value >= 0
|
|
475
|
+
raise ArgumentError, "#{label} must be a non-negative number or nil"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
value
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
RouteIdentity = Data.define(:requested, :actual, :resolution_status) do
|
|
483
|
+
RESOLUTION_STATUSES = %i[unobserved matched resolved_differently].freeze
|
|
484
|
+
|
|
485
|
+
def initialize(requested:, actual: nil, resolution_status: nil)
|
|
486
|
+
requested_route = requested.is_a?(Route) ? requested : Route.parse(requested)
|
|
487
|
+
actual_route =
|
|
488
|
+
if actual.nil?
|
|
489
|
+
nil
|
|
490
|
+
elsif actual.is_a?(Route)
|
|
491
|
+
actual
|
|
492
|
+
else
|
|
493
|
+
Route.parse(actual)
|
|
494
|
+
end
|
|
495
|
+
status = resolution_status&.to_sym ||
|
|
496
|
+
(actual_route.nil? ? :unobserved :
|
|
497
|
+
(requested_route == actual_route ? :matched : :resolved_differently))
|
|
498
|
+
unless RESOLUTION_STATUSES.include?(status)
|
|
499
|
+
raise ArgumentError, "invalid route resolution status"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
super(
|
|
503
|
+
requested: requested_route,
|
|
504
|
+
actual: actual_route,
|
|
505
|
+
resolution_status: status
|
|
506
|
+
)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
ParsedRun = Data.define(
|
|
511
|
+
:session_id, :terminal_message_id, :terminal_reason,
|
|
512
|
+
:final_message, :final_message_truncated, :preliminary_usage, :unknown_events
|
|
513
|
+
) do
|
|
514
|
+
def initialize(session_id:, terminal_message_id:, terminal_reason:,
|
|
515
|
+
final_message:, preliminary_usage:, unknown_events: [],
|
|
516
|
+
final_message_truncated: false)
|
|
517
|
+
unless preliminary_usage.is_a?(NormalizedUsage)
|
|
518
|
+
raise ArgumentError, "preliminary_usage must be NormalizedUsage"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
super(
|
|
522
|
+
session_id: Immutable.string(session_id),
|
|
523
|
+
terminal_message_id: Immutable.string(terminal_message_id),
|
|
524
|
+
terminal_reason: Immutable.string(terminal_reason),
|
|
525
|
+
final_message: Immutable.string(final_message),
|
|
526
|
+
final_message_truncated: final_message_truncated == true,
|
|
527
|
+
preliminary_usage: preliminary_usage,
|
|
528
|
+
unknown_events: Immutable.strings(unknown_events)
|
|
529
|
+
)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
InspectionCommand = Data.define(
|
|
534
|
+
:argv, :stdin_data, :environment, :credential_environment_keys,
|
|
535
|
+
:session_id, :message_id
|
|
536
|
+
) do
|
|
537
|
+
def initialize(argv:, environment:, credential_environment_keys:,
|
|
538
|
+
session_id:, message_id:, stdin_data: nil)
|
|
539
|
+
super(
|
|
540
|
+
argv: Immutable.strings(argv),
|
|
541
|
+
stdin_data: stdin_data.nil? ? nil : Immutable.string(stdin_data),
|
|
542
|
+
environment: Immutable.hash(environment),
|
|
543
|
+
credential_environment_keys:
|
|
544
|
+
Immutable.strings(credential_environment_keys),
|
|
545
|
+
session_id: Immutable.string(session_id),
|
|
546
|
+
message_id: Immutable.string(message_id)
|
|
547
|
+
)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def environment_for(env: ENV)
|
|
551
|
+
selected = credential_environment_keys.each_with_object({}) do |key, values|
|
|
552
|
+
value = env[key]
|
|
553
|
+
values[key] = value.to_s unless value.to_s.empty?
|
|
554
|
+
end
|
|
555
|
+
selected.merge(environment).freeze
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
NormalizedOutcome = Data.define(
|
|
560
|
+
:provider, :launcher_identity, :kind, :termination,
|
|
561
|
+
:final_message, :final_message_truncated, :identity, :usage, :diagnostic,
|
|
562
|
+
:unknown_events, :session_id, :message_id
|
|
563
|
+
) do
|
|
564
|
+
KINDS = %i[
|
|
565
|
+
completed authentication_failure configuration_failure cli_failure
|
|
566
|
+
malformed_output cancelled timed_out
|
|
567
|
+
].freeze
|
|
568
|
+
|
|
569
|
+
def initialize(provider:, launcher_identity:, kind:, termination:,
|
|
570
|
+
final_message: nil, identity:, usage: nil, diagnostic: nil,
|
|
571
|
+
unknown_events: [], session_id: nil, message_id: nil,
|
|
572
|
+
final_message_truncated: false)
|
|
573
|
+
normalized_kind = kind.to_sym
|
|
574
|
+
unless KINDS.include?(normalized_kind)
|
|
575
|
+
raise ArgumentError, "invalid normalized outcome kind"
|
|
576
|
+
end
|
|
577
|
+
unless termination.is_a?(TerminationEvidence)
|
|
578
|
+
raise ArgumentError, "termination must be TerminationEvidence"
|
|
579
|
+
end
|
|
580
|
+
unless identity.is_a?(RouteIdentity)
|
|
581
|
+
raise ArgumentError, "identity must be RouteIdentity"
|
|
582
|
+
end
|
|
583
|
+
unless usage.nil? || usage.is_a?(NormalizedUsage)
|
|
584
|
+
raise ArgumentError, "usage must be NormalizedUsage or nil"
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
super(
|
|
588
|
+
provider: provider.to_sym,
|
|
589
|
+
launcher_identity: Immutable.string(launcher_identity),
|
|
590
|
+
kind: normalized_kind,
|
|
591
|
+
termination: termination,
|
|
592
|
+
final_message:
|
|
593
|
+
final_message.nil? ? nil : Immutable.string(final_message),
|
|
594
|
+
final_message_truncated: final_message_truncated == true,
|
|
595
|
+
identity: identity,
|
|
596
|
+
usage: usage,
|
|
597
|
+
diagnostic: diagnostic.nil? ? nil : Immutable.string(diagnostic),
|
|
598
|
+
unknown_events: Immutable.strings(unknown_events),
|
|
599
|
+
session_id: session_id.nil? ? nil : Immutable.string(session_id),
|
|
600
|
+
message_id: message_id.nil? ? nil : Immutable.string(message_id)
|
|
601
|
+
)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def completed?
|
|
605
|
+
kind == :completed
|
|
606
|
+
end
|
|
607
|
+
end
|
|
176
608
|
end
|
data/lib/agent_cli_runtime.rb
CHANGED
|
@@ -8,10 +8,14 @@ end
|
|
|
8
8
|
|
|
9
9
|
require "agent_cli_runtime/redactor"
|
|
10
10
|
require "agent_cli_runtime/usage_extractors"
|
|
11
|
+
require "agent_cli_runtime/opencode/result_parser"
|
|
11
12
|
require "agent_cli_runtime/profile"
|
|
12
13
|
require "agent_cli_runtime/profiles"
|
|
13
14
|
require "agent_cli_runtime/probe"
|
|
14
15
|
require "agent_cli_runtime/runtime"
|
|
16
|
+
require "agent_cli_runtime/opencode/probe"
|
|
17
|
+
require "agent_cli_runtime/opencode/overlay"
|
|
18
|
+
require "agent_cli_runtime/opencode/inspection"
|
|
15
19
|
require "agent_cli_runtime/cli"
|
|
16
20
|
|
|
17
21
|
module AgentCliRuntime
|
|
@@ -21,8 +25,8 @@ module AgentCliRuntime
|
|
|
21
25
|
Runtime.compile(request)
|
|
22
26
|
end
|
|
23
27
|
|
|
24
|
-
def prepare!(profile)
|
|
25
|
-
Runtime.prepare!(profile)
|
|
28
|
+
def prepare!(profile, env: ENV)
|
|
29
|
+
Runtime.prepare!(profile, env:)
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def require_capability!(profile, capability)
|
|
@@ -37,8 +41,24 @@ module AgentCliRuntime
|
|
|
37
41
|
Runtime.observe(profile, result)
|
|
38
42
|
end
|
|
39
43
|
|
|
44
|
+
def parse_run(profile, stdout:)
|
|
45
|
+
Runtime.parse_run(profile, stdout:)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def prepare_inspection(prepared, parsed_run)
|
|
49
|
+
OpenCode::Inspection.compile(prepared, parsed_run)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def normalize(profile, captured, requested_route:)
|
|
53
|
+
Runtime.normalize(profile, captured, requested_route:)
|
|
54
|
+
end
|
|
55
|
+
|
|
40
56
|
def probe(profile, home: nil, env: ENV)
|
|
41
|
-
|
|
57
|
+
if profile.is_a?(ProbeRequest)
|
|
58
|
+
OpenCode::Probe.call(profile, env: env)
|
|
59
|
+
else
|
|
60
|
+
Probe.call(profile, home: home, env: env)
|
|
61
|
+
end
|
|
42
62
|
end
|
|
43
63
|
|
|
44
64
|
def probe_all(home: nil, env: ENV)
|