agent-cli-runtime 0.1.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.
@@ -0,0 +1,289 @@
1
+ module AgentCliRuntime
2
+ module Runtime
3
+ module_function
4
+
5
+ def compile(request)
6
+ unless request.is_a?(Request)
7
+ raise ArgumentError,
8
+ "request must be an AgentCliRuntime::Request"
9
+ end
10
+
11
+ profile = Profiles.resolve(request.profile)
12
+ evidence = [ supported_evidence(profile, :headless) ]
13
+ argv = [ request.executable || profile.bin ]
14
+ prompt_style = profile.prompt_style
15
+ argv << profile.headless_flag
16
+ argv << request.prompt if prompt_style == :headless_flag_value
17
+
18
+ argv.concat(
19
+ permission_arguments(
20
+ profile, request.permission_mode, evidence,
21
+ trusted_arguments: request.permission_arguments
22
+ )
23
+ )
24
+ argv.concat(directory_arguments(profile, request, evidence))
25
+ argv.concat(tool_scope_arguments(profile, request, evidence))
26
+ argv.concat(budget_arguments(profile, request.max_budget_usd, evidence))
27
+ argv.concat(identity_arguments(profile, request, evidence))
28
+
29
+ request.capabilities.each do |capability|
30
+ capability_evidence = require_capability!(profile, capability)
31
+ evidence << capability_evidence
32
+ argv.concat(capability_evidence.arguments)
33
+ end
34
+
35
+ unless request.raw_cli_arguments.empty?
36
+ unless profile.raw_cli_arguments_supported?
37
+ unsupported!(
38
+ profile, :raw_cli_arguments,
39
+ "agent profile #{profile.name.inspect} does not accept raw CLI arguments"
40
+ )
41
+ end
42
+ evidence << supported_evidence(
43
+ profile, :raw_cli_arguments, request.raw_cli_arguments
44
+ )
45
+ argv.concat(request.raw_cli_arguments)
46
+ end
47
+ argv.concat(request.trusted_cli_arguments)
48
+ argv.concat(profile.output_format_flags) if request.include_output_format
49
+ argv << (prompt_style == :stdin ? "-" : request.prompt) unless
50
+ prompt_style == :headless_flag_value
51
+
52
+ CompiledInvocation.new(
53
+ argv: request.command_prefix + argv,
54
+ stdin_data: prompt_style == :stdin ? request.prompt : nil,
55
+ provider: profile.name,
56
+ launcher_identity: profile.launcher_identity,
57
+ capability_evidence: evidence
58
+ )
59
+ rescue Error
60
+ raise
61
+ rescue StandardError => e
62
+ profile = Profiles.resolve(request.profile) if request.is_a?(Request)
63
+ compilation_error!(profile, e)
64
+ end
65
+
66
+ def prepare!(profile)
67
+ resolved = Profiles.resolve(profile)
68
+ result = Probe.call(resolved)
69
+ return result if result.ready
70
+
71
+ evidence = unsupported_evidence(
72
+ resolved, :probe, result.diagnostic || "local prerequisites unavailable"
73
+ )
74
+ raise ProbeError.new(
75
+ "agent profile #{resolved.name.inspect} probe failed: #{evidence.diagnostic}",
76
+ evidence: evidence
77
+ )
78
+ end
79
+
80
+ def require_capability!(profile, capability)
81
+ resolved = Profiles.resolve(profile)
82
+ arguments = resolved.require_cli_capability!(capability)
83
+ supported_evidence(resolved, capability, arguments)
84
+ rescue UnknownProvider
85
+ raise
86
+ rescue Error => e
87
+ evidence = unsupported_evidence(
88
+ resolved, capability, Redactor.diagnostic(e)
89
+ )
90
+ raise UnsupportedCapability.new(
91
+ "agent profile #{resolved.name.inspect} cannot provide " \
92
+ "#{capability.to_sym.inspect}: #{evidence.diagnostic}",
93
+ evidence: evidence
94
+ ), cause: e
95
+ end
96
+
97
+ def extract_usage(profile, event)
98
+ resolved = Profiles.resolve(profile)
99
+ begin
100
+ normalize_usage(resolved.extract_usage_event(event))
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ end
105
+
106
+ def observe(profile, result)
107
+ resolved = Profiles.resolve(profile)
108
+ raw = result.is_a?(Hash) ? result : {}
109
+ ObservableResult.new(
110
+ provider: resolved.name,
111
+ launcher_identity: resolved.launcher_identity,
112
+ exit_code: raw[:exit_code],
113
+ timed_out: raw[:timed_out],
114
+ status: raw[:status],
115
+ usage: normalize_usage(raw[:usage]),
116
+ final_message: raw[:final_message],
117
+ diagnostic: Redactor.diagnostic(
118
+ raw[:error_message] || raw[:limit_text]
119
+ )
120
+ )
121
+ end
122
+
123
+ def supported_evidence(profile, capability, arguments = [])
124
+ CapabilityEvidence.new(
125
+ capability: capability,
126
+ supported: true,
127
+ provider: profile.name,
128
+ launcher_identity: profile.launcher_identity,
129
+ arguments: arguments
130
+ )
131
+ end
132
+
133
+ def unsupported_evidence(profile, capability, diagnostic)
134
+ CapabilityEvidence.new(
135
+ capability: capability,
136
+ supported: false,
137
+ provider: profile&.name || :unknown,
138
+ launcher_identity: profile&.launcher_identity || "unknown",
139
+ diagnostic: Redactor.diagnostic(diagnostic)
140
+ )
141
+ end
142
+
143
+ def permission_arguments(profile, permission_mode, evidence,
144
+ trusted_arguments: nil)
145
+ arguments =
146
+ if trusted_arguments
147
+ trusted_arguments
148
+ else
149
+ profile.permission_flags(permission_mode)
150
+ end
151
+ capability =
152
+ permission_mode&.tr("-", "_")&.to_sym || :permission_bypass
153
+ evidence << supported_evidence(profile, capability, arguments)
154
+ arguments
155
+ rescue ArgumentError => e
156
+ unsupported!(
157
+ profile, permission_mode&.tr("-", "_")&.to_sym || :permission, e
158
+ )
159
+ end
160
+ private_class_method :permission_arguments
161
+
162
+ def directory_arguments(profile, request, evidence)
163
+ return [] if request.add_dirs.empty?
164
+
165
+ unless profile.add_dir_flag
166
+ if request.require_add_dirs
167
+ unsupported!(
168
+ profile, :add_directory,
169
+ "agent profile #{profile.name.inspect} cannot constrain additional directories"
170
+ )
171
+ end
172
+ evidence << unsupported_evidence(
173
+ profile, :add_directory,
174
+ "unsupported; directories intentionally omitted"
175
+ )
176
+ return []
177
+ end
178
+
179
+ arguments =
180
+ request.add_dirs.flat_map { |directory| [ profile.add_dir_flag, directory ] }
181
+ evidence << supported_evidence(profile, :add_directory, arguments)
182
+ arguments
183
+ end
184
+ private_class_method :directory_arguments
185
+
186
+ def tool_scope_arguments(profile, request, evidence)
187
+ arguments = []
188
+ {
189
+ allowed_tools: [ request.allowed_tools, :allowed ],
190
+ disallowed_tools: [ request.disallowed_tools, :disallowed ]
191
+ }.each do |capability, (tools, scope)|
192
+ value = tool_csv(tools)
193
+ next unless value
194
+
195
+ flag = profile.tool_scope_flags[scope]
196
+ unless flag
197
+ unsupported!(
198
+ profile, capability,
199
+ "agent profile #{profile.name.inspect} cannot enforce #{scope} tool scope"
200
+ )
201
+ end
202
+ scoped_arguments = [ flag, value ]
203
+ evidence << supported_evidence(profile, capability, scoped_arguments)
204
+ arguments.concat(scoped_arguments)
205
+ end
206
+ arguments
207
+ end
208
+ private_class_method :tool_scope_arguments
209
+
210
+ def budget_arguments(profile, max_budget_usd, evidence)
211
+ return [] if max_budget_usd.nil?
212
+ unless profile.budget_flag
213
+ unsupported!(
214
+ profile, :budget,
215
+ "agent profile #{profile.name.inspect} cannot enforce a native budget"
216
+ )
217
+ end
218
+
219
+ arguments = [ profile.budget_flag, max_budget_usd.to_s ]
220
+ evidence << supported_evidence(profile, :budget, arguments)
221
+ arguments
222
+ end
223
+ private_class_method :budget_arguments
224
+
225
+ def identity_arguments(profile, request, evidence)
226
+ arguments = request.identity_arguments.dup
227
+ return arguments unless request.model || request.effort
228
+ unless request.model
229
+ unsupported!(
230
+ profile, :effort,
231
+ "effort requires an explicit model in an invocation request"
232
+ )
233
+ end
234
+
235
+ identity = profile.identity_arguments(
236
+ model: request.model,
237
+ effort: request.effort,
238
+ pin_model: request.pin_model
239
+ )
240
+ evidence << supported_evidence(profile, :model, identity.native_arguments)
241
+ evidence << supported_evidence(profile, :effort) if request.effort
242
+ arguments.concat(identity.native_arguments)
243
+ rescue UnsupportedCapability => e
244
+ capability = request.effort && !profile.effort_argument_builder ? :effort : :model
245
+ unsupported!(profile, capability, e)
246
+ end
247
+ private_class_method :identity_arguments
248
+
249
+ def tool_csv(tools)
250
+ values = Array(tools).compact.map(&:to_s).reject(&:empty?).uniq
251
+ values.empty? ? nil : values.join(",")
252
+ end
253
+ private_class_method :tool_csv
254
+
255
+ def normalize_usage(usage)
256
+ return nil unless usage.is_a?(Hash)
257
+
258
+ input = usage.key?(:input) ? usage[:input] : usage["input"]
259
+ output = usage.key?(:output) ? usage[:output] : usage["output"]
260
+ cached = usage.key?(:cached) ? usage[:cached] : usage["cached"]
261
+ model = usage.key?(:model) ? usage[:model] : usage["model"]
262
+ {
263
+ input: [ input.to_i, 0 ].max,
264
+ output: [ output.to_i, 0 ].max,
265
+ cached: [ cached.to_i, 0 ].max,
266
+ model: model&.to_s&.dup&.freeze
267
+ }.freeze
268
+ end
269
+ private_class_method :normalize_usage
270
+
271
+ def unsupported!(profile, capability, diagnostic)
272
+ evidence = unsupported_evidence(profile, capability, diagnostic)
273
+ raise UnsupportedCapability.new(
274
+ evidence.diagnostic,
275
+ evidence: evidence
276
+ )
277
+ end
278
+ private_class_method :unsupported!
279
+
280
+ def compilation_error!(profile, error)
281
+ evidence = unsupported_evidence(profile, :compilation, error)
282
+ raise CompilationError.new(
283
+ "agent invocation compilation failed: #{evidence.diagnostic}",
284
+ evidence: evidence
285
+ ), cause: error
286
+ end
287
+ private_class_method :compilation_error!
288
+ end
289
+ end
@@ -0,0 +1,115 @@
1
+ module AgentCliRuntime
2
+ module UsageExtractors
3
+ module_function
4
+
5
+ CLAUDE = lambda do |event|
6
+ next nil unless event.is_a?(Hash)
7
+
8
+ usage = usage_hash(event)
9
+ if event["type"] == "result" && usage
10
+ usage_result(event, usage, model_from(event))
11
+ elsif event["type"] == "stream_event" && usage
12
+ usage_result(event, usage, model_from(event))
13
+ end
14
+ end
15
+
16
+ CODEX = lambda do |event|
17
+ next nil unless event.is_a?(Hash)
18
+
19
+ usage = usage_hash(event)
20
+ usage_result(event, usage, model_from(event)) if usage
21
+ end
22
+
23
+ PI = lambda do |event|
24
+ next nil unless event.is_a?(Hash)
25
+
26
+ usage = usage_hash(event)
27
+ usage_result(event, usage, model_from(event)) if usage
28
+ end
29
+
30
+ GROK = lambda do |event|
31
+ next nil unless event.is_a?(Hash)
32
+
33
+ usage = usage_hash(event)
34
+ usage_result(event, usage, model_from(event)) if usage
35
+ end
36
+
37
+ def usage_hash(event)
38
+ candidates = [
39
+ event["usage"],
40
+ event["token_usage"],
41
+ event.dig("event", "usage"),
42
+ event.dig("event", "message", "usage"),
43
+ event.dig("info", "usage"),
44
+ event.dig("info", "total_token_usage"),
45
+ event.dig("response", "usage"),
46
+ event.dig("item", "usage")
47
+ ]
48
+ candidates.find { |value| value.is_a?(Hash) }
49
+ end
50
+
51
+ def usage_result(event, usage, model)
52
+ {
53
+ input: token_count(
54
+ usage, "input_tokens", "inputTokens", "prompt_tokens", "promptTokens"
55
+ ),
56
+ output: token_count(
57
+ usage, "output_tokens", "outputTokens", "completion_tokens",
58
+ "completionTokens"
59
+ ),
60
+ cached: cached_tokens(usage),
61
+ model: model || model_from_usage(usage) || model_from(event)
62
+ }
63
+ end
64
+
65
+ def cached_tokens(usage)
66
+ direct = token_count(
67
+ usage, "cached", "cached_tokens", "cachedTokens",
68
+ "cached_input_tokens", "cachedInputTokens"
69
+ )
70
+ return direct if direct.positive?
71
+
72
+ token_count(usage, "cache_read_input_tokens", "cacheReadInputTokens") +
73
+ token_count(usage, "cache_creation_input_tokens", "cacheCreationInputTokens") +
74
+ token_count(
75
+ nested_hash(usage, "prompt_tokens_details"), "cached_tokens", "cachedTokens"
76
+ ) +
77
+ token_count(
78
+ nested_hash(usage, "input_tokens_details"), "cached_tokens", "cachedTokens"
79
+ )
80
+ end
81
+
82
+ def token_count(hash, *keys)
83
+ return 0 unless hash.is_a?(Hash)
84
+
85
+ key = keys.find { |candidate| hash.key?(candidate) }
86
+ key ? hash[key].to_i : 0
87
+ end
88
+
89
+ def nested_hash(hash, key)
90
+ value = hash[key]
91
+ value.is_a?(Hash) ? value : {}
92
+ end
93
+
94
+ def model_from(event)
95
+ model_usage = event["modelUsage"]
96
+ if model_usage.is_a?(Hash) && !model_usage.empty?
97
+ return model_usage.keys.first.to_s
98
+ end
99
+
100
+ candidates = [
101
+ event["model"],
102
+ event.dig("message", "model"),
103
+ event.dig("event", "message", "model"),
104
+ event.dig("response", "model"),
105
+ event.dig("item", "model")
106
+ ]
107
+ candidates.find { |value| !value.to_s.empty? }&.to_s
108
+ end
109
+
110
+ def model_from_usage(usage)
111
+ value = usage["model"] || usage["model_name"] || usage["modelName"]
112
+ value.to_s.empty? ? nil : value.to_s
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,174 @@
1
+ module AgentCliRuntime
2
+ module Immutable
3
+ module_function
4
+
5
+ def string(value)
6
+ string = value.to_s
7
+ string.frozen? ? string : string.dup.freeze
8
+ end
9
+
10
+ def strings(values)
11
+ Array(values).map { |value| string(value) }.freeze
12
+ end
13
+
14
+ def symbols(values)
15
+ Array(values).map(&:to_sym).uniq.freeze
16
+ end
17
+ end
18
+ private_constant :Immutable
19
+
20
+ CapabilityEvidence = Data.define(
21
+ :capability, :supported, :provider, :launcher_identity, :arguments, :diagnostic
22
+ ) do
23
+ def initialize(capability:, supported:, provider:, launcher_identity:,
24
+ arguments: [], diagnostic: nil)
25
+ super(
26
+ capability: capability.to_sym,
27
+ supported: supported == true,
28
+ provider: provider.to_sym,
29
+ launcher_identity: Immutable.string(launcher_identity),
30
+ arguments: Immutable.strings(arguments),
31
+ diagnostic: diagnostic.nil? ? nil : Immutable.string(diagnostic)
32
+ )
33
+ end
34
+ end
35
+
36
+ IdentityArguments = Data.define(
37
+ :model, :requested_effort, :effective_effort, :effort_supported,
38
+ :model_pinned, :native_arguments
39
+ ) do
40
+ def initialize(model:, requested_effort:, effective_effort:, effort_supported:,
41
+ model_pinned:, native_arguments:)
42
+ super(
43
+ model: Immutable.string(model),
44
+ requested_effort:
45
+ requested_effort.nil? ? nil : Immutable.string(requested_effort),
46
+ effective_effort:
47
+ effective_effort.nil? ? nil : Immutable.string(effective_effort),
48
+ effort_supported: effort_supported == true,
49
+ model_pinned: model_pinned == true,
50
+ native_arguments: Immutable.strings(native_arguments)
51
+ )
52
+ end
53
+ end
54
+
55
+ Request = Data.define(
56
+ :profile, :prompt, :permission_mode, :permission_arguments,
57
+ :add_dirs, :require_add_dirs,
58
+ :allowed_tools, :disallowed_tools, :max_budget_usd,
59
+ :model, :effort, :pin_model, :identity_arguments,
60
+ :capabilities, :raw_cli_arguments, :trusted_cli_arguments,
61
+ :executable, :command_prefix, :include_output_format
62
+ ) do
63
+ def initialize(profile:, prompt:, permission_mode: nil, permission_arguments: nil,
64
+ add_dirs: [], require_add_dirs: false, allowed_tools: nil,
65
+ disallowed_tools: nil, max_budget_usd: nil, model: nil,
66
+ effort: nil, pin_model: true, identity_arguments: [],
67
+ capabilities: [], raw_cli_arguments: [],
68
+ trusted_cli_arguments: [], executable: nil,
69
+ command_prefix: [], include_output_format: true)
70
+ super(
71
+ profile: profile,
72
+ prompt: Immutable.string(prompt),
73
+ permission_mode:
74
+ permission_mode.nil? ? nil : Immutable.string(permission_mode),
75
+ permission_arguments:
76
+ permission_arguments.nil? ? nil : Immutable.strings(permission_arguments),
77
+ add_dirs: Immutable.strings(add_dirs),
78
+ require_add_dirs: require_add_dirs == true,
79
+ allowed_tools: Immutable.strings(allowed_tools),
80
+ disallowed_tools: Immutable.strings(disallowed_tools),
81
+ max_budget_usd: max_budget_usd,
82
+ model: model.nil? ? nil : Immutable.string(model),
83
+ effort: effort.nil? ? nil : Immutable.string(effort),
84
+ pin_model: pin_model != false,
85
+ identity_arguments: Immutable.strings(identity_arguments),
86
+ capabilities: Immutable.symbols(capabilities),
87
+ raw_cli_arguments: Immutable.strings(raw_cli_arguments),
88
+ trusted_cli_arguments: Immutable.strings(trusted_cli_arguments),
89
+ executable: executable.nil? ? nil : Immutable.string(executable),
90
+ command_prefix: Immutable.strings(command_prefix),
91
+ include_output_format: include_output_format != false
92
+ )
93
+ end
94
+ end
95
+
96
+ CompiledInvocation = Data.define(
97
+ :argv, :stdin_data, :provider, :launcher_identity, :capability_evidence
98
+ ) do
99
+ def initialize(argv:, stdin_data:, provider:, launcher_identity:,
100
+ capability_evidence:)
101
+ super(
102
+ argv: Immutable.strings(argv),
103
+ stdin_data: stdin_data.nil? ? nil : Immutable.string(stdin_data),
104
+ provider: provider.to_sym,
105
+ launcher_identity: Immutable.string(launcher_identity),
106
+ capability_evidence: Array(capability_evidence).freeze
107
+ )
108
+ end
109
+ end
110
+
111
+ AuthConfiguration = Data.define(:status, :source, :diagnostic) do
112
+ STATUSES = %i[configured missing not_checked].freeze
113
+
114
+ def initialize(status:, source: nil, diagnostic: nil)
115
+ normalized_status = status.to_sym
116
+ unless STATUSES.include?(normalized_status)
117
+ raise ArgumentError,
118
+ "unknown auth configuration status #{status.inspect}; valid: #{STATUSES.inspect}"
119
+ end
120
+
121
+ super(
122
+ status: normalized_status,
123
+ source: source.nil? ? nil : Immutable.string(source),
124
+ diagnostic: diagnostic.nil? ? nil : Immutable.string(diagnostic)
125
+ )
126
+ end
127
+
128
+ def configured?
129
+ status == :configured
130
+ end
131
+ end
132
+
133
+ ProbeResult = Data.define(
134
+ :provider, :ready, :installed, :executable, :version, :minimum_version,
135
+ :auth_configuration, :capability_evidence, :diagnostic
136
+ ) do
137
+ def initialize(provider:, ready:, installed:, executable:, version:,
138
+ minimum_version:, auth_configuration:, capability_evidence:,
139
+ diagnostic:)
140
+ super(
141
+ provider: provider.to_sym,
142
+ ready: ready == true,
143
+ installed: installed == true,
144
+ executable: Immutable.string(executable),
145
+ version: version.nil? ? nil : Immutable.string(version),
146
+ minimum_version:
147
+ minimum_version.nil? ? nil : Immutable.string(minimum_version),
148
+ auth_configuration: auth_configuration,
149
+ capability_evidence: Array(capability_evidence).freeze,
150
+ diagnostic: diagnostic.nil? ? nil : Immutable.string(diagnostic)
151
+ )
152
+ end
153
+ end
154
+
155
+ ObservableResult = Data.define(
156
+ :provider, :launcher_identity, :exit_code, :timed_out, :status,
157
+ :usage, :final_message, :diagnostic
158
+ ) do
159
+ def initialize(provider:, launcher_identity:, exit_code:, timed_out:,
160
+ status:, usage:, final_message:, diagnostic:)
161
+ super(
162
+ provider: provider.to_sym,
163
+ launcher_identity: Immutable.string(launcher_identity),
164
+ exit_code: exit_code,
165
+ timed_out: timed_out == true,
166
+ status: status&.to_sym,
167
+ usage: usage&.dup&.freeze,
168
+ final_message:
169
+ final_message.nil? ? nil : Immutable.string(final_message),
170
+ diagnostic: diagnostic.nil? ? nil : Immutable.string(diagnostic)
171
+ )
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,3 @@
1
+ module AgentCliRuntime
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,47 @@
1
+ require "agent_cli_runtime/version"
2
+ require "agent_cli_runtime/errors"
3
+ require "agent_cli_runtime/values"
4
+
5
+ module AgentCliRuntime
6
+ DIAGNOSTIC_BYTES = 512
7
+ end
8
+
9
+ require "agent_cli_runtime/redactor"
10
+ require "agent_cli_runtime/usage_extractors"
11
+ require "agent_cli_runtime/profile"
12
+ require "agent_cli_runtime/profiles"
13
+ require "agent_cli_runtime/probe"
14
+ require "agent_cli_runtime/runtime"
15
+ require "agent_cli_runtime/cli"
16
+
17
+ module AgentCliRuntime
18
+ module_function
19
+
20
+ def compile(request)
21
+ Runtime.compile(request)
22
+ end
23
+
24
+ def prepare!(profile)
25
+ Runtime.prepare!(profile)
26
+ end
27
+
28
+ def require_capability!(profile, capability)
29
+ Runtime.require_capability!(profile, capability)
30
+ end
31
+
32
+ def extract_usage(profile, event)
33
+ Runtime.extract_usage(profile, event)
34
+ end
35
+
36
+ def observe(profile, result)
37
+ Runtime.observe(profile, result)
38
+ end
39
+
40
+ def probe(profile, home: nil, env: ENV)
41
+ Probe.call(profile, home: home, env: env)
42
+ end
43
+
44
+ def probe_all(home: nil, env: ENV)
45
+ Probe.all(home: home, env: env)
46
+ end
47
+ end