ace-llm 0.41.0 → 0.42.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fae0fd9e7709654101b452e68a39a32412c1788948f60ff6b3ea619ca4877d8
4
- data.tar.gz: c5ecb76fe5e258e27e5c71ddd8d0f760637ca7a0f67766a7239fe2e60acf3dbf
3
+ metadata.gz: 5b93f7041470f94d60776c4d9659cd403909a02777f3e8f6453e040cb62d5ae7
4
+ data.tar.gz: 812993967a5d36cedfccc9a1789303ca8abd9882fb5b53d7d2eda7413e82488f
5
5
  SHA512:
6
- metadata.gz: a885b5933135a969eb4f91a4e013af1e9a35b1e79450c8f47b8ce2b9afa372226c482c1023bab1033438a04b2fc95fbc6ccf4f8592e5b5ce6b763d0820bfb429
7
- data.tar.gz: b378d04c4280ede3e36d60b01fbe7ee821f28597ac0c9074745961126d642db7e822af19c44f0362d15eaf5067e3ed6b4ee9cf9a2779abf15652e979f9313114
6
+ metadata.gz: 497b2a465fd61863b06b4b4a4da6f5c1919bdf3a6228e1964994a0204fa0bcdf7c78870f1717f43ba485c0cfd4a1d4ca5d72430c4add742853c95b69c176dfa4
7
+ data.tar.gz: a1fc3830077342a69ad444ff03f80e88f4ac899d4a2dc9f71b485f33262c772790fd46231ecba3fd82e2bc47a4445f779fd429df25b7247ce1f3e8adc88952fd
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.42.0] - 2026-09-29
11
+
12
+ ### Added
13
+ - `Ace::LLM::Models::ExecutionEvidence`: structured, immutable record of how a CLI provider execution actually ended (outcome kind, invocation correlation ID, configured deadline, monotonic elapsed time, exit status/signal, bounded output excerpts). `Ace::LLM::ProviderError` instances can now carry it via `execution_evidence`.
14
+
15
+ ### Changed
16
+ - `ErrorClassifier` prefers structured execution evidence over error-message prose and returns the new `EXECUTION_INCOMPLETE` classification when a provider session began and then ended without a confirmed completion (deadline expiry, transport drop, nonzero exit, or clean exit without a final response). A nonzero exit whose stderr merely mentions "timeout" is no longer classified as a timeout; a successful response containing the word is unaffected.
17
+ - `FallbackOrchestrator` aborts the whole chain (no retry, no fallback) on execution-incomplete failures — the provider process already ran, so replaying via another provider would repeat its side effects. The terminal error carries the evidence summary, the provider's own bounded diagnostic line, and the list of tried providers; all-providers-exhausted messages include the last execution evidence.
18
+
19
+ ## [0.41.1] - 2026-09-29
20
+
21
+ ### Changed
22
+
23
+ - Codex selector help now resolves `codex:gpt` to `gpt-6-sol` under the v6-only model catalog.
24
+
25
+ ### Technical
26
+
27
+ - Docs: codex alias table, shortcut rows, and demo tape aligned to the gpt-6 sol/astra/luna policy (gpt-5.x, terra, and spark entries removed).
28
+ - Tests: codex target and model-limit-resolver expectations updated to the v6 catalog.
29
+
10
30
  ## [0.41.0] - 2026-09-27
11
31
 
12
32
  ### Changed
@@ -11,6 +11,11 @@ module Ace
11
11
  FALLBACK_IMMEDIATELY = :fallback_immediately
12
12
  SKIP_TO_NEXT = :skip_to_next
13
13
  TERMINAL = :terminal
14
+ # The provider process started but its session ended without a confirmed
15
+ # completion (deadline expiry, transport drop, nonzero exit, or a clean
16
+ # exit without a final response). Such sessions may have performed side
17
+ # effects — retries and fallback would replay them.
18
+ EXECUTION_INCOMPLETE = :execution_incomplete
14
19
 
15
20
  # Map HTTP status codes to classification
