ask-agent 0.14.0 → 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: c80f27cbdec5b3808533dba4861762e19e4ef81c084be099a4a7eb0b2169170e
4
- data.tar.gz: 34e5388035a8f216df468039bf8c7e304296d7661832e8109df4d76fbb527193
3
+ metadata.gz: 816f21ed178b31227a58afeeed65f2428dbe9cce7d4a6ddab2c6fda7a6a60a89
4
+ data.tar.gz: 4dec09ce3cc94c6abc0e94bd5dee9f6ac9bde083dc7995af601a6650e692fd37
5
5
  SHA512:
6
- metadata.gz: bb1bf1b6aac7a61a650291f9dcabd781c0a664cb09d97c7f2cc2fcdcfaa035b71adef2c423ebacfaae0de04d0524b4dff649f503921006837e0644d9a9f86615
7
- data.tar.gz: d718885adebbaf33f2b9a2b95704968494d14e3080746a58da37c9518af475ac09103357014e5bea98339d56150ce63a162c6ac9a9c8ae4cffa07f13716b8793
6
+ metadata.gz: 8de0c033834105f7c147115bd84b97306fc91b787690fc9e82563835f53b8fad69ff90dfabeeada12be9a8651e9ecfffd960d8e2fa6e84ffff4709f30519a5de
7
+ data.tar.gz: b5d46db9e66b242ea35fafaf86c67401ad27ee8ef0099ad4811a3d8442e63bd54aa2b16be5e4a7b2aa0f7305603cf39d1b5497e66648f59a9d701489bbdc6c8c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,67 @@
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
+
1
65
  ## [0.14.0] — 2026-07-23
2
66
 
3
67
  ### 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
 
@@ -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)
@@ -21,7 +21,7 @@ module Ask
21
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
@@ -65,6 +65,21 @@ module Ask
65
65
  @meta_agent_results = nil
66
66
 
67
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
68
83
  end
69
84
 
70
85
  def run(message, tools: nil)
@@ -127,7 +142,62 @@ module Ask
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.14.0"
5
+ VERSION = "0.15.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -22,7 +22,6 @@ module Ask
22
22
 
23
23
  module Extensions
24
24
  autoload :Permissions, "ask/agent/extensions/permissions"
25
- autoload :PermissionGate, "ask/agent/extensions/permission_gate"
26
25
  autoload :RateLimiter, "ask/agent/extensions/rate_limiter"
27
26
  autoload :AuditLog, "ask/agent/extensions/audit_log"
28
27
  end
@@ -233,6 +232,7 @@ require_relative "agent/tool_abort_controller"
233
232
  require_relative "agent/session"
234
233
  require_relative "agent/loop"
235
234
  require_relative "agent/reflector"
235
+ require_relative "agent/evaluator"
236
236
  require_relative "agent/tool_executor"
237
237
  require_relative "agent/compactor"
238
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.14.0
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,9 @@ 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
163
177
  - lib/ask/agent/extensions/permissions.rb
164
178
  - lib/ask/agent/extensions/rate_limiter.rb
165
179
  - lib/ask/agent/hooks.rb
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Legacy alias — PermissionGate has been renamed to Permissions.
4
- # This file will be removed in the next major version.
5
- require_relative "permissions"
6
-
7
- module Ask
8
- module Agent
9
- module Extensions
10
- PermissionGate = Permissions
11
- end
12
- end
13
- end