ace-llm-providers-cli 0.35.2 → 0.36.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 +9 -0
- data/lib/ace/llm/providers/cli/agy_client.rb +11 -9
- data/lib/ace/llm/providers/cli/claude_code_client.rb +14 -9
- data/lib/ace/llm/providers/cli/claude_oai_client.rb +11 -8
- data/lib/ace/llm/providers/cli/codex_client.rb +14 -9
- data/lib/ace/llm/providers/cli/codex_oai_client.rb +9 -8
- data/lib/ace/llm/providers/cli/gemini_client.rb +10 -9
- data/lib/ace/llm/providers/cli/models/capture_result.rb +197 -0
- data/lib/ace/llm/providers/cli/molecules/process_supervisor.rb +18 -1
- data/lib/ace/llm/providers/cli/molecules/safe_capture.rb +227 -64
- data/lib/ace/llm/providers/cli/open_code_client.rb +11 -8
- data/lib/ace/llm/providers/cli/pi_client.rb +39 -8
- data/lib/ace/llm/providers/cli/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd7b7ee1d350ec4ea77298f5ecd0f137537897291ce0ba1f13949357fb1a0d94
|
|
4
|
+
data.tar.gz: 2ee0526ca547c242ec6346f7db4e1816a31b652ba8c06d8ba98ddff0cf6acfe0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 60660511b2ef85e79054367ccb21560663cdca66714d6c3f501ad0d04e917f058330b4ba8e93c285133832e91edf949a1cd80c6bf348c93ec2657d4e5fd1b044
|
|
7
|
+
data.tar.gz: 67f8baa381e615f7c53eba0df32db3fab86e417f0ef048f99929c8a12f3a9e83f93e9a31df8470e61a8fc35b6e8c7e17681f9c2c905f21dcccf2967ca0ceca59
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.36.0] - 2026-09-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Models::CaptureResult`: typed outcome of one subprocess capture (completed / deadline-exceeded / transport failure / spawn failure) carrying raw streams internally, plus exit status, signal, configured deadline, monotonic elapsed time, and an opaque invocation ID generated before spawn. Evidence leaves only as bounded, redacted excerpts.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- `SafeCapture.call` returns a `CaptureResult` instead of a bare `[stdout, stderr, status]` tuple and no longer raises on deadline expiry; spawn failures (binary not found) become a typed outcome instead of a raw `Errno`. Process-group cleanup, partial-stream retention, and the Linux subreaper supervisor are preserved. Deadline measurement uses the monotonic clock.
|
|
17
|
+
- All CLI provider clients (codex, codexoai, claude, claudeoai, gemini, pi, opencode, agy) consume the typed result and raise `ProviderError`s that carry structured execution evidence; provider-reported auth failures (pi 401) still surface as `AuthenticationError`.
|
|
18
|
+
|
|
10
19
|
## [0.35.2] - 2026-09-29
|
|
11
20
|
|
|
12
21
|
### Changed
|
|
@@ -57,9 +57,10 @@ module Ace
|
|
|
57
57
|
)
|
|
58
58
|
|
|
59
59
|
cmd = build_agy_command(prompt, options)
|
|
60
|
-
|
|
60
|
+
capture = execute_agy_command(cmd, working_dir: working_dir, options: options)
|
|
61
|
+
capture.raise_unless_success
|
|
61
62
|
|
|
62
|
-
parse_agy_response(
|
|
63
|
+
parse_agy_response(capture, prompt)
|
|
63
64
|
rescue => e
|
|
64
65
|
handle_agy_error(e)
|
|
65
66
|
end
|
|
@@ -177,17 +178,18 @@ module Ace
|
|
|
177
178
|
)
|
|
178
179
|
end
|
|
179
180
|
|
|
180
|
-
def parse_agy_response(
|
|
181
|
-
response = if stream_json_output?(stdout)
|
|
182
|
-
parse_stream_json(stdout)
|
|
181
|
+
def parse_agy_response(capture, prompt)
|
|
182
|
+
response = if stream_json_output?(capture.stdout)
|
|
183
|
+
parse_stream_json(capture.stdout)
|
|
183
184
|
else
|
|
184
|
-
parse_json_or_text(stdout)
|
|
185
|
+
parse_json_or_text(capture.stdout)
|
|
185
186
|
end
|
|
186
187
|
|
|
187
188
|
error_message = response["error"].to_s
|
|
188
|
-
run_status = response.fetch("status",
|
|
189
|
-
if
|
|
190
|
-
raise Ace::LLM::ProviderError,
|
|
189
|
+
run_status = response.fetch("status", "SUCCESS")
|
|
190
|
+
if run_status != "SUCCESS"
|
|
191
|
+
raise Ace::LLM::ProviderError,
|
|
192
|
+
build_failure_message(error_message, capture.stderr, capture.stdout, run_status)
|
|
191
193
|
end
|
|
192
194
|
|
|
193
195
|
text = extract_response_text(response)
|
|
@@ -60,15 +60,16 @@ module Ace
|
|
|
60
60
|
working_dir: options[:working_dir],
|
|
61
61
|
subprocess_env: subprocess_env
|
|
62
62
|
)
|
|
63
|
-
|
|
63
|
+
capture = execute_claude_command(
|
|
64
64
|
cmd,
|
|
65
65
|
prompt,
|
|
66
66
|
subprocess_env: subprocess_env,
|
|
67
67
|
working_dir: working_dir,
|
|
68
68
|
subprocess_command_prefix: options[:subprocess_command_prefix]
|
|
69
69
|
)
|
|
70
|
+
capture.raise_unless_success
|
|
70
71
|
|
|
71
|
-
parse_claude_response(
|
|
72
|
+
parse_claude_response(capture, prompt, options)
|
|
72
73
|
rescue => e
|
|
73
74
|
handle_claude_error(e)
|
|
74
75
|
end
|
|
@@ -279,18 +280,20 @@ module Ace
|
|
|
279
280
|
filtered
|
|
280
281
|
end
|
|
281
282
|
|
|
282
|
-
def parse_claude_response(
|
|
283
|
-
unless status.success?
|
|
284
|
-
error_msg = stderr.empty? ? stdout : stderr
|
|
285
|
-
raise
|
|
283
|
+
def parse_claude_response(capture, prompt, options)
|
|
284
|
+
unless capture.status.success?
|
|
285
|
+
error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
|
|
286
|
+
raise capture.provider_error("Claude CLI failed: #{error_msg}")
|
|
286
287
|
end
|
|
287
288
|
|
|
288
289
|
begin
|
|
289
290
|
# Allow duplicate keys to avoid warnings from Claude CLI output
|
|
290
291
|
# Some versions of Claude CLI may return JSON with duplicate keys
|
|
291
|
-
response = JSON.parse(stdout, allow_duplicate_key: true)
|
|
292
|
+
response = JSON.parse(capture.stdout, allow_duplicate_key: true)
|
|
292
293
|
rescue JSON::ParserError => e
|
|
293
|
-
raise
|
|
294
|
+
raise capture.with_no_response_evidence(
|
|
295
|
+
Ace::LLM::ProviderError.new("Failed to parse Claude response: #{e.message}")
|
|
296
|
+
)
|
|
294
297
|
end
|
|
295
298
|
|
|
296
299
|
if response["is_error"]
|
|
@@ -301,7 +304,9 @@ module Ace
|
|
|
301
304
|
text = extract_claude_text(response)
|
|
302
305
|
|
|
303
306
|
if text.strip.empty?
|
|
304
|
-
raise
|
|
307
|
+
raise capture.with_no_response_evidence(
|
|
308
|
+
Ace::LLM::ProviderError.new(build_response_error(response))
|
|
309
|
+
)
|
|
305
310
|
end
|
|
306
311
|
|
|
307
312
|
# Build metadata
|
|
@@ -57,15 +57,16 @@ module Ace
|
|
|
57
57
|
prompt = rewrite_skill_commands(prompt, working_dir: working_dir)
|
|
58
58
|
|
|
59
59
|
cmd = build_claude_command(options)
|
|
60
|
-
|
|
60
|
+
capture = execute_claude_command(
|
|
61
61
|
cmd,
|
|
62
62
|
prompt,
|
|
63
63
|
subprocess_env: subprocess_env,
|
|
64
64
|
working_dir: working_dir,
|
|
65
65
|
subprocess_command_prefix: options[:subprocess_command_prefix]
|
|
66
66
|
)
|
|
67
|
+
capture.raise_unless_success
|
|
67
68
|
|
|
68
|
-
parse_claude_response(
|
|
69
|
+
parse_claude_response(capture, prompt, options)
|
|
69
70
|
rescue => e
|
|
70
71
|
handle_claude_error(e)
|
|
71
72
|
end
|
|
@@ -229,16 +230,18 @@ module Ace
|
|
|
229
230
|
"sonnet"
|
|
230
231
|
end
|
|
231
232
|
|
|
232
|
-
def parse_claude_response(
|
|
233
|
-
unless status.success?
|
|
234
|
-
error_msg = stderr.empty? ? stdout : stderr
|
|
235
|
-
raise
|
|
233
|
+
def parse_claude_response(capture, prompt, options)
|
|
234
|
+
unless capture.status.success?
|
|
235
|
+
error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
|
|
236
|
+
raise capture.provider_error("Claude OAI CLI failed: #{error_msg}")
|
|
236
237
|
end
|
|
237
238
|
|
|
238
239
|
begin
|
|
239
|
-
response = JSON.parse(stdout, allow_duplicate_key: true)
|
|
240
|
+
response = JSON.parse(capture.stdout, allow_duplicate_key: true)
|
|
240
241
|
rescue JSON::ParserError => e
|
|
241
|
-
raise
|
|
242
|
+
raise capture.with_no_response_evidence(
|
|
243
|
+
Ace::LLM::ProviderError.new("Failed to parse Claude OAI response: #{e.message}")
|
|
244
|
+
)
|
|
242
245
|
end
|
|
243
246
|
|
|
244
247
|
text = response["result"] || response["response"] || ""
|
|
@@ -83,8 +83,9 @@ module Ace
|
|
|
83
83
|
completed = false
|
|
84
84
|
result = nil
|
|
85
85
|
begin
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
capture = execute_codex_command(cmd, prompt, execution_options)
|
|
87
|
+
capture.raise_unless_success
|
|
88
|
+
result = parse_codex_response(capture, prompt, execution_options)
|
|
88
89
|
completed = true
|
|
89
90
|
result
|
|
90
91
|
ensure
|
|
@@ -320,10 +321,10 @@ module Ace
|
|
|
320
321
|
)
|
|
321
322
|
end
|
|
322
323
|
|
|
323
|
-
def parse_codex_response(
|
|
324
|
-
unless status.success?
|
|
325
|
-
error_msg = stderr.empty? ? stdout : stderr
|
|
326
|
-
raise
|
|
324
|
+
def parse_codex_response(capture, prompt, options)
|
|
325
|
+
unless capture.status.success?
|
|
326
|
+
error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
|
|
327
|
+
raise capture.provider_error("Codex CLI failed: #{error_msg}")
|
|
327
328
|
end
|
|
328
329
|
|
|
329
330
|
# The native last-message file contains only the final response,
|
|
@@ -331,7 +332,11 @@ module Ace
|
|
|
331
332
|
last_message_path = options[:last_message_file]
|
|
332
333
|
if last_message_path
|
|
333
334
|
text = File.file?(last_message_path) ? File.read(last_message_path).strip : ""
|
|
334
|
-
|
|
335
|
+
if text.empty?
|
|
336
|
+
raise capture.with_no_response_evidence(
|
|
337
|
+
Ace::LLM::ProviderError.new("Codex CLI produced no final message")
|
|
338
|
+
)
|
|
339
|
+
end
|
|
335
340
|
|
|
336
341
|
return {text: text, metadata: build_synthetic_metadata(text, prompt)}
|
|
337
342
|
end
|
|
@@ -340,7 +345,7 @@ module Ace
|
|
|
340
345
|
# only when a real CLI banner is present, not when report prose
|
|
341
346
|
# happens to contain a standalone `codex` line.
|
|
342
347
|
# Codex output includes metadata lines and the actual response
|
|
343
|
-
lines = stdout.split("\n")
|
|
348
|
+
lines = capture.stdout.split("\n")
|
|
344
349
|
|
|
345
350
|
has_cli_banner = lines.first(20).any? { |line| line.start_with?("OpenAI Codex v") }
|
|
346
351
|
response_start = lines.find_index { |line| line.strip == "codex" } if has_cli_banner
|
|
@@ -353,7 +358,7 @@ module Ace
|
|
|
353
358
|
text = response_lines.join("\n").strip
|
|
354
359
|
else
|
|
355
360
|
# Fallback: use entire output if we can't parse the format
|
|
356
|
-
text = stdout.strip
|
|
361
|
+
text = capture.stdout.strip
|
|
357
362
|
end
|
|
358
363
|
|
|
359
364
|
# Build metadata
|
|
@@ -58,9 +58,10 @@ module Ace
|
|
|
58
58
|
prompt = rewrite_skill_commands(prompt, working_dir: working_dir)
|
|
59
59
|
|
|
60
60
|
cmd = build_codex_oai_command(prompt, options, working_dir: working_dir)
|
|
61
|
-
|
|
61
|
+
capture = execute_codex_command(cmd, prompt, options)
|
|
62
|
+
capture.raise_unless_success
|
|
62
63
|
|
|
63
|
-
parse_codex_response(
|
|
64
|
+
parse_codex_response(capture, prompt, options)
|
|
64
65
|
rescue => e
|
|
65
66
|
handle_codex_error(e)
|
|
66
67
|
end
|
|
@@ -203,13 +204,13 @@ module Ace
|
|
|
203
204
|
)
|
|
204
205
|
end
|
|
205
206
|
|
|
206
|
-
def parse_codex_response(
|
|
207
|
-
unless status.success?
|
|
208
|
-
error_msg = stderr.empty? ? stdout : stderr
|
|
209
|
-
raise
|
|
207
|
+
def parse_codex_response(capture, prompt, options)
|
|
208
|
+
unless capture.status.success?
|
|
209
|
+
error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
|
|
210
|
+
raise capture.provider_error("Codex OAI CLI failed: #{error_msg}")
|
|
210
211
|
end
|
|
211
212
|
|
|
212
|
-
lines = stdout.split("\n")
|
|
213
|
+
lines = capture.stdout.split("\n")
|
|
213
214
|
response_start = lines.find_index { |line| line.include?("codex") }
|
|
214
215
|
|
|
215
216
|
if response_start && response_start < lines.length - 1
|
|
@@ -217,7 +218,7 @@ module Ace
|
|
|
217
218
|
response_lines = response_lines.reject { |line| line.include?("tokens used") }
|
|
218
219
|
text = response_lines.join("\n").strip
|
|
219
220
|
else
|
|
220
|
-
text = stdout.strip
|
|
221
|
+
text = capture.stdout.strip
|
|
221
222
|
end
|
|
222
223
|
|
|
223
224
|
metadata = build_synthetic_metadata(text, prompt)
|
|
@@ -56,9 +56,10 @@ module Ace
|
|
|
56
56
|
prompt = format_messages_as_prompt(messages)
|
|
57
57
|
|
|
58
58
|
cmd = build_gemini_command(prompt, options)
|
|
59
|
-
|
|
59
|
+
capture = execute_gemini_command(cmd, prompt, options)
|
|
60
|
+
capture.raise_unless_success
|
|
60
61
|
|
|
61
|
-
parse_gemini_response(
|
|
62
|
+
parse_gemini_response(capture, prompt, options)
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
# List available Gemini models
|
|
@@ -274,15 +275,15 @@ module Ace
|
|
|
274
275
|
)
|
|
275
276
|
end
|
|
276
277
|
|
|
277
|
-
def parse_gemini_response(
|
|
278
|
-
unless status.success?
|
|
279
|
-
error_msg = stderr.empty? ? stdout : stderr
|
|
280
|
-
raise
|
|
278
|
+
def parse_gemini_response(capture, prompt, options)
|
|
279
|
+
unless capture.status.success?
|
|
280
|
+
error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
|
|
281
|
+
raise capture.provider_error("Gemini CLI failed: #{error_msg}")
|
|
281
282
|
end
|
|
282
283
|
|
|
283
284
|
# Try to parse JSON output first
|
|
284
285
|
begin
|
|
285
|
-
parsed = JSON.parse(stdout)
|
|
286
|
+
parsed = JSON.parse(capture.stdout)
|
|
286
287
|
|
|
287
288
|
# Extract response text from parsed JSON
|
|
288
289
|
# Gemini CLI JSON format: { "response": "...", "stats": { ... } }
|
|
@@ -292,14 +293,14 @@ module Ace
|
|
|
292
293
|
parsed["candidates"].first["content"] || parsed["candidates"].first["text"]
|
|
293
294
|
else
|
|
294
295
|
# Fallback to raw output if JSON structure unexpected
|
|
295
|
-
stdout.strip
|
|
296
|
+
capture.stdout.strip
|
|
296
297
|
end
|
|
297
298
|
|
|
298
299
|
# Extract metadata from stats if available
|
|
299
300
|
metadata = extract_metadata_from_json(parsed, prompt)
|
|
300
301
|
rescue JSON::ParserError
|
|
301
302
|
# Fallback to raw text output if JSON parsing fails
|
|
302
|
-
text = stdout.strip
|
|
303
|
+
text = capture.stdout.strip
|
|
303
304
|
metadata = build_synthetic_metadata(text, prompt)
|
|
304
305
|
end
|
|
305
306
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require "ace/llm/models/execution_evidence"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module LLM
|
|
9
|
+
module Providers
|
|
10
|
+
module CLI
|
|
11
|
+
module Models
|
|
12
|
+
# Typed outcome of one SafeCapture subprocess invocation.
|
|
13
|
+
#
|
|
14
|
+
# Carries the raw captured streams and the process facts (outcome kind,
|
|
15
|
+
# exit/signal, configured deadline, monotonic elapsed time, invocation
|
|
16
|
+
# correlation ID) so classification downstream can key on evidence
|
|
17
|
+
# instead of error-message prose. Raw streams stay internal to this
|
|
18
|
+
# object; only bounded excerpts leave through #execution_evidence.
|
|
19
|
+
#
|
|
20
|
+
# This is a model - pure immutable data.
|
|
21
|
+
class CaptureResult
|
|
22
|
+
OUTCOME_COMPLETED = :completed
|
|
23
|
+
OUTCOME_DEADLINE_EXCEEDED = :deadline_exceeded
|
|
24
|
+
OUTCOME_TRANSPORT_FAILURE = :transport_failure
|
|
25
|
+
OUTCOME_SPAWN_FAILURE = :spawn_failure
|
|
26
|
+
|
|
27
|
+
MAX_MESSAGE_LENGTH = 2000
|
|
28
|
+
|
|
29
|
+
# Generate the correlation ID for an invocation before spawning.
|
|
30
|
+
# @return [String] opaque 8-character ID
|
|
31
|
+
def self.next_invocation_id
|
|
32
|
+
SecureRandom.alphanumeric(8)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param outcome [Symbol] one of the OUTCOME_* constants
|
|
36
|
+
# @param stdout [String] captured stdout (empty when nothing captured)
|
|
37
|
+
# @param stderr [String] captured stderr (empty when nothing captured)
|
|
38
|
+
# @param status [Process::Status, nil] exit status (nil when the process never exited on its own)
|
|
39
|
+
# @param signal [String, nil] terminating signal name (derived from status when not given)
|
|
40
|
+
# @param provider_name [String] human-facing provider label
|
|
41
|
+
# @param invocation_id [String] correlation ID generated before spawn
|
|
42
|
+
# @param deadline_seconds [Numeric, nil] configured deadline
|
|
43
|
+
# @param elapsed_seconds [Numeric, nil] monotonic elapsed time
|
|
44
|
+
# @param spawn_error [Exception, nil] exception that prevented spawning
|
|
45
|
+
def initialize(outcome:, stdout: "", stderr: "", status: nil, signal: nil, provider_name: "CLI",
|
|
46
|
+
invocation_id: Models::CaptureResult.next_invocation_id, deadline_seconds: nil,
|
|
47
|
+
elapsed_seconds: nil, spawn_error: nil)
|
|
48
|
+
@outcome = outcome
|
|
49
|
+
@stdout = stdout.to_s
|
|
50
|
+
@stderr = stderr.to_s
|
|
51
|
+
@status = status
|
|
52
|
+
@signal = signal
|
|
53
|
+
@provider_name = provider_name
|
|
54
|
+
@invocation_id = invocation_id
|
|
55
|
+
@deadline_seconds = deadline_seconds
|
|
56
|
+
@elapsed_seconds = elapsed_seconds
|
|
57
|
+
@spawn_error = spawn_error
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_reader :outcome, :stdout, :stderr, :status, :signal, :provider_name,
|
|
62
|
+
:invocation_id, :deadline_seconds, :elapsed_seconds, :spawn_error
|
|
63
|
+
|
|
64
|
+
# True when the process ran to its own completion and exited zero.
|
|
65
|
+
def success?
|
|
66
|
+
outcome == OUTCOME_COMPLETED && status&.success? ? true : false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# True when the provider process was actually spawned. Failed spawns
|
|
70
|
+
# never began execution, so replaying them cannot repeat side effects.
|
|
71
|
+
def execution_began?
|
|
72
|
+
outcome != OUTCOME_SPAWN_FAILURE
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def exit_status
|
|
76
|
+
status&.exitstatus
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def signal
|
|
80
|
+
return @signal if @signal
|
|
81
|
+
return nil if outcome == OUTCOME_SPAWN_FAILURE
|
|
82
|
+
|
|
83
|
+
termsig = status&.termsig
|
|
84
|
+
termsig ? (Signal.list.key(termsig) || "signal #{termsig}") : nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Structured evidence for classification, with bounded output excerpts.
|
|
88
|
+
# @return [Ace::LLM::Models::ExecutionEvidence]
|
|
89
|
+
def execution_evidence
|
|
90
|
+
Ace::LLM::Models::ExecutionEvidence.new(
|
|
91
|
+
outcome: evidence_outcome,
|
|
92
|
+
invocation_id: invocation_id,
|
|
93
|
+
provider_name: provider_name,
|
|
94
|
+
deadline_seconds: deadline_seconds,
|
|
95
|
+
elapsed_seconds: elapsed_seconds,
|
|
96
|
+
exit_status: exit_status,
|
|
97
|
+
signal: signal,
|
|
98
|
+
execution_began: execution_began?,
|
|
99
|
+
stdout_excerpt: stdout,
|
|
100
|
+
stderr_excerpt: stderr
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Build the ProviderError describing this outcome, with evidence attached.
|
|
105
|
+
# Explicit caller messages can embed raw CLI output, so they are
|
|
106
|
+
# redacted and bounded here — never trusted as-is.
|
|
107
|
+
# @return [Ace::LLM::ProviderError]
|
|
108
|
+
def provider_error(message = nil)
|
|
109
|
+
text = Ace::LLM::Models::ExecutionEvidence.redact(message || default_error_message)
|
|
110
|
+
text = "#{text[0, MAX_MESSAGE_LENGTH]}…[truncated]" if text.length > MAX_MESSAGE_LENGTH
|
|
111
|
+
error = Ace::LLM::ProviderError.new(text)
|
|
112
|
+
error.execution_evidence = execution_evidence
|
|
113
|
+
error
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Raise ProviderError unless the invocation completed with exit 0.
|
|
117
|
+
# Deadline expiry, transport failure, spawn failure, and nonzero exit
|
|
118
|
+
# each surface as a distinct, evidence-carrying error.
|
|
119
|
+
def raise_unless_success
|
|
120
|
+
raise provider_error unless success?
|
|
121
|
+
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Attach "session ended without a final response" evidence to an error
|
|
126
|
+
# raised while parsing a completed (exit-zero) capture — e.g. a CLI
|
|
127
|
+
# that exited cleanly but produced no final message. The message is
|
|
128
|
+
# redacted before being carried forward.
|
|
129
|
+
# @param error [Ace::LLM::ProviderError]
|
|
130
|
+
# @return [Ace::LLM::ProviderError]
|
|
131
|
+
def with_no_response_evidence(error)
|
|
132
|
+
message = Ace::LLM::Models::ExecutionEvidence.redact(error.message.to_s)
|
|
133
|
+
message = "#{message[0, MAX_MESSAGE_LENGTH]}…[truncated]" if message.length > MAX_MESSAGE_LENGTH
|
|
134
|
+
redacted = error.exception(message)
|
|
135
|
+
evidence = Ace::LLM::Models::ExecutionEvidence.new(
|
|
136
|
+
outcome: :no_response,
|
|
137
|
+
invocation_id: invocation_id,
|
|
138
|
+
provider_name: provider_name,
|
|
139
|
+
deadline_seconds: deadline_seconds,
|
|
140
|
+
elapsed_seconds: elapsed_seconds,
|
|
141
|
+
exit_status: exit_status,
|
|
142
|
+
execution_began: true,
|
|
143
|
+
stdout_excerpt: stdout,
|
|
144
|
+
stderr_excerpt: stderr
|
|
145
|
+
)
|
|
146
|
+
redacted.execution_evidence ||= evidence
|
|
147
|
+
redacted
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def evidence_outcome
|
|
153
|
+
case outcome
|
|
154
|
+
when OUTCOME_COMPLETED then :nonzero_exit
|
|
155
|
+
when OUTCOME_DEADLINE_EXCEEDED then :deadline_exceeded
|
|
156
|
+
when OUTCOME_TRANSPORT_FAILURE, OUTCOME_SPAWN_FAILURE then :transport_failure
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def default_error_message
|
|
161
|
+
case outcome
|
|
162
|
+
when OUTCOME_DEADLINE_EXCEEDED
|
|
163
|
+
"#{provider_name} CLI execution exceeded its #{format_seconds(deadline_seconds)} deadline"
|
|
164
|
+
when OUTCOME_TRANSPORT_FAILURE
|
|
165
|
+
"#{provider_name} CLI session ended unexpectedly" + (signal ? " (#{signal})" : "")
|
|
166
|
+
when OUTCOME_SPAWN_FAILURE
|
|
167
|
+
"#{provider_name} CLI could not be started: #{spawn_error_class}"
|
|
168
|
+
else
|
|
169
|
+
base = "#{provider_name} CLI failed with exit status #{exit_status}"
|
|
170
|
+
(detail = failure_detail) ? "#{base}: #{detail}" : base
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# First non-empty captured line, bounded and redacted — keeps
|
|
175
|
+
# actionable CLI diagnostics in the error without leaking
|
|
176
|
+
# transcripts or credentials.
|
|
177
|
+
def failure_detail
|
|
178
|
+
source = [stderr, stdout].map { |stream| stream.to_s.lines.map(&:strip) }.flatten
|
|
179
|
+
detail = source.reject(&:empty?).first.to_s
|
|
180
|
+
detail = Ace::LLM::Models::ExecutionEvidence.redact(detail)
|
|
181
|
+
detail = "#{detail[0, 300]}…[truncated]" if detail.length > 300
|
|
182
|
+
detail.empty? ? nil : detail
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def spawn_error_class
|
|
186
|
+
spawn_error ? spawn_error.class.name : "unknown error"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def format_seconds(value)
|
|
190
|
+
value.is_a?(Numeric) ? format("%.1fs", value) : value.to_s
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -17,7 +17,15 @@ module Ace
|
|
|
17
17
|
def run(command)
|
|
18
18
|
enable_child_subreaper
|
|
19
19
|
ready_fd = Integer(ENV.delete("ACE_SAFE_CAPTURE_READY_FD"))
|
|
20
|
-
command_pid =
|
|
20
|
+
command_pid = begin
|
|
21
|
+
Process.spawn(*command, pgroup: true, ready_fd => :close)
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
# The child never started: tell the capture through the
|
|
24
|
+
# readiness pipe so it records a spawn failure, not an
|
|
25
|
+
# executed (nonzero-exit) child session.
|
|
26
|
+
report_spawn_failure(ready_fd, e)
|
|
27
|
+
raise
|
|
28
|
+
end
|
|
21
29
|
command_pgid = command_pid
|
|
22
30
|
IO.for_fd(ready_fd).tap { |io| io.write("1"); io.close }
|
|
23
31
|
|
|
@@ -30,6 +38,15 @@ module Ace
|
|
|
30
38
|
exit(status.exitstatus || 128 + status.termsig)
|
|
31
39
|
end
|
|
32
40
|
|
|
41
|
+
def report_spawn_failure(ready_fd, error)
|
|
42
|
+
IO.for_fd(ready_fd).tap do |io|
|
|
43
|
+
io.write("S#{error.class.name}: #{error.message}")
|
|
44
|
+
io.close
|
|
45
|
+
end
|
|
46
|
+
rescue SystemCallError, IOError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
33
50
|
def enable_child_subreaper
|
|
34
51
|
previous_deprecated = Warning[:deprecated]
|
|
35
52
|
Warning[:deprecated] = false
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "rbconfig"
|
|
5
5
|
|
|
6
|
+
require_relative "../models/capture_result"
|
|
7
|
+
|
|
6
8
|
module Ace
|
|
7
9
|
module LLM
|
|
8
10
|
module Providers
|
|
@@ -17,6 +19,10 @@ module Ace
|
|
|
17
19
|
# Uses Open3.popen3 and, on Linux, a dedicated child-subreaper supervisor
|
|
18
20
|
# so the complete command tree is terminated and reaped without thread
|
|
19
21
|
# interruption or closed-stream races.
|
|
22
|
+
#
|
|
23
|
+
# Returns a typed Models::CaptureResult instead of a bare tuple so
|
|
24
|
+
# callers can distinguish completed runs from deadline expiry, transport
|
|
25
|
+
# failure, and spawn failure, and carry that evidence into errors.
|
|
20
26
|
class SafeCapture
|
|
21
27
|
# @param cmd [Array<String>] Command arguments
|
|
22
28
|
# @param timeout [Integer] Timeout in seconds
|
|
@@ -26,85 +32,238 @@ module Ace
|
|
|
26
32
|
# @param provider_name [String] Provider name for error messages
|
|
27
33
|
# @param isolate_process_group [Boolean] Spawn subprocess in isolated process group
|
|
28
34
|
# @param cleanup_group_on_exit [Boolean] Clean up descendants on success
|
|
29
|
-
# @return [
|
|
30
|
-
# @raise [Ace::LLM::ProviderError] on timeout
|
|
35
|
+
# @return [Models::CaptureResult] typed capture outcome with raw streams
|
|
31
36
|
def self.call(cmd, timeout:, stdin_data: nil, chdir: nil, env: nil, provider_name: "CLI",
|
|
32
37
|
command_prefix: nil,
|
|
33
38
|
isolate_process_group: true, cleanup_group_on_exit: true)
|
|
34
39
|
normalized_timeout = normalize_timeout(timeout)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
40
|
+
invocation_id = Models::CaptureResult.next_invocation_id
|
|
41
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
|
|
43
|
+
spawn_result = begin
|
|
44
|
+
spawn_subprocess(
|
|
45
|
+
cmd, chdir: chdir, env: env, command_prefix: command_prefix,
|
|
46
|
+
isolate_process_group: isolate_process_group,
|
|
47
|
+
cleanup_group_on_exit: cleanup_group_on_exit
|
|
48
|
+
)
|
|
49
|
+
rescue SystemCallError => e
|
|
50
|
+
return spawn_failure_result(
|
|
51
|
+
e, provider_name: provider_name, invocation_id: invocation_id,
|
|
52
|
+
deadline_seconds: normalized_timeout, started_at: started_at
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
run_capture(
|
|
58
|
+
spawn_result,
|
|
59
|
+
provider_name: provider_name,
|
|
60
|
+
stdin_data: stdin_data,
|
|
61
|
+
isolate_process_group: isolate_process_group,
|
|
62
|
+
cleanup_group_on_exit: cleanup_group_on_exit,
|
|
63
|
+
normalized_timeout: normalized_timeout,
|
|
64
|
+
invocation_id: invocation_id,
|
|
65
|
+
started_at: started_at
|
|
66
|
+
)
|
|
67
|
+
rescue SystemCallError => e
|
|
68
|
+
# popen3 raises before the capture block runs when the command
|
|
69
|
+
# cannot be spawned (e.g. binary not found).
|
|
70
|
+
spawn_failure_result(
|
|
71
|
+
e, provider_name: provider_name, invocation_id: invocation_id,
|
|
72
|
+
deadline_seconds: normalized_timeout, started_at: started_at
|
|
73
|
+
)
|
|
74
|
+
ensure
|
|
75
|
+
spawn_result.close_setup_handles
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class << self
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
SpawnSetup = Struct.new(:args, :opts, :supervised, :ready_reader, :ready_writer, :readiness,
|
|
83
|
+
keyword_init: true) do
|
|
84
|
+
def supervised?
|
|
85
|
+
supervised
|
|
61
86
|
end
|
|
62
|
-
stdin.close
|
|
63
87
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
# Read the supervisor's readiness message (small, bounded; the
|
|
89
|
+
# supervisor closes the pipe after writing). "1" means the child
|
|
90
|
+
# group spawned; "S<detail>" reports a child spawn failure.
|
|
91
|
+
def activate_streams
|
|
92
|
+
ready_writer&.close
|
|
93
|
+
self.readiness = ready_reader&.read
|
|
94
|
+
ready_reader&.close
|
|
95
|
+
end
|
|
68
96
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
wait_thr.join(5)
|
|
77
|
-
end
|
|
97
|
+
# StandardError describing a supervisor-reported child spawn
|
|
98
|
+
# failure, or nil when the child group started normally.
|
|
99
|
+
def child_spawn_failure
|
|
100
|
+
return nil if supervised != true || readiness.nil.
|
|
101
|
+
|
|
102
|
+
message = readiness.to_s
|
|
103
|
+
return nil unless message.start_with?("S")
|
|
78
104
|
|
|
79
|
-
|
|
80
|
-
stderr.close unless stderr.closed?
|
|
81
|
-
out_reader.join(1)
|
|
82
|
-
err_reader.join(1)
|
|
83
|
-
out_reader.kill if out_reader.alive?
|
|
84
|
-
err_reader.kill if err_reader.alive?
|
|
85
|
-
raise Ace::LLM::ProviderError,
|
|
86
|
-
"#{provider_name} CLI execution timed out after #{normalized_timeout} seconds"
|
|
105
|
+
StandardError.new("child process failed to start: #{message[1..].strip}")
|
|
87
106
|
end
|
|
88
107
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
def close_setup_handles
|
|
109
|
+
[ready_reader, ready_writer].each do |io|
|
|
110
|
+
next unless io
|
|
111
|
+
|
|
112
|
+
io.close unless io.closed?
|
|
113
|
+
end
|
|
92
114
|
end
|
|
115
|
+
end
|
|
116
|
+
private_constant :SpawnSetup
|
|
93
117
|
|
|
94
|
-
|
|
118
|
+
def spawn_failure_result(error, provider_name:, invocation_id:, deadline_seconds:, started_at:)
|
|
119
|
+
Models::CaptureResult.new(
|
|
120
|
+
outcome: Models::CaptureResult::OUTCOME_SPAWN_FAILURE,
|
|
121
|
+
provider_name: provider_name,
|
|
122
|
+
invocation_id: invocation_id,
|
|
123
|
+
deadline_seconds: deadline_seconds,
|
|
124
|
+
elapsed_seconds: elapsed_since(started_at),
|
|
125
|
+
spawn_error: error
|
|
126
|
+
)
|
|
95
127
|
end
|
|
96
|
-
ensure
|
|
97
|
-
ready_reader&.close unless ready_reader&.closed?
|
|
98
|
-
ready_writer&.close unless ready_writer&.closed?
|
|
99
|
-
end
|
|
100
128
|
|
|
101
|
-
|
|
102
|
-
|
|
129
|
+
def run_capture(spawn_result, provider_name:, stdin_data:, isolate_process_group:,
|
|
130
|
+
cleanup_group_on_exit:, normalized_timeout:, invocation_id:, started_at:)
|
|
131
|
+
Open3.popen3(*spawn_result.args, **spawn_result.opts) do |stdin, stdout, stderr, wait_thr|
|
|
132
|
+
spawn_result.activate_streams
|
|
133
|
+
if (spawn_failure = spawn_result.child_spawn_failure)
|
|
134
|
+
return Models::CaptureResult.new(
|
|
135
|
+
outcome: Models::CaptureResult::OUTCOME_SPAWN_FAILURE,
|
|
136
|
+
provider_name: provider_name,
|
|
137
|
+
invocation_id: invocation_id,
|
|
138
|
+
deadline_seconds: normalized_timeout,
|
|
139
|
+
elapsed_seconds: elapsed_since(started_at),
|
|
140
|
+
spawn_error: spawn_failure
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
pid = wait_thr.pid
|
|
145
|
+
pgid = safe_getpgid(pid)
|
|
146
|
+
debug_log(provider_name, "spawn pid=#{pid} pgid=#{pgid || "n/a"} invocation=#{invocation_id}")
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
stdin.write(stdin_data) if stdin_data
|
|
150
|
+
rescue Errno::EPIPE
|
|
151
|
+
# Subprocess exited before consuming stdin — continue to capture stderr for the real error
|
|
152
|
+
end
|
|
153
|
+
stdin.close
|
|
103
154
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
155
|
+
partial_stdout = +""
|
|
156
|
+
partial_stderr = +""
|
|
157
|
+
out_reader = Thread.new { safe_read_stream(stdout, partial_stdout) }
|
|
158
|
+
err_reader = Thread.new { safe_read_stream(stderr, partial_stderr) }
|
|
159
|
+
out_reader.report_on_exception = false
|
|
160
|
+
err_reader.report_on_exception = false
|
|
161
|
+
|
|
162
|
+
unless wait_thr.join(normalized_timeout)
|
|
163
|
+
# Deadline exceeded: kill subprocess group (and descendants), retain partial streams
|
|
164
|
+
terminate_subprocess_tree(
|
|
165
|
+
pid: pid, pgid: pgid, provider_name: provider_name, supervised: spawn_result.supervised?
|
|
166
|
+
)
|
|
167
|
+
unless wait_thr.join(5)
|
|
168
|
+
terminate_group_or_pid("KILL", pid, pgid)
|
|
169
|
+
wait_thr.join(5)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
return Models::CaptureResult.new(
|
|
173
|
+
outcome: Models::CaptureResult::OUTCOME_DEADLINE_EXCEEDED,
|
|
174
|
+
stdout: drain_reader(out_reader, stdout, partial_stdout),
|
|
175
|
+
stderr: drain_reader(err_reader, stderr, partial_stderr),
|
|
176
|
+
provider_name: provider_name,
|
|
177
|
+
invocation_id: invocation_id,
|
|
178
|
+
deadline_seconds: normalized_timeout,
|
|
179
|
+
elapsed_seconds: elapsed_since(started_at)
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
status = wait_thr.value
|
|
184
|
+
if isolate_process_group && cleanup_group_on_exit && !spawn_result.supervised?
|
|
185
|
+
terminate_descendants_after_success(pid: pid, pgid: pgid, provider_name: provider_name)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
outcome = if status.exited?
|
|
189
|
+
Models::CaptureResult::OUTCOME_COMPLETED
|
|
190
|
+
else
|
|
191
|
+
# Terminated by a signal we did not send: the session ended
|
|
192
|
+
# abruptly rather than the CLI finishing on its own.
|
|
193
|
+
Models::CaptureResult::OUTCOME_TRANSPORT_FAILURE
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
return Models::CaptureResult.new(
|
|
197
|
+
outcome: outcome,
|
|
198
|
+
stdout: out_reader.value.to_s,
|
|
199
|
+
stderr: err_reader.value.to_s,
|
|
200
|
+
status: status,
|
|
201
|
+
provider_name: provider_name,
|
|
202
|
+
invocation_id: invocation_id,
|
|
203
|
+
deadline_seconds: normalized_timeout,
|
|
204
|
+
elapsed_seconds: elapsed_since(started_at)
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def spawn_subprocess(cmd, chdir:, env:, command_prefix:, isolate_process_group:,
|
|
210
|
+
cleanup_group_on_exit:)
|
|
211
|
+
opts = {}
|
|
212
|
+
opts[:chdir] = chdir if chdir
|
|
213
|
+
opts[:pgroup] = true if isolate_process_group
|
|
214
|
+
|
|
215
|
+
full_cmd = Array(command_prefix) + cmd
|
|
216
|
+
supervised = isolate_process_group && cleanup_group_on_exit && RUBY_PLATFORM.include?("linux")
|
|
217
|
+
full_cmd = supervisor_command(full_cmd) if supervised
|
|
218
|
+
ready_reader, ready_writer = IO.pipe if supervised
|
|
219
|
+
opts[ready_writer.fileno] = ready_writer if supervised
|
|
220
|
+
spawn_env = env&.dup || {}
|
|
221
|
+
spawn_env["ACE_SAFE_CAPTURE_READY_FD"] = ready_writer.fileno.to_s if supervised
|
|
222
|
+
args = spawn_env.empty? ? full_cmd : [spawn_env, *full_cmd]
|
|
223
|
+
|
|
224
|
+
SpawnSetup.new(
|
|
225
|
+
args: args,
|
|
226
|
+
opts: opts,
|
|
227
|
+
supervised: supervised,
|
|
228
|
+
ready_reader: ready_reader,
|
|
229
|
+
ready_writer: ready_writer,
|
|
230
|
+
readiness: nil
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Pull whatever partial output a reader thread captured without
|
|
235
|
+
# ever blocking past a short bound: a descendant outside the
|
|
236
|
+
# killed process group can keep the pipe open indefinitely. The
|
|
237
|
+
# buffer is caller-owned, so bytes already read survive even when
|
|
238
|
+
# the stream must be closed or the reader thread must be killed.
|
|
239
|
+
def drain_reader(reader, io, buffer)
|
|
240
|
+
if reader.join(2)
|
|
241
|
+
reader.value.to_s
|
|
242
|
+
else
|
|
243
|
+
io.close unless io.closed?
|
|
244
|
+
reader.join(1)
|
|
245
|
+
reader.kill if reader.alive?
|
|
246
|
+
buffer.dup
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Incrementally buffer stream output into a caller-owned buffer so
|
|
251
|
+
# already-read bytes survive even when the stream must be closed
|
|
252
|
+
# mid-read or the reader thread must be killed.
|
|
253
|
+
def safe_read_stream(io, buffer)
|
|
254
|
+
loop do
|
|
255
|
+
chunk = io.read_nonblock(65_536, exception: false)
|
|
256
|
+
if chunk.nil?
|
|
257
|
+
break # EOF
|
|
258
|
+
elsif chunk == :wait_readable
|
|
259
|
+
IO.select([io])
|
|
260
|
+
next
|
|
261
|
+
end
|
|
262
|
+
buffer << chunk
|
|
263
|
+
end
|
|
264
|
+
buffer
|
|
265
|
+
rescue IOError, SystemCallError
|
|
266
|
+
buffer
|
|
108
267
|
end
|
|
109
268
|
|
|
110
269
|
def normalize_timeout(value)
|
|
@@ -119,6 +278,10 @@ module Ace
|
|
|
119
278
|
raise ArgumentError, "timeout must be a positive numeric value, got #{value.inspect}"
|
|
120
279
|
end
|
|
121
280
|
|
|
281
|
+
def elapsed_since(started_at)
|
|
282
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
283
|
+
end
|
|
284
|
+
|
|
122
285
|
def supervisor_command(command)
|
|
123
286
|
supervisor = File.expand_path("process_supervisor.rb", __dir__)
|
|
124
287
|
[RbConfig.ruby, "-W0", supervisor, *command]
|
|
@@ -54,9 +54,10 @@ module Ace
|
|
|
54
54
|
full_prompt = build_full_prompt(prompt, options)
|
|
55
55
|
|
|
56
56
|
cmd = build_opencode_command_with_prompt(full_prompt, options)
|
|
57
|
-
|
|
57
|
+
capture = execute_opencode_command(cmd, options: options)
|
|
58
|
+
capture.raise_unless_success
|
|
58
59
|
|
|
59
|
-
parse_opencode_response(
|
|
60
|
+
parse_opencode_response(capture, full_prompt, options)
|
|
60
61
|
rescue => e
|
|
61
62
|
handle_opencode_error(e)
|
|
62
63
|
end
|
|
@@ -211,25 +212,27 @@ module Ace
|
|
|
211
212
|
)
|
|
212
213
|
end
|
|
213
214
|
|
|
214
|
-
def parse_opencode_response(
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
def parse_opencode_response(capture, prompt, options)
|
|
216
|
+
# Unreachable through generate (raise_unless_success precedes parse),
|
|
217
|
+
# kept for direct callers with clearer 400 diagnostics.
|
|
218
|
+
unless capture.status.success?
|
|
219
|
+
error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
|
|
217
220
|
|
|
218
221
|
# Detect common error patterns for better error messages
|
|
219
222
|
if error_msg.include?("400") || error_msg.include?("Bad Request")
|
|
220
223
|
raise Ace::LLM::ProviderError, "OpenCode API request failed (400 Bad Request). The model or prompt may be invalid."
|
|
221
224
|
end
|
|
222
225
|
|
|
223
|
-
raise
|
|
226
|
+
raise capture.provider_error("OpenCode CLI failed: #{error_msg}")
|
|
224
227
|
end
|
|
225
228
|
|
|
226
229
|
begin
|
|
227
230
|
# Try to parse as JSON first
|
|
228
|
-
response = JSON.parse(stdout)
|
|
231
|
+
response = JSON.parse(capture.stdout)
|
|
229
232
|
text = response["result"] || response["text"] || response["response"] || ""
|
|
230
233
|
rescue JSON::ParserError
|
|
231
234
|
# Fall back to treating entire output as text
|
|
232
|
-
text = stdout.strip
|
|
235
|
+
text = capture.stdout.strip
|
|
233
236
|
response = {}
|
|
234
237
|
end
|
|
235
238
|
|
|
@@ -57,9 +57,20 @@ module Ace
|
|
|
57
57
|
full_prompt = rewrite_skill_commands(full_prompt, working_dir: working_dir)
|
|
58
58
|
|
|
59
59
|
cmd = build_pi_command(full_prompt, options, system_prompt: system_prompt)
|
|
60
|
-
|
|
60
|
+
capture = execute_pi_command(cmd, working_dir: working_dir, options: options)
|
|
61
|
+
begin
|
|
62
|
+
capture.raise_unless_success
|
|
63
|
+
rescue Ace::LLM::ProviderError
|
|
64
|
+
raise if !capture.status || capture.status.success?
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
# 401 output means credentials, not a generic CLI failure —
|
|
67
|
+
# AuthenticationError classifies as skip-to-next-provider.
|
|
68
|
+
raise auth_error if captured_auth_failure?(capture)
|
|
69
|
+
|
|
70
|
+
raise
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
parse_pi_response(capture, full_prompt, options)
|
|
63
74
|
rescue => e
|
|
64
75
|
handle_pi_error(e)
|
|
65
76
|
end
|
|
@@ -275,18 +286,38 @@ module Ace
|
|
|
275
286
|
)
|
|
276
287
|
end
|
|
277
288
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
289
|
+
# 401/Unauthorized anywhere in captured output marks a credentials
|
|
290
|
+
# failure rather than a generic CLI error.
|
|
291
|
+
def captured_auth_failure?(capture)
|
|
292
|
+
output = "#{capture.stderr}\n#{capture.stdout}"
|
|
293
|
+
output.include?("401") || output.include?("Unauthorized")
|
|
294
|
+
end
|
|
281
295
|
|
|
282
|
-
|
|
296
|
+
def auth_error
|
|
297
|
+
Ace::LLM::AuthenticationError.new("Pi authentication failed. Run 'pi login' to configure credentials.")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def parse_pi_response(capture, prompt, options)
|
|
301
|
+
# Unreachable through generate (raise_unless_success precedes parse),
|
|
302
|
+
# kept for direct callers: auth failures stay AuthenticationError.
|
|
303
|
+
unless capture.status.success?
|
|
304
|
+
error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
|
|
305
|
+
|
|
306
|
+
if captured_auth_failure?(capture)
|
|
283
307
|
raise Ace::LLM::AuthenticationError, "Pi authentication failed. Run 'pi login' to configure credentials."
|
|
284
308
|
end
|
|
285
309
|
|
|
286
|
-
raise
|
|
310
|
+
raise capture.provider_error("Pi CLI failed: #{error_msg}")
|
|
287
311
|
end
|
|
288
312
|
|
|
289
|
-
text, usage, finish_reason = parse_ndjson(stdout)
|
|
313
|
+
text, usage, finish_reason = parse_ndjson(capture.stdout)
|
|
314
|
+
if text.to_s.strip.empty?
|
|
315
|
+
# Clean exit without a final response: an incomplete session,
|
|
316
|
+
# not a completed answer.
|
|
317
|
+
raise capture.with_no_response_evidence(
|
|
318
|
+
Ace::LLM::ProviderError.new("Pi CLI produced no final message")
|
|
319
|
+
)
|
|
320
|
+
end
|
|
290
321
|
response = {"usage" => normalize_usage(usage), "finish_reason" => finish_reason}
|
|
291
322
|
|
|
292
323
|
metadata = build_metadata(response, text, prompt, options)
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-llm-providers-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.36.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-llm
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.42'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.
|
|
25
|
+
version: '0.42'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: rake
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -110,6 +110,7 @@ files:
|
|
|
110
110
|
- lib/ace/llm/providers/cli/codex_client.rb
|
|
111
111
|
- lib/ace/llm/providers/cli/codex_oai_client.rb
|
|
112
112
|
- lib/ace/llm/providers/cli/gemini_client.rb
|
|
113
|
+
- lib/ace/llm/providers/cli/models/capture_result.rb
|
|
113
114
|
- lib/ace/llm/providers/cli/molecules/health_checker.rb
|
|
114
115
|
- lib/ace/llm/providers/cli/molecules/process_supervisor.rb
|
|
115
116
|
- lib/ace/llm/providers/cli/molecules/safe_capture.rb
|