16
21
  STATUS_CLASSIFICATIONS = {
@@ -43,8 +48,13 @@ module Ace
43
48
 
44
49
  # Classify an error for retry/fallback decisions
45
50
  # @param error [Exception] The error to classify
46
- # @return [Symbol] Classification type (RETRYABLE_WITH_BACKOFF, FALLBACK_IMMEDIATELY, SKIP_TO_NEXT, TERMINAL)
51
+ # @return [Symbol] Classification type (RETRYABLE_WITH_BACKOFF, FALLBACK_IMMEDIATELY, SKIP_TO_NEXT, TERMINAL, EXECUTION_INCOMPLETE)
47
52
  def self.classify(error)
53
+ # Structured capture evidence attached at the subprocess boundary
54
+ # outranks message prose: a nonzero exit whose stderr mentions
55
+ # "timeout" is a nonzero exit, not a deadline expiry.
56
+ return EXECUTION_INCOMPLETE if execution_incomplete?(error)
57
+
48
58
  case error
49
59
  when Ace::LLM::AuthenticationError
50
60
  SKIP_TO_NEXT
@@ -64,6 +74,31 @@ module Ace
64
74
  end
65
75
  end
66
76
 
77
+ # True when the error carries structured evidence that the provider
78
+ # execution began and ended without a confirmed completion.
79
+ # @param error [Exception]
80
+ # @return [Boolean]
81
+ def self.execution_incomplete?(error)
82
+ evidence = execution_evidence_for(error)
83
+ evidence&.uncertain_execution? ? true : false
84
+ end
85
+
86
+ # Extract structured execution evidence from an error, accepting either
87
+ # an ExecutionEvidence instance or its serialized hash form.
88
+ # @param error [Exception]
89
+ # @return [Ace::LLM::Models::ExecutionEvidence, nil]
90
+ def self.execution_evidence_for(error)
91
+ return nil unless error.respond_to?(:execution_evidence)
92
+
93
+ evidence = error.execution_evidence
94
+ case evidence
95
+ when Ace::LLM::Models::ExecutionEvidence
96
+ evidence
97
+ when Hash
98
+ Ace::LLM::Models::ExecutionEvidence.from_h(evidence)
99
+ end
100
+ end
101
+
67
102
  # Determine if an error is retryable
68
103
  # @param error [Exception] The error to check
69
104
  # @return [Boolean] True if error should be retried
@@ -115,7 +115,7 @@ module Ace
115
115
  puts " Short aliases for common provider:MODEL combinations:"
116
116
  puts " gflash → google:gemini-flash-latest"
117
117
  puts " glite → google:gemini-flash-lite-latest"
118
- puts " codex:gpt → codex:gpt-5.4"
118
+ puts " codex:gpt → codex:gpt-6-sol"
119
119
  puts " cc → claude:sonnet"
120
120
  puts " sonnet → anthropic:claude-sonnet-4-5"
121
121
  end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module LLM
5
+ module Models
6
+ # Structured evidence about how a CLI provider execution actually ended.
7
+ #
8
+ # Built at the subprocess boundary (ace-llm-providers-cli SafeCapture) and
9
+ # attached to Ace::LLM::ProviderError instances so classification and
10
+ # fallback decisions can key on observed process facts instead of error
11
+ # message prose. Carries bounded output excerpts only — never the full
12
+ # streams, prompt content, or environment.
13
+ #
14
+ # This is a model - pure immutable data.
15
+ class ExecutionEvidence
16
+ OUTCOMES = %i[
17
+ deadline_exceeded
18
+ transport_failure
19
+ nonzero_exit
20
+ no_response
21
+ ].freeze
22
+
23
+ EXCERPT_LIMIT = 2000
24
+
25
+ # Credential shapes that must not survive into diagnostics, reports, or
26
+ # error messages. Matches are truncated to a short prefix so the
27
+ # credential TYPE stays readable while the value is destroyed.
28
+ SECRET_PATTERNS = [
29
+ /(?:bearer|authorization|basic)\s*[:=]?\s*[\w.\-+=\/]{8,}/i,
30
+ /(?:api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|secret|password|passwd|pwd)\s*["']?\s*[:=]\s*["']?[\w.\-+=\/]{8,}/i,
31
+ /sk-(?:proj-)?[A-Za-z0-9_\-]{16,}/,
32
+ /gh[pousr]_[A-Za-z0-9_]{16,}/,
33
+ /AKIA[0-9A-Z]{16}/,
34
+ /xox[baprs]-[A-Za-z0-9\-]{10,}/,
35
+ /eyJ[A-Za-z0-9_\-]{20,}\.eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{10,}/
36
+ ].freeze
37
+
38
+ # Destroy credential values in captured diagnostics while keeping the
39
+ # surrounding text readable.
40
+ # @param text [String, nil]
41
+ # @return [String, nil]
42
+ def self.redact(text)
43
+ return text if text.nil?
44
+
45
+ redacted = text.to_s.dup
46
+ SECRET_PATTERNS.each do |pattern|
47
+ redacted = redacted.gsub(pattern) { |match| "#{match[0, 6]}…[redacted]" }
48
+ end
49
+ redacted
50
+ end
51
+
52
+ attr_reader :outcome, :invocation_id, :provider_name, :deadline_seconds,
53
+ :elapsed_seconds, :exit_status, :signal, :execution_began,
54
+ :stdout_excerpt, :stderr_excerpt
55
+
56
+ # @param outcome [Symbol] one of OUTCOMES
57
+ # @param invocation_id [String, nil] opaque correlation ID generated before spawn
58
+ # @param provider_name [String, nil] human-facing provider label (e.g. "Codex")
59
+ # @param deadline_seconds [Numeric, nil] configured deadline for the invocation
60
+ # @param elapsed_seconds [Numeric, nil] monotonic elapsed time of the invocation
61
+ # @param exit_status [Integer, nil] process exit code when the process exited
62
+ # @param signal [String, nil] signal name when the process was terminated by one
63
+ # @param execution_began [Boolean] true when the provider process was actually spawned
64
+ # @param stdout_excerpt [String, nil] bounded excerpt of captured stdout
65
+ # @param stderr_excerpt [String, nil] bounded excerpt of captured stderr
66
+ def initialize(outcome:, invocation_id: nil, provider_name: nil, deadline_seconds: nil,
67
+ elapsed_seconds: nil, exit_status: nil, signal: nil, execution_began: true,
68
+ stdout_excerpt: nil, stderr_excerpt: nil)
69
+ raise ArgumentError, "unknown outcome #{outcome.inspect}" unless OUTCOMES.include?(outcome)
70
+
71
+ @outcome = outcome
72
+ @invocation_id = invocation_id
73
+ @provider_name = provider_name
74
+ @deadline_seconds = deadline_seconds
75
+ @elapsed_seconds = elapsed_seconds
76
+ @exit_status = exit_status
77
+ @signal = signal
78
+ @execution_began = execution_began
79
+ @stdout_excerpt = bounded_excerpt(stdout_excerpt)
80
+ @stderr_excerpt = bounded_excerpt(stderr_excerpt)
81
+ freeze
82
+ end
83
+
84
+ # Build evidence from a plain hash (e.g. deserialized metadata)
85
+ # @param hash [Hash]
86
+ # @return [ExecutionEvidence]
87
+ def self.from_h(hash)
88
+ return nil unless hash.is_a?(Hash)
89
+
90
+ symbolized = hash.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
91
+ outcome = symbolized[:outcome].to_s.to_sym
92
+ return nil unless OUTCOMES.include?(outcome)
93
+
94
+ new(
95
+ outcome: outcome,
96
+ invocation_id: symbolized[:invocation_id],
97
+ provider_name: symbolized[:provider_name],
98
+ deadline_seconds: symbolized[:deadline_seconds],
99
+ elapsed_seconds: symbolized[:elapsed_seconds],
100
+ exit_status: symbolized[:exit_status],
101
+ signal: symbolized[:signal],
102
+ execution_began: symbolized.fetch(:execution_began, true),
103
+ stdout_excerpt: symbolized[:stdout_excerpt],
104
+ stderr_excerpt: symbolized[:stderr_excerpt]
105
+ )
106
+ end
107
+
108
+ # True when the provider process was spawned and its final state is not
109
+ # a confirmed successful completion. Such sessions may have performed
110
+ # side effects; callers must not replay them automatically.
111
+ def uncertain_execution?
112
+ execution_began
113
+ end
114
+
115
+ # First bounded diagnostic line from the captured streams, for
116
+ # actionable terminal messages. Prefers stderr; falls back to stdout.
117
+ # @return [String, nil]
118
+ def detail_line
119
+ source = [stderr_excerpt, stdout_excerpt].compact
120
+ .map { |stream| stream.lines.map(&:strip) }
121
+ .flatten
122
+ .reject(&:empty?)
123
+ .first.to_s
124
+ return nil if source.empty?
125
+
126
+ source.length > 300 ? "#{source[0, 300]}…[truncated]" : source
127
+ end
128
+
129
+ # Human-readable one-line summary safe for reports and error messages.
130
+ # @return [String]
131
+ def summary
132
+ parts = [outcome_label]
133
+ parts << "elapsed #{format_seconds(elapsed_seconds)}" if elapsed_seconds
134
+ parts << "deadline #{format_seconds(deadline_seconds)}" if deadline_seconds
135
+ parts << "exit #{exit_status}" unless exit_status.nil?
136
+ parts << "signal #{signal}" if signal
137
+ parts << "invocation #{invocation_id}" if invocation_id
138
+ parts.join(", ")
139
+ end
140
+
141
+ # @return [Hash] plain hash without output excerpts (for metadata surfaces)
142
+ def to_h
143
+ {
144
+ outcome: outcome,
145
+ invocation_id: invocation_id,
146
+ provider_name: provider_name,
147
+ deadline_seconds: deadline_seconds,
148
+ elapsed_seconds: elapsed_seconds,
149
+ exit_status: exit_status,
150
+ signal: signal,
151
+ execution_began: execution_began
152
+ }.compact
153
+ end
154
+
155
+ private
156
+
157
+ def outcome_label
158
+ case outcome
159
+ when :deadline_exceeded then "deadline_exceeded"
160
+ when :transport_failure then "transport_failure"
161
+ when :nonzero_exit then "nonzero_exit"
162
+ when :no_response then "completed_without_final_response"
163
+ end
164
+ end
165
+
166
+ def format_seconds(value)
167
+ return value.to_s if value.is_a?(String)
168
+
169
+ format("%.1fs", value)
170
+ end
171
+
172
+ def bounded_excerpt(text)
173
+ return nil if text.nil?
174
+
175
+ excerpt = self.class.redact(text.to_s)
176
+ excerpt = "#{excerpt[0, EXCERPT_LIMIT]}…[truncated]" if excerpt.length > EXCERPT_LIMIT
177
+ excerpt.freeze
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -22,6 +22,8 @@ module Ace
22
22
  @visited_providers = Set.new
23
23
  @start_time = nil
24
24
  @last_failure_terminal = false
25
+ @last_execution_evidence = nil
26
+ @chain_aborted = false
25
27
  end
26
28
 
27
29
  # Execute a block with fallback support
@@ -35,6 +37,7 @@ module Ace
35
37
  @start_time = Time.now
36
38
  @primary_provider = primary_provider
37
39
  @visited_providers.clear
40
+ @chain_aborted = false
38
41
 
39
42
  # If fallback disabled, just execute with primary
40
43
  return yield(get_client(primary_provider, registry), primary_provider) if @config.disabled?
@@ -43,6 +46,7 @@ module Ace
43
46
  result = try_provider_with_retry(primary_provider, registry) do |client, provider_name|
44
47
  yield client, provider_name
45
48
  end
49
+ raise_incomplete_execution_error if @chain_aborted
46
50
  return result if result
47
51
 
48
52
  # Try fallback providers in order (per-provider chain or default)
@@ -61,6 +65,7 @@ module Ace
61
65
  result = try_provider_with_retry(fallback_provider, registry) do |client, provider_name|
62
66
  yield client, provider_name
63
67
  end
68
+ raise_incomplete_execution_error if @chain_aborted
64
69
  return result if result
65
70
  end
66
71
 
@@ -88,27 +93,53 @@ module Ace
88
93
  raise if error.is_a?(Ace::LLM::ConfigurationError) && provider_name != @primary_provider
89
94
  last_error = error
90
95
 
91
- # Handle the error - returns :retry or :stop_and_fallback
96
+ # Handle the error - returns :retry, :stop_and_fallback, or :abort_chain
92
97
  action = handle_error(error, provider_name, attempts)
93
98
 
94
99
  if action == :retry
95
100
  attempts += 1
96
101
  next
97
- else # :stop_and_fallback
102
+ else # :stop_and_fallback or :abort_chain
98
103
  return nil
99
104
  end
100
105
  end
101
106
  end
102
107
 
108
+ # Raise the terminal error for a chain aborted by an incomplete
109
+ # execution. Carries the structured evidence so downstream consumers
110
+ # (E2E runner, reviewers) can reconcile the session without guessing.
111
+ def raise_incomplete_execution_error
112
+ evidence = @last_execution_evidence
113
+ message = +"Provider execution incomplete; remaining providers were not tried to avoid replaying CLI side effects."
114
+ message << " Evidence: #{evidence.summary}." if evidence
115
+ if evidence && (detail = evidence.detail_line)
116
+ message << " Detail: #{detail}"
117
+ end
118
+ message << " Tried: #{@visited_providers.to_a.join(", ")}."
119
+ message << " Reconcile the session manually before rerunning this work."
120
+ error = Ace::LLM::ProviderError.new(message)
121
+ error.execution_evidence = evidence if evidence
122
+ raise error
123
+ end
124
+
103
125
  # Handle error and determine retry/fallback strategy
104
126
  # @param error [Exception] The error that occurred
105
127
  # @param provider_name [String] Provider name
106
128
  # @param attempts [Integer] Current attempt number
107
- # @return [Symbol] :retry to retry, :stop_and_fallback to move to next provider
129
+ # @return [Symbol] :retry to retry, :stop_and_fallback to move to next provider, :abort_chain to stop entirely
108
130
  def handle_error(error, provider_name, attempts)
109
131
  classification = Atoms::ErrorClassifier.classify(error)
110
132
 
111
133
  case classification
134
+ when Atoms::ErrorClassifier::EXECUTION_INCOMPLETE
135
+ # The provider process already ran; its session ended without a
136
+ # confirmed completion. Retrying or falling back would replay
137
+ # whatever side effects the session performed.
138
+ @last_failure_terminal = true
139
+ @last_execution_evidence = Atoms::ErrorClassifier.execution_evidence_for(error)
140
+ @chain_aborted = true
141
+ report_status("⚠ #{provider_name} execution incomplete (#{@last_execution_evidence&.summary}); not retrying or falling back to avoid replaying side effects")
142
+ :abort_chain
112
143
  when Atoms::ErrorClassifier::SKIP_TO_NEXT
113
144
  @last_failure_terminal = false
114
145
  report_status("⚠ #{provider_name} authentication failed, skipping...")
@@ -196,13 +227,16 @@ module Ace
196
227
  def build_exhaustion_error_message
197
228
  providers_tried = @visited_providers.to_a.join(", ")
198
229
 
199
- msg = "All configured providers unavailable. "
200
- msg += "Tried: #{providers_tried}. "
201
- msg += "\nTry:\n"
202
- msg += " - Check provider status pages\n"
203
- msg += " - Configure additional providers\n"
204
- msg += " - Retry in a few minutes\n"
205
- msg += " - Run with --debug for detailed errors"
230
+ msg = +"All configured providers unavailable. "
231
+ msg << "Tried: #{providers_tried}. "
232
+ if (evidence = @last_execution_evidence)
233
+ msg << "\nLast execution evidence: #{evidence.summary}. "
234
+ end
235
+ msg << "\nTry:\n"
236
+ msg << " - Check provider status pages\n"
237
+ msg << " - Configure additional providers\n"
238
+ msg << " - Retry in a few minutes\n"
239
+ msg << " - Run with --debug for detailed errors"
206
240
  msg
207
241
  end
208
242
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module LLM
5
- VERSION = "0.41.0"
5
+ VERSION = "0.42.0"
6
6
  end
7
7
  end
data/lib/ace/llm.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "llm/atoms/http_client"
18
18
  require_relative "llm/atoms/xdg_directory_resolver"
19
19
  require_relative "llm/atoms/error_classifier"
20
20
 
21
+ require_relative "llm/models/execution_evidence"
21
22
  require_relative "llm/models/fallback_config"
22
23
  require_relative "llm/models/role_config"
23
24
 
@@ -51,7 +52,15 @@ require_relative "llm/cli"
51
52
  module Ace
52
53
  module LLM
53
54
  class Error < StandardError; end
54
- class ProviderError < Error; end
55
+
56
+ # Provider failure carrying optional structured execution evidence
57
+ # (Ace::LLM::Models::ExecutionEvidence) about how the provider session
58
+ # actually ended. Evidence is attached at the subprocess boundary; absence
59
+ # keeps legacy message-based classification behavior.
60
+ class ProviderError < Error
61
+ attr_accessor :execution_evidence
62
+ end
63
+
55
64
  class ConfigurationError < Error; end
56
65
  class AuthenticationError < Error; end
57
66
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.41.0
4
+ version: 0.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-27 00:00:00.000000000 Z
10
+ date: 2026-09-30 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -288,6 +288,7 @@ files:
288
288
  - lib/ace/llm/cli.rb
289
289
  - lib/ace/llm/cli/commands/query.rb
290
290
  - lib/ace/llm/configuration.rb
291
+ - lib/ace/llm/models/execution_evidence.rb
291
292
  - lib/ace/llm/models/fallback_config.rb
292
293
  - lib/ace/llm/models/role_config.rb
293
294
  - lib/ace/llm/molecules/client_registry.rb