ask-agent 0.12.1 → 0.15.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: 1c3eb4f6e24d5acd650f8e11614dcd3257b9f57b252ba6df2c8c48ca3dffdd1e
4
- data.tar.gz: efc969c3abc319814c4afecc9103750a37962cbd664d1944d84ec3d2d89adb71
3
+ metadata.gz: 816f21ed178b31227a58afeeed65f2428dbe9cce7d4a6ddab2c6fda7a6a60a89
4
+ data.tar.gz: 4dec09ce3cc94c6abc0e94bd5dee9f6ac9bde083dc7995af601a6650e692fd37
5
5
  SHA512:
6
- metadata.gz: c9a78d1dd7e990eb11bd56997f9da131e0bb11f88affca0b4e01d9504711aa17787e98bde447bcd397045dcb97e4930cd04437638b3bebc83d166e8f10c34d37
7
- data.tar.gz: 4d17c16b767ff92973d32acff24232c5a5d86739ead407f1e92f92707a8d685559e146c4c5430861e397f6bd5cf5c9d2fcdbf503015e6adeae3821f1567041ca
6
+ metadata.gz: 8de0c033834105f7c147115bd84b97306fc91b787690fc9e82563835f53b8fad69ff90dfabeeada12be9a8651e9ecfffd960d8e2fa6e84ffff4709f30519a5de
7
+ data.tar.gz: b5d46db9e66b242ea35fafaf86c67401ad27ee8ef0099ad4811a3d8442e63bd54aa2b16be5e4a7b2aa0f7305603cf39d1b5497e66648f59a9d701489bbdc6c8c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,94 @@
1
+ ## [0.15.0] — 2026-07-24
2
+
3
+ ### Added
4
+
5
+ - **Independent Evaluator — `Ask::Agent::Evaluator`** — Generator/evaluator separation.
6
+ A separate model (configured independently from the session's model) judges the
7
+ agent's output against a structured rubric before delivery. This prevents the
8
+ anti-pattern of a model grading its own work.
9
+
10
+ ```ruby
11
+ # Evaluate with a different model — the recommended approach
12
+ session = Ask::Agent::Session.new(
13
+ model: "gpt-4o",
14
+ evaluator: { model: "claude-sonnet-4", goal: "Write an email validator" }
15
+ )
16
+ session.run("Write email validation")
17
+ ```
18
+
19
+ Three verdicts:
20
+ - **`:accept`** — output meets the goal, passes through to reflection
21
+ - **`:revise`** — evaluator provides actionable feedback; session runs another
22
+ turn with the feedback injected into system context
23
+ - **`:block`** — output is fundamentally wrong; session returns blocked message
24
+ and emits `Events::EvaluationBlocked`
25
+
26
+ Rubric dimensions (each scored 0-2):
27
+ - correctness (3× weight), completeness (2×), verification (2×), scope (1×), clarity (1×)
28
+
29
+ Custom rubrics supported:
30
+ ```ruby
31
+ evaluator = Ask::Agent::Evaluator.new(
32
+ model: "claude-sonnet-4",
33
+ rubric: [
34
+ Ask::Agent::Evaluator::Dimension.new(name: "performance", description: "Is it fast?", weight: 2)
35
+ ]
36
+ )
37
+ ```
38
+
39
+ - **`evaluator:` option on `Session`** — accepts `true`, `false`/`nil`, or a Hash:
40
+ - `evaluator: true` — uses `config.default_evaluator_model` (falls back to the
41
+ session's model, though using a different model is strongly recommended)
42
+ - `evaluator: { model: "claude-sonnet-4", goal: "Custom goal" }` — explicit config
43
+ - `evaluator: false` (default) — no evaluation, backward compatible
44
+
45
+ - **`default_evaluator_model` config option** — set a global default:
46
+ ```ruby
47
+ Ask::Agent.configure do |c|
48
+ c.default_evaluator_model = "claude-sonnet-4"
49
+ end
50
+ ```
51
+
52
+ - **New event types** for streaming evaluation:
53
+ - `Events::EvaluationStart` — emitted when evaluation begins (includes dimension list)
54
+ - `Events::EvaluationDelta` — streamed evaluation text from the evaluator model
55
+ - `Events::EvaluationEnd` — emitted with decision, feedback, scores, and evidence
56
+ - `Events::EvaluationBlocked` — emitted when evaluator returns `:block`
57
+
58
+ - **17 unit tests** for Evaluator — construction, rubric, all three verdicts, event
59
+ emission, custom rubrics, JSON parsing, and malformed response fallback.
60
+
61
+ - **7 integration tests** for Session with evaluator — config (true/hash),
62
+ revise triggers improvement, revise skips reflector, block returns blocked
63
+ message, block emits event, evaluator-not-configured skips evaluation.
64
+
65
+ ## [0.14.0] — 2026-07-23
66
+
67
+ ### Added
68
+
69
+ - **`state:` keyword on Session** — Accepts any `Ask::State::Adapter` directly. Sessions persist conversation state, tool results, and metadata. Replaces `persistence:` keyword (still supported for backward compatibility).
70
+ - **Per-turn persistence** — Session now persists after every LLM turn, not just at the end of `run()`. Mid-session crashes no longer lose progress.
71
+ - **`Session.load` restores `@messages`** — Previously `session.messages` returned `nil` after loading. Now it's populated from the restored chat messages.
72
+ - **`Ask::State::Adapter#clear`** — Abstract method added to the adapter contract. Memory adapter implements it.
73
+
74
+ ### Changed
75
+
76
+ - **Session behind the scenes now uses `@state.set`/`@state.get`/`@state.delete`** instead of the old `@persistence.save`/`@persistence.load`/`@persistence.delete`. Custom adapters must respond to `set`/`get`/`delete`.
77
+
78
+ ## [0.13.0] — 2026-07-23
79
+
80
+ ### Added
81
+
82
+ - **ModelFallback middleware** — Switches to a fallback model+provider when the primary LLM call fails with a rate limit, server error, or service unavailable. Supports static and dynamic (lambda-based) fallback lists. Credentials resolve automatically via `Ask::Auth`.
83
+ - Static fallbacks: ordered list of `{ model:, provider: }` hashes
84
+ - Dynamic fallbacks: lambda receiving `(error, request)` returning the list
85
+ - Custom eligible errors: configure which errors trigger fallback
86
+ - Each fallback builds its own provider instance with resolved credentials
87
+
88
+ ### Changed
89
+
90
+ - `Pipeline::KNOWN_MIDDLEWARES` now includes `:model_fallback`.
91
+
1
92
  ## [0.12.0] — 2026-07-22
2
93
 
3
94
  ### Added
data/README.md CHANGED
@@ -37,7 +37,88 @@ puts response
37
37
  | `Ask::Agent::Telemetry` | telemetry.rb | File-backed telemetry for error tracking |
38
38
  | `Ask::Agent::Reflector` | reflector.rb | Assistant response self-evaluation |
39
39
  | `Ask::Agent::MetaAgent` | meta_agent.rb | LLM-powered self-improvement from telemetry |
40
- | `Ask::Agent::Configuration` | configuration.rb | Global config: model, turns, concurrency |
40
+ | `Ask::Agent::Evaluator` | evaluator.rb | Independent response evaluation with structured rubric — different model, isolated context |
41
+ | `Ask::Agent::Configuration` | configuration.rb | Global config: model, turns, concurrency, evaluator |
42
+
43
+ ## Evaluator
44
+
45
+ Independent response evaluation with generator/evaluator separation. The
46
+ evaluator uses a **separate model** (different from the session's model) and an
47
+ **isolated context** to judge the agent's output — preventing the anti-pattern
48
+ of a model grading its own work.
49
+
50
+ ### Quick start
51
+
52
+ ```ruby
53
+ session = Ask::Agent::Session.new(
54
+ model: "gpt-4o",
55
+ evaluator: { model: "claude-sonnet-4", goal: "Write an email validator" }
56
+ )
57
+ session.run("Write email validation")
58
+ ```
59
+
60
+ ### Verdicts
61
+
62
+ | Verdict | Behavior |
63
+ |---------|----------|
64
+ | `:accept` | Output passes — falls through to reflection |
65
+ | `:revise` | Evaluator provides feedback; session runs another turn with it injected |
66
+ | `:block` | Output is fundamentally wrong — returns blocked message, emits `EvaluationBlocked` |
67
+
68
+ ### Configuration
69
+
70
+ ```ruby
71
+ # Set a global default evaluator model
72
+ Ask::Agent.configure do |c|
73
+ c.default_evaluator_model = "claude-sonnet-4"
74
+ end
75
+
76
+ # Then use evaluator: true to enable with the default
77
+ session = Ask::Agent::Session.new(model: "gpt-4o", evaluator: true)
78
+ ```
79
+
80
+ ### Custom rubric
81
+
82
+ ```ruby
83
+ evaluator = Ask::Agent::Evaluator.new(
84
+ model: "claude-sonnet-4",
85
+ rubric: [
86
+ Ask::Agent::Evaluator::Dimension.new(
87
+ name: "performance",
88
+ description: "Is the implementation efficient?",
89
+ weight: 2
90
+ )
91
+ ]
92
+ )
93
+
94
+ result = evaluator.evaluate(
95
+ goal: "Write an email validator",
96
+ response: agent_output
97
+ )
98
+ result.accept? # => true/false
99
+ result.scores # => { performance: 2 }
100
+ result.feedback # => "Add edge case for unicode characters"
101
+ ```
102
+
103
+ ### Events
104
+
105
+ The evaluator emits its own events during evaluation:
106
+
107
+ ```ruby
108
+ session.on_event do |event|
109
+ case event
110
+ when Ask::Agent::Events::EvaluationStart
111
+ puts "Evaluating against: #{event.dimensions.join(', ')}"
112
+ when Ask::Agent::Events::EvaluationDelta
113
+ print event.content
114
+ when Ask::Agent::Events::EvaluationEnd
115
+ puts "Decision: #{event.decision}"
116
+ puts "Scores: #{event.scores}"
117
+ when Ask::Agent::Events::EvaluationBlocked
118
+ puts "Blocked: #{event.feedback}"
119
+ end
120
+ end
121
+ ```
41
122
 
42
123
  ## Events
43
124
 
@@ -60,13 +141,13 @@ end
60
141
 
61
142
  Opt-in safety modules:
62
143
 
63
- - **PermissionGate** — Require approval for destructive tools (write, edit, bash, destroy)
144
+ - **Permissions** — Access control for tools. Supports named access modes (`:full_access`, `:read_only`, `:ask_before_changes`) or custom blocked-tool lists.
64
145
  - **RateLimiter** — Prevent runaway tool calls (configurable per-minute and per-turn limits)
65
146
  - **AuditLog** — Immutable, append-only log of every tool call
66
147
 
67
148
  ```ruby
68
149
  extensions = [
69
- Ask::Agent::Extensions::PermissionGate.new,
150
+ Ask::Agent::Extensions::Permissions.new(mode: :read_only),
70
151
  Ask::Agent::Extensions::RateLimiter.new(max_calls_per_minute: 30),
71
152
  Ask::Agent::Extensions::AuditLog.new(path: "agent.log")
72
153
  ]
@@ -81,6 +162,57 @@ session = Ask::Agent::Session.new(
81
162
  )
82
163
  ```
83
164
 
165
+ ## Middleware
166
+
167
+ Wrapping LLM provider calls with cross-cutting behavior:
168
+
169
+ - **RetryOnFailure** — Retry on rate limits and server errors with exponential backoff
170
+ - **ModelFallback** — Switch to a fallback model+provider on transient errors
171
+ - **LogCalls** — Log every LLM provider call
172
+ - **DefaultSettings** — Inject default generation parameters
173
+
174
+ ```ruby
175
+ Ask::Agent.configure do |c|
176
+ c.middleware.use :retry_on_failure, max_retries: 3
177
+ c.middleware.use :model_fallback, fallbacks: [
178
+ { model: "claude-sonnet-4", provider: :anthropic },
179
+ { model: "gemini-2.0-flash", provider: :google }
180
+ ]
181
+ c.middleware.use :log_calls, logger: Rails.logger
182
+ c.middleware.use :default_settings, temperature: 0.7
183
+ end
184
+ ```
185
+
186
+ ### ModelFallback
187
+
188
+ When the primary LLM is overloaded or down, `ModelFallback` transparently switches to a backup model+provider. Credentials for each provider are resolved automatically.
189
+
190
+ **Static fallbacks** — ordered list tried in sequence:
191
+ ```ruby
192
+ c.middleware.use :model_fallback, fallbacks: [
193
+ { model: "claude-sonnet-4", provider: :anthropic },
194
+ { model: "gemini-2.0-flash", provider: :google }
195
+ ]
196
+ ```
197
+
198
+ **Dynamic fallbacks** — lambda that receives the error and request:
199
+ ```ruby
200
+ c.middleware.use :model_fallback, fallbacks: ->(error, request) {
201
+ if request[:messages].sum { |m| m[:content].to_s.length } > 100_000
202
+ [{ model: "claude-sonnet-4", provider: :anthropic }] # long-context
203
+ else
204
+ [{ model: "gpt-4o-mini", provider: :openai }] # cheaper
205
+ end
206
+ }
207
+ ```
208
+
209
+ **Custom eligible errors** — by default rate limits, server errors, and service unavailable:
210
+ ```ruby
211
+ c.middleware.use :model_fallback,
212
+ fallbacks: [{ model: "claude-sonnet-4", provider: :anthropic }],
213
+ eligible_errors: [Ask::RateLimitError, Ask::ServerError]
214
+ ```
215
+
84
216
  ## Configuration
85
217
 
86
218
  ```ruby
@@ -5,7 +5,7 @@ module Ask
5
5
  class Configuration
6
6
  attr_accessor :default_model, :default_max_turns, :compactor_enabled,
7
7
  :compactor_threshold, :parallel_tool_execution, :max_tool_retries,
8
- :prompt_caching
8
+ :prompt_caching, :default_evaluator_model
9
9
 
10
10
  # @return [Middleware::Pipeline] the middleware pipeline for provider calls
11
11
  attr_reader :middleware
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ class Evaluator
6
+ # Structured result from an evaluation.
7
+ # - decision :accept — response meets the goal
8
+ # :revise — response needs improvement (feedback provided)
9
+ # :block — response is fundamentally wrong (hard stop)
10
+ # - feedback actionable text the generator can use to improve
11
+ # - scores hash of dimension name => score (0, 1, or 2)
12
+ # - evidence array of specific evidence strings
13
+ Result = Data.define(:decision, :feedback, :scores, :evidence) do
14
+ def accept? = decision == :accept
15
+ def revise? = decision == :revise
16
+ def block? = decision == :block
17
+ end
18
+
19
+ # A single dimension in the evaluation rubric.
20
+ Dimension = Data.define(:name, :description, :weight) do
21
+ def initialize(name:, description:, weight: 1)
22
+ super(name: name, description: description, weight: weight)
23
+ end
24
+ end
25
+
26
+ # Default rubric borrowed from the course's evaluator-rubric template.
27
+ DEFAULT_DIMENSIONS = [
28
+ Dimension.new(name: "correctness", description: "Does the output match the requested goal?", weight: 3),
29
+ Dimension.new(name: "completeness", description: "Are all aspects of the goal addressed?", weight: 2),
30
+ Dimension.new(name: "verification", description: "Is there evidence that the output actually works?", weight: 2),
31
+ Dimension.new(name: "scope", description: "Did it stay within the defined boundaries without overreaching?", weight: 1),
32
+ Dimension.new(name: "clarity", description: "Is the output clear, well-structured, and maintainable?", weight: 1),
33
+ ].freeze
34
+
35
+ # How many times the evaluator may retry on a malformed response.
36
+ MAX_EVAL_RETRIES = 2
37
+
38
+ attr_reader :model, :rubric
39
+
40
+ # @param model [String] the model id to use for evaluation (should differ from the generator's model)
41
+ # @param rubric [Array<Dimension>] the rubric dimensions to evaluate against
42
+ def initialize(model:, rubric: DEFAULT_DIMENSIONS)
43
+ @model = model
44
+ @rubric = rubric
45
+ end
46
+
47
+ # Evaluate a response against a goal.
48
+ #
49
+ # @param goal [String] what the generator was asked to do
50
+ # @param response [String] what the generator produced
51
+ # @param event_emitter [#emit, nil] optional event emitter for streaming evaluation
52
+ # @return [Result] structured evaluation result
53
+ def evaluate(goal:, response:, event_emitter: nil)
54
+ event_emitter&.emit(Events::EvaluationStart.new(dimensions: @rubric.map(&:name)))
55
+
56
+ chat = build_chat
57
+ chat.with_instructions(evaluation_prompt(goal))
58
+
59
+ accumulated = +""
60
+ chat.ask(response.to_s) do |chunk|
61
+ if chunk.content.to_s.strip.length > 0
62
+ accumulated << chunk.content.to_s
63
+ event_emitter&.emit(Events::EvaluationDelta.new(content: chunk.content.to_s))
64
+ end
65
+ end
66
+
67
+ result = parse_result(accumulated)
68
+ event_emitter&.emit(Events::EvaluationEnd.new(
69
+ decision: result.decision,
70
+ feedback: result.feedback,
71
+ scores: result.scores,
72
+ evidence: result.evidence
73
+ ))
74
+
75
+ result
76
+ end
77
+
78
+ private
79
+
80
+ def build_chat
81
+ Chat.new(model: @model)
82
+ end
83
+
84
+ def evaluation_prompt(goal)
85
+ dimensions_text = @rubric.each_with_index.map { |d, i|
86
+ weight_label = d.weight > 1 ? " (weight: #{d.weight}x)" : ""
87
+ "#{i + 1}. **#{d.name}**#{weight_label} — #{d.description}"
88
+ }.join("\n")
89
+
90
+ <<~PROMPT
91
+ You are an independent evaluator. Your job is to assess whether a response
92
+ successfully achieves the given goal. You are NOT the agent that produced
93
+ this response — you are a neutral, objective judge.
94
+
95
+ ## Goal
96
+
97
+ #{goal}
98
+
99
+ ## Rubric
100
+
101
+ Evaluate the response against these dimensions:
102
+
103
+ #{dimensions_text}
104
+
105
+ For each dimension, assign a score:
106
+ - **0** = fails completely
107
+ - **1** = partially meets
108
+ - **2** = fully meets
109
+
110
+ Then provide:
111
+ - A final **decision**: "accept" (response meets the goal), "revise" (needs specific improvements), or "block" (fundamentally wrong — cannot be fixed with revisions)
112
+ - **Actionable feedback** the generator can use to improve (if decision is revise or block)
113
+ - **Concrete evidence** for your scores
114
+
115
+ Return valid JSON only — no other text:
116
+ {
117
+ "scores": { "correctness": 2, "completeness": 1, ... },
118
+ "decision": "accept",
119
+ "feedback": "Specific feedback here (or empty string if accepted)",
120
+ "evidence": ["Evidence point 1", "Evidence point 2"]
121
+ }
122
+ PROMPT
123
+ end
124
+
125
+ def parse_result(text)
126
+ json = extract_json(text)
127
+ return default_fallback unless json
128
+
129
+ scores = json["scores"] || {}
130
+
131
+ decision = case json["decision"].to_s.strip.downcase
132
+ when "revise" then :revise
133
+ when "block" then :block
134
+ else :accept
135
+ end
136
+
137
+ Result.new(
138
+ decision: decision,
139
+ feedback: json["feedback"].to_s.strip,
140
+ scores: scores.transform_keys(&:to_sym),
141
+ evidence: Array(json["evidence"])
142
+ )
143
+ end
144
+
145
+ def extract_json(text)
146
+ # Try direct parse first
147
+ JSON.parse(text.strip)
148
+ rescue JSON::ParserError
149
+ # Fall back to extracting the first JSON object
150
+ match = text.match(/\{.*\}/m)
151
+ match ? JSON.parse(match[0]) : nil
152
+ end
153
+
154
+ def default_fallback
155
+ Result.new(
156
+ decision: :accept,
157
+ feedback: "",
158
+ scores: {},
159
+ evidence: []
160
+ )
161
+ end
162
+ end
163
+ end
164
+ end
@@ -28,6 +28,11 @@ module Ask
28
28
  ReflectionDelta = Data.define(:content)
29
29
  ReflectionEnd = Data.define(:decision, :feedback)
30
30
 
31
+ EvaluationStart = Data.define(:dimensions)
32
+ EvaluationDelta = Data.define(:content)
33
+ EvaluationEnd = Data.define(:decision, :feedback, :scores, :evidence)
34
+ EvaluationBlocked = Data.define(:feedback, :scores, :evidence)
35
+
31
36
  MetaAgentAnalysis = Data.define(:results, :count)
32
37
 
33
38
  Error = Data.define(:error, :recoverable)
@@ -3,14 +3,30 @@
3
3
  module Ask
4
4
  module Agent
5
5
  module Extensions
6
- class PermissionGate
6
+ class Permissions
7
7
  DEFAULT_TOOLS = %i[write edit bash destroy].freeze
8
8
 
9
- def initialize(blocked_tools: DEFAULT_TOOLS, timeout: nil)
10
- @blocked_tools = Array(blocked_tools).map(&:to_sym)
9
+ ACCESS_MODES = {
10
+ full_access: { blocked_tools: [] }.freeze,
11
+ ask_before_changes: { blocked_tools: DEFAULT_TOOLS }.freeze,
12
+ read_only: { blocked_tools: %i[write edit bash destroy] }.freeze
13
+ }.freeze
14
+
15
+ def initialize(mode: nil, blocked_tools: nil, timeout: nil)
16
+ @mode = mode
11
17
  @timeout = timeout
12
18
  @pending = {}
13
19
  @mutex = Mutex.new
20
+
21
+ @blocked_tools = if mode
22
+ config = ACCESS_MODES[mode]
23
+ raise ArgumentError, "Unknown access mode: #{mode.inspect}. Valid: #{ACCESS_MODES.keys.join(', ')}" unless config
24
+ config[:blocked_tools].dup
25
+ elsif blocked_tools
26
+ Array(blocked_tools).map(&:to_sym)
27
+ else
28
+ DEFAULT_TOOLS.dup
29
+ end
14
30
  end
15
31
 
16
32
  def before_tool_call(tool_call, _context)
@@ -61,7 +77,7 @@ module Ask
61
77
  }
62
78
  end
63
79
 
64
- warn "[PermissionGate] Tool '#{tool_call.name}' requires approval. Call approve('#{tool_call.id}') to allow."
80
+ warn "[Permissions] Tool '#{tool_call.name}' requires approval. Call approve('#{tool_call.id}') to allow."
65
81
  { action: :block, reason: "Tool '#{tool_call.name}' requires approval" }
66
82
  end
67
83
  end
@@ -17,7 +17,7 @@ module Ask
17
17
  @max_consecutive_tool_turns = max_consecutive_tool_turns
18
18
  end
19
19
 
20
- def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil)
20
+ def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil)
21
21
  raise MaxTurnsExceeded if @turn_count >= @max_turns
22
22
 
23
23
  event_emitter.emit(Events::TurnStart.new)
@@ -103,6 +103,9 @@ module Ask
103
103
  compactor.run(event_emitter: event_emitter)
104
104
  end
105
105
 
106
+ # Persist after each turn so mid-session crashes don't lose progress
107
+ persist&.call
108
+
106
109
  raise MaxTurnsExceeded if @turn_count >= @max_turns
107
110
 
108
111
  # Recursive call — LLM processes tool results
@@ -114,7 +117,8 @@ module Ask
114
117
  compactor: compactor,
115
118
  hooks: hooks,
116
119
  event_emitter: event_emitter,
117
- session_id: session_id
120
+ session_id: session_id,
121
+ persist: persist
118
122
  )
119
123
  end
120
124
 
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ module Middleware
6
+ # Switches to a fallback model+provider when the primary LLM call fails
7
+ # with a transient error (rate limit, server error, service unavailable).
8
+ #
9
+ # Each fallback specifies both a model and a provider slug, so the
10
+ # middleware can switch from e.g. OpenAI to Anthropic transparently.
11
+ # Credentials for each provider are resolved automatically via
12
+ # {Ask::Auth.resolve}.
13
+ #
14
+ # @example Basic usage — fallback to Anthropic when OpenAI is overloaded
15
+ # pipeline.use :model_fallback, fallbacks: [
16
+ # { model: "claude-sonnet-4", provider: :anthropic },
17
+ # { model: "gemini-2.0-flash", provider: :google }
18
+ # ]
19
+ #
20
+ # @example With failure-trigger customization
21
+ # pipeline.use :model_fallback, fallbacks: [
22
+ # { model: "claude-sonnet-4", provider: :anthropic, on_error: [Ask::RateLimitError, Ask::ServerError] }
23
+ # ]
24
+ #
25
+ # @example Using the block form to choose fallbacks dynamically
26
+ # pipeline.use :model_fallback, fallbacks: ->(error, request) {
27
+ # if request[:messages].sum { |m| m[:content].to_s.length } > 100_000
28
+ # [{ model: "claude-sonnet-4", provider: :anthropic }] # use long-context model
29
+ # else
30
+ # [{ model: "gpt-4o-mini", provider: :openai }] # use cheaper model
31
+ # end
32
+ # }
33
+ class ModelFallback < Base
34
+ DEFAULT_ELIGIBLE_ERRORS = [
35
+ Ask::RateLimitError, Ask::ServerError, Ask::ServiceUnavailable
36
+ ].freeze
37
+
38
+ def initialize(fallbacks:, eligible_errors: nil)
39
+ @fallbacks = fallbacks.respond_to?(:call) ? fallbacks : Array(fallbacks)
40
+ @eligible_errors = Array(eligible_errors || DEFAULT_ELIGIBLE_ERRORS)
41
+ raise ArgumentError, "At least one fallback is required" if Array(@fallbacks).empty?
42
+ end
43
+
44
+ def around_request(provider, request)
45
+ # Try primary provider
46
+ begin
47
+ return yield
48
+ rescue *@eligible_errors => e
49
+ result = try_fallbacks(request, error: e)
50
+ return result if result
51
+ raise
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def try_fallbacks(request, error:)
58
+ fallback_list = resolve_fallback_list(error, request)
59
+
60
+ fallback_list.each do |fb|
61
+ begin
62
+ new_provider = build_fallback_provider(fb[:provider])
63
+ request[:model] = fb[:model]
64
+ return invoke_fallback(new_provider, request)
65
+ rescue *@eligible_errors
66
+ next # Try next fallback
67
+ end
68
+ end
69
+
70
+ nil # All fallbacks exhausted
71
+ end
72
+
73
+ def resolve_fallback_list(error, request)
74
+ list = if @fallbacks.respond_to?(:call)
75
+ @fallbacks.call(error, request)
76
+ else
77
+ @fallbacks
78
+ end
79
+ raise "Fallback list must be an array of hashes, got #{list.class}" unless list.is_a?(Array)
80
+ list
81
+ end
82
+
83
+ def build_fallback_provider(provider_slug)
84
+ slug = provider_slug.to_s
85
+ klass = Ask::Provider.resolve(slug)
86
+ klass.new(fallback_config(slug))
87
+ end
88
+
89
+ def invoke_fallback(provider, request)
90
+ provider.chat(
91
+ request[:messages],
92
+ model: request[:model],
93
+ tools: request[:tools],
94
+ temperature: request[:temperature],
95
+ stream: request[:stream],
96
+ schema: request[:schema],
97
+ **request[:extra_params]
98
+ )
99
+ end
100
+
101
+ def fallback_config(slug)
102
+ key = Ask::Auth.resolve(:"#{slug}_api_key") rescue nil
103
+ config = { api_key: key }
104
+ config[:"#{slug}_api_key"] = key
105
+ Ask::LLM::Config.new(config)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -19,7 +19,8 @@ module Ask
19
19
  KNOWN_MIDDLEWARES = {
20
20
  retry_on_failure: "Ask::Agent::Middleware::RetryOnFailure",
21
21
  log_calls: "Ask::Agent::Middleware::LogCalls",
22
- default_settings: "Ask::Agent::Middleware::DefaultSettings"
22
+ default_settings: "Ask::Agent::Middleware::DefaultSettings",
23
+ model_fallback: "Ask::Agent::Middleware::ModelFallback"
23
24
  }.freeze
24
25
 
25
26
  def initialize
@@ -18,10 +18,10 @@ module Ask
18
18
  attr_reader :skills_registry
19
19
 
20
20
  def initialize(model:, tools: [], max_turns: 25, max_tool_retries: 3,
21
- compactor: nil, hooks: {}, persistence: nil,
21
+ compactor: nil, hooks: {}, state: nil, persistence: nil,
22
22
  id: nil, system_prompt: nil, parallel_tools: true,
23
23
  reflector: nil, telemetry: true, meta_agent: nil,
24
- agent_dir: nil, **chat_options)
24
+ agent_dir: nil, evaluator: nil, **chat_options)
25
25
  @id = id || SecureRandom.uuid
26
26
  @agent_dir = agent_dir
27
27
  @max_turns = max_turns
@@ -48,11 +48,10 @@ module Ask
48
48
  @compactor = compactor ? build_compactor(compactor) : nil
49
49
  @hooks = Hooks.new(hooks)
50
50
 
51
- # Build system context from typed sources
52
51
  @system_context = build_system_context(system_prompt)
53
52
  apply_system_context
54
53
 
55
- @persistence = persistence
54
+ @state = state || persistence
56
55
 
57
56
  reflector_opts = reflector.is_a?(Hash) ? reflector : {}
58
57
  @reflector = if reflector
@@ -66,6 +65,21 @@ module Ask
66
65
  @meta_agent_results = nil
67
66
 
68
67
  @compactor&.chat = @chat
68
+
69
+ # Parse evaluator configuration
70
+ @evaluator = nil
71
+ @evaluator_config = {}
72
+
73
+ if evaluator
74
+ eval_model = if evaluator.is_a?(Hash)
75
+ @evaluator_config = evaluator
76
+ evaluator[:model] || Ask::Agent.configuration.default_evaluator_model || model_id_from(@chat)
77
+ else
78
+ Ask::Agent.configuration.default_evaluator_model || model_id_from(@chat)
79
+ end
80
+
81
+ @evaluator = Evaluator.new(model: eval_model)
82
+ end
69
83
  end
70
84
 
71
85
  def run(message, tools: nil)
@@ -89,16 +103,17 @@ module Ask
89
103
  begin
90
104
  @tool_executor.telemetry = @telemetry
91
105
 
92
- response = @loop.run_turn(
93
- chat: @chat,
94
- message: message,
95
- tools: active_tools,
96
- tool_executor: @tool_executor,
97
- compactor: @compactor,
98
- hooks: @hooks,
99
- event_emitter: self,
100
- session_id: @id
101
- )
106
+ response = @loop.run_turn(
107
+ chat: @chat,
108
+ message: message,
109
+ tools: active_tools,
110
+ tool_executor: @tool_executor,
111
+ compactor: @compactor,
112
+ hooks: @hooks,
113
+ event_emitter: self,
114
+ session_id: @id,
115
+ persist: @state ? method(:persist!) : nil
116
+ )
102
117
 
103
118
  @total_input_tokens += @loop.last_input_tokens.to_i
104
119
  @total_output_tokens += @loop.last_output_tokens.to_i
@@ -122,12 +137,67 @@ module Ask
122
137
  raise
123
138
  ensure
124
139
  @running = false
125
- persist! if @persistence
140
+ persist! if @state
126
141
  end
127
142
 
128
143
  @tool_calls_made = @tool_executor.total_executions
129
144
 
130
- if @reflector && @reflector.reflect?(@tool_calls_made) && !@abort_requested
145
+ # Independent evaluator step (generator/evaluator separation).
146
+ # Runs BEFORE self-reflection so the evaluator gets a fresh, unbiased look
147
+ # at the generator's output using a separate model and isolated context.
148
+ @skip_reflector = false
149
+
150
+ if @evaluator && !@abort_requested
151
+ goal = @evaluator_config[:goal] || message
152
+
153
+ eval_result = @evaluator.evaluate(
154
+ goal: goal.to_s,
155
+ response: response,
156
+ event_emitter: self
157
+ )
158
+
159
+ @telemetry.log(:evaluation_end, session_id: @id,
160
+ decision: eval_result.decision,
161
+ feedback: eval_result.feedback,
162
+ scores: eval_result.scores)
163
+
164
+ case eval_result.decision
165
+ when :revise
166
+ @chat.add_message(
167
+ role: :system,
168
+ content: "An independent evaluator has requested revisions:\n\n#{eval_result.feedback}"
169
+ )
170
+
171
+ response = @loop.run_turn(
172
+ chat: @chat,
173
+ message: "",
174
+ tools: active_tools,
175
+ tool_executor: @tool_executor,
176
+ compactor: @compactor,
177
+ hooks: @hooks,
178
+ event_emitter: self,
179
+ session_id: @id
180
+ )
181
+
182
+ @total_input_tokens += @loop.last_input_tokens.to_i
183
+ @total_output_tokens += @loop.last_output_tokens.to_i
184
+ @total_cost += @loop.last_cost.to_f
185
+
186
+ # Skip reflector — we already iterated based on evaluator feedback
187
+ @skip_reflector = true
188
+ when :block
189
+ emit(Events::EvaluationBlocked.new(
190
+ feedback: eval_result.feedback,
191
+ scores: eval_result.scores,
192
+ evidence: eval_result.evidence
193
+ ))
194
+ response = "This response was blocked by the evaluator: #{eval_result.feedback}"
195
+ when :accept
196
+ # Fall through to reflector for backward compatibility
197
+ end
198
+ end
199
+
200
+ if @reflector && !@skip_reflector && @reflector.reflect?(@tool_calls_made) && !@abort_requested
131
201
  eval_result = @reflector.evaluate(response: response, event_emitter: self)
132
202
  @telemetry.log(:reflection_end, session_id: @id, decision: eval_result[:decision], feedback: eval_result[:feedback])
133
203
 
@@ -193,34 +263,37 @@ module Ask
193
263
  def deleted? = @deleted
194
264
 
195
265
  def save
196
- persist! if @persistence
266
+ persist! if @state
197
267
  end
198
268
 
199
269
  def self.load(id, adapter:)
200
- data = adapter.load(id)
270
+ data = adapter.get(id)
201
271
  return nil unless data
202
272
 
273
+ data = deep_symbolize_keys(data)
274
+
203
275
  session = new(
204
276
  id: data[:id],
205
277
  model: data.dig(:metadata, :model),
206
278
  tools: data.dig(:metadata, :tools)&.map(&:constantize) || [],
207
- persistence: adapter
279
+ state: adapter
208
280
  )
209
281
 
210
- data[:messages].each do |msg|
211
- session.chat.add_message(
212
- role: msg[:role].to_sym,
213
- content: msg[:content],
214
- tool_call_id: msg[:tool_call_id]
215
- )
216
- end
217
-
218
- session
282
+ data[:messages].each do |msg|
283
+ session.chat.add_message(
284
+ role: msg[:role].to_sym,
285
+ content: msg[:content],
286
+ tool_call_id: msg[:tool_call_id]
287
+ )
288
+ end
289
+
290
+ session.instance_variable_set(:@messages, session.chat.messages.dup)
291
+ session
219
292
  end
220
293
 
221
294
  def delete
222
295
  @deleted = true
223
- @persistence&.delete(@id)
296
+ @state&.delete(@id)
224
297
  end
225
298
 
226
299
  def abort
@@ -290,7 +363,7 @@ module Ask
290
363
  end
291
364
 
292
365
  def persist!
293
- @persistence.save(@id, {
366
+ @state.set(@id, {
294
367
  id: @id,
295
368
  messages: @chat.messages.map { |m|
296
369
  {
@@ -358,6 +431,19 @@ module Ask
358
431
  SystemContext.new(sources)
359
432
  end
360
433
 
434
+ # Recursively convert string keys to symbol keys in hashes.
435
+ # Needed when loading session data that was serialized through JSON.
436
+ def self.deep_symbolize_keys(obj)
437
+ case obj
438
+ when Hash
439
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize_keys(v) }
440
+ when Array
441
+ obj.map { |e| deep_symbolize_keys(e) }
442
+ else
443
+ obj
444
+ end
445
+ end
446
+
361
447
  # Render the system context and apply it to the chat.
362
448
  def apply_system_context
363
449
  rendered = @system_context.render
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.12.1"
5
+ VERSION = "0.15.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -21,7 +21,7 @@ module Ask
21
21
  class UnknownAgent < Error; end
22
22
 
23
23
  module Extensions
24
- autoload :PermissionGate, "ask/agent/extensions/permission_gate"
24
+ autoload :Permissions, "ask/agent/extensions/permissions"
25
25
  autoload :RateLimiter, "ask/agent/extensions/rate_limiter"
26
26
  autoload :AuditLog, "ask/agent/extensions/audit_log"
27
27
  end
@@ -32,6 +32,7 @@ module Ask
32
32
  autoload :RetryOnFailure, "ask/agent/middleware/retry_on_failure"
33
33
  autoload :LogCalls, "ask/agent/middleware/log_calls"
34
34
  autoload :DefaultSettings, "ask/agent/middleware/default_settings"
35
+ autoload :ModelFallback, "ask/agent/middleware/model_fallback"
35
36
  end
36
37
 
37
38
  module StreamTransforms
@@ -231,6 +232,7 @@ require_relative "agent/tool_abort_controller"
231
232
  require_relative "agent/session"
232
233
  require_relative "agent/loop"
233
234
  require_relative "agent/reflector"
235
+ require_relative "agent/evaluator"
234
236
  require_relative "agent/tool_executor"
235
237
  require_relative "agent/compactor"
236
238
  require_relative "agent/hooks"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ask-state-providers
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: ask-llm-providers
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -157,9 +171,10 @@ files:
157
171
  - lib/ask/agent/context_source.rb
158
172
  - lib/ask/agent/context_sources.rb
159
173
  - lib/ask/agent/definition.rb
174
+ - lib/ask/agent/evaluator.rb
160
175
  - lib/ask/agent/events.rb
161
176
  - lib/ask/agent/extensions/audit_log.rb
162
- - lib/ask/agent/extensions/permission_gate.rb
177
+ - lib/ask/agent/extensions/permissions.rb
163
178
  - lib/ask/agent/extensions/rate_limiter.rb
164
179
  - lib/ask/agent/hooks.rb
165
180
  - lib/ask/agent/loop.rb
@@ -167,6 +182,7 @@ files:
167
182
  - lib/ask/agent/middleware/base.rb
168
183
  - lib/ask/agent/middleware/default_settings.rb
169
184
  - lib/ask/agent/middleware/log_calls.rb
185
+ - lib/ask/agent/middleware/model_fallback.rb
170
186
  - lib/ask/agent/middleware/pipeline.rb
171
187
  - lib/ask/agent/middleware/retry_on_failure.rb
172
188
  - lib/ask/agent/persistence/base.rb