ask-agent 0.14.0 → 0.20.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: 44a7d741bb255935ebfa4790a7da3d36dc4b4200ab8ebd1e061f8d31b194c798
4
+ data.tar.gz: ad33f40cffc85328e4cc753291db6fa5730fd7edffc52fcf4f96b34eade7a863
5
5
  SHA512:
6
- metadata.gz: bb1bf1b6aac7a61a650291f9dcabd781c0a664cb09d97c7f2cc2fcdcfaa035b71adef2c423ebacfaae0de04d0524b4dff649f503921006837e0644d9a9f86615
7
- data.tar.gz: d718885adebbaf33f2b9a2b95704968494d14e3080746a58da37c9518af475ac09103357014e5bea98339d56150ce63a162c6ac9a9c8ae4cffa07f13716b8793
6
+ metadata.gz: 3e8c63b1465e8090d050e92b03773ec1a38caf9b2c4b22abbb08f5b19007e50317d5befa058343ea6c08e11450ad0a5c5c2f99609ef64d5f00ef9855893f5086
7
+ data.tar.gz: 02e39e5fdb5ecdcd91dc57572a0594d90155534ed7dc282b1029a4041fe7943b94e45c5066e1c86c75b1cb0efb7824a5eb522dcd650c81ad41cfd5177dfafc98
data/CHANGELOG.md CHANGED
@@ -1,3 +1,132 @@
1
+ ## [0.19.0] — 2026-07-26
2
+
3
+ ### Added
4
+
5
+ - **`Ask::Agent::SubAgent.new("definition_name")` — create sub-agents from
6
+ filesystem definitions**. Passing a string looks up an agent definition
7
+ by name (same convention as `Ask::Agent.new("name")`), reading model,
8
+ tools, instructions, and other settings from the definition files.
9
+
10
+ ```ruby
11
+ # agents/web_search/agent.rb defines model, tools, instructions
12
+ search = Ask::Agent::SubAgent.new("web_search")
13
+
14
+ coordinator = Ask::Agent::Session.new(
15
+ model: "gpt-4o",
16
+ tools: [search, Ask::Tools::Shell::Bash]
17
+ )
18
+ ```
19
+
20
+ - **VCR-based integration tests** for SubAgent. Real API calls are recorded
21
+ and replayed via VCR cassettes. Run with `OPENAI_API_KEY` set to record,
22
+ or without to replay existing cassettes.
23
+
24
+ ### Changed
25
+
26
+ - `Ask::Agent::SubAgent.new(name:, ...)` now supports `provider:` parameter
27
+ for provider-specific sub-agents.
28
+
29
+ ## [0.18.0] — 2026-07-26
30
+
31
+ ### Added
32
+
33
+ - **`Ask::Agent::SubAgent` — delegate tasks to a specialized sub-agent tool**.
34
+ A self-contained tool class that satisfies the tool duck type (`name`,
35
+ `description`, `params_schema`, `call`). When the coordinator LLM calls it,
36
+ a fresh sub-agent session runs independently with its own model, tools,
37
+ and instructions.
38
+
39
+ ```ruby
40
+ search = Ask::Agent::SubAgent.new(
41
+ name: "web_search",
42
+ description: "Search the web for current information",
43
+ model: "gpt-4o-mini",
44
+ tools: [MyApp::Tools::WebSearch],
45
+ system_prompt: "You are a research assistant."
46
+ )
47
+
48
+ coordinator = Ask::Agent::Session.new(
49
+ model: "gpt-4o",
50
+ tools: [search, Ask::Tools::Shell::Bash]
51
+ )
52
+
53
+ coordinator.run("What's the latest Rails release and how stable is it?")
54
+ ```
55
+
56
+ ### Removed
57
+
58
+ - **`Ask::Agent.sub_agent_tool`** factory method — replaced by the
59
+ `Ask::Agent::SubAgent` class directly. The class IS the tool, no
60
+ factory or wrapper needed.
61
+
62
+ ## [0.17.0] — 2026-07-26
63
+
64
+ ### Added
65
+
66
+ - Bump ask-tools dependency for `Ask::Tools::SubAgent` support
67
+
68
+ ### Added
69
+
70
+ - **Independent Evaluator — `Ask::Agent::Evaluator`** — Generator/evaluator separation.
71
+ A separate model (configured independently from the session's model) judges the
72
+ agent's output against a structured rubric before delivery. This prevents the
73
+ anti-pattern of a model grading its own work.
74
+
75
+ ```ruby
76
+ # Evaluate with a different model — the recommended approach
77
+ session = Ask::Agent::Session.new(
78
+ model: "gpt-4o",
79
+ evaluator: { model: "claude-sonnet-4", goal: "Write an email validator" }
80
+ )
81
+ session.run("Write email validation")
82
+ ```
83
+
84
+ Three verdicts:
85
+ - **`:accept`** — output meets the goal, passes through to reflection
86
+ - **`:revise`** — evaluator provides actionable feedback; session runs another
87
+ turn with the feedback injected into system context
88
+ - **`:block`** — output is fundamentally wrong; session returns blocked message
89
+ and emits `Events::EvaluationBlocked`
90
+
91
+ Rubric dimensions (each scored 0-2):
92
+ - correctness (3× weight), completeness (2×), verification (2×), scope (1×), clarity (1×)
93
+
94
+ Custom rubrics supported:
95
+ ```ruby
96
+ evaluator = Ask::Agent::Evaluator.new(
97
+ model: "claude-sonnet-4",
98
+ rubric: [
99
+ Ask::Agent::Evaluator::Dimension.new(name: "performance", description: "Is it fast?", weight: 2)
100
+ ]
101
+ )
102
+ ```
103
+
104
+ - **`evaluator:` option on `Session`** — accepts `true`, `false`/`nil`, or a Hash:
105
+ - `evaluator: true` — uses `config.default_evaluator_model` (falls back to the
106
+ session's model, though using a different model is strongly recommended)
107
+ - `evaluator: { model: "claude-sonnet-4", goal: "Custom goal" }` — explicit config
108
+ - `evaluator: false` (default) — no evaluation, backward compatible
109
+
110
+ - **`default_evaluator_model` config option** — set a global default:
111
+ ```ruby
112
+ Ask::Agent.configure do |c|
113
+ c.default_evaluator_model = "claude-sonnet-4"
114
+ end
115
+ ```
116
+
117
+ - **New event types** for streaming evaluation:
118
+ - `Events::EvaluationStart` — emitted when evaluation begins (includes dimension list)
119
+ - `Events::EvaluationDelta` — streamed evaluation text from the evaluator model
120
+ - `Events::EvaluationEnd` — emitted with decision, feedback, scores, and evidence
121
+ - `Events::EvaluationBlocked` — emitted when evaluator returns `:block`
122
+
123
+ - **17 unit tests** for Evaluator — construction, rubric, all three verdicts, event
124
+ emission, custom rubrics, JSON parsing, and malformed response fallback.
125
+
126
+ - **7 integration tests** for Session with evaluator — config (true/hash),
127
+ revise triggers improvement, revise skips reflector, block returns blocked
128
+ message, block emits event, evaluator-not-configured skips evaluation.
129
+
1
130
  ## [0.14.0] — 2026-07-23
2
131
 
3
132
  ### 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
@@ -11,6 +11,7 @@ module Ask
11
11
 
12
12
  MessageStart = Data.define
13
13
  TextDelta = Data.define(:content)
14
+ ThinkingDelta = Data.define(:content)
14
15
  ToolCallDelta = Data.define(:name, :arguments, :id)
15
16
  MessageEnd = Data.define(:tool_calls)
16
17
 
@@ -28,6 +29,11 @@ module Ask
28
29
  ReflectionDelta = Data.define(:content)
29
30
  ReflectionEnd = Data.define(:decision, :feedback)
30
31
 
32
+ EvaluationStart = Data.define(:dimensions)
33
+ EvaluationDelta = Data.define(:content)
34
+ EvaluationEnd = Data.define(:decision, :feedback, :scores, :evidence)
35
+ EvaluationBlocked = Data.define(:feedback, :scores, :evidence)
36
+
31
37
  MetaAgentAnalysis = Data.define(:results, :count)
32
38
 
33
39
  Error = Data.define(:error, :recoverable)
@@ -27,6 +27,10 @@ module Ask
27
27
  event_emitter.emit(Events::TextDelta.new(content: chunk.content))
28
28
  end
29
29
 
30
+ if chunk.respond_to?(:thinking) && chunk.thinking.to_s.strip.length > 0
31
+ event_emitter.emit(Events::ThinkingDelta.new(content: chunk.thinking))
32
+ end
33
+
30
34
  if chunk.tool_call?
31
35
  chunk.tool_calls.each do |id, tc|
32
36
  event_emitter.emit(Events::ToolCallDelta.new(
@@ -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
 
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Agent
7
+ # Framework-agnostic SSE streaming for agent sessions.
8
+ #
9
+ # Returns a Rack-compatible Enumerator that yields SSE-formatted strings
10
+ # as the agent runs. Works with any Rack server (Puma, Falcon, etc.)
11
+ # without requiring ActionController::Live or Rails.
12
+ #
13
+ # @example In a Rails controller (with ActionController::Live::SSE)
14
+ # def create
15
+ # response.headers["Content-Type"] = "text/event-stream"
16
+ # sse = SSE.new(response.stream)
17
+ #
18
+ # Ask::Agent::Streaming.run(session, prompt) do |type, data|
19
+ # sse.write(data, event: type)
20
+ # end
21
+ # ensure
22
+ # sse&.close
23
+ # end
24
+ #
25
+ # @example In a Rack app (raw Enumerator)
26
+ # stream = Ask::Agent::Streaming.run(session, prompt)
27
+ # [200, { "Content-Type" => "text/event-stream" }, stream]
28
+ #
29
+ # @example With custom event mapping
30
+ # stream = Ask::Agent::Streaming.run(session, prompt) do |event|
31
+ # case event
32
+ # when Events::TextDelta
33
+ # { type: "delta", data: { content: event.content } }
34
+ # when Events::ToolExecutionStart
35
+ # { type: "tool_start", data: { name: event.name, id: event.id } }
36
+ # else
37
+ # nil # skip unhandled events
38
+ # end
39
+ # end
40
+ module Streaming
41
+ DEFAULT_EVENT_MAP = {
42
+ Events::TextDelta => "delta",
43
+ Events::ThinkingDelta => "thinking",
44
+ Events::ToolCallDelta => "tool_call_delta",
45
+ Events::ToolExecutionStart => "tool_start",
46
+ Events::ToolExecutionUpdate => "tool_update",
47
+ Events::ToolExecutionEnd => "tool_end",
48
+ Events::SessionEnd => "done",
49
+ Events::Error => "error"
50
+ }.freeze
51
+
52
+ class << self
53
+ # Run an agent session and stream events as SSE-formatted strings.
54
+ #
55
+ # Two modes:
56
+ #
57
+ # 1. **No block** — returns a Rack-compatible Enumerator that yields
58
+ # raw SSE strings: "data: {\"type\":\"delta\",\"content\":\"...\"}\n\n"
59
+ #
60
+ # 2. **With block** — calls the block for each event with
61
+ # `(event_type_string, data_hash)`. The block is responsible for
62
+ # writing/handling the data. This mode is designed for use with
63
+ # Rails' `ActionController::Live::SSE#write`.
64
+ #
65
+ # @param session [Session] the agent session to run
66
+ # @param prompt [String] the user's message
67
+ # @param event_map [Hash<Class, String>] optional custom event-to-type mapping
68
+ # @yield [type, data] called for each event (only in block mode)
69
+ # @yieldparam type [String] the SSE event type name
70
+ # @yieldparam data [Hash] the event data payload
71
+ # @return [Enumerator, nil] Enumerator in no-block mode, nil in block mode
72
+ def run(session, prompt, event_map: {}, &block)
73
+ mapping = DEFAULT_EVENT_MAP.merge(event_map)
74
+
75
+ if block
76
+ run_with_block(session, prompt, mapping, &block)
77
+ nil
78
+ else
79
+ run_with_enumerator(session, prompt, mapping)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def run_with_block(session, prompt, mapping)
86
+ errors = []
87
+
88
+ session.on_event do |event|
89
+ type = event_type(event, mapping)
90
+ data = event_data(event)
91
+ next unless type
92
+
93
+ yield(type, data)
94
+ end
95
+
96
+ # Emit start event
97
+ yield("start", { session_id: session.id })
98
+
99
+ session.run(prompt)
100
+
101
+ # If errors accumulated during tool execution, emit them
102
+ errors.each { |err| yield("error", { message: err }) }
103
+ rescue => e
104
+ yield("error", { message: e.message })
105
+ end
106
+
107
+ def run_with_enumerator(session, prompt, mapping)
108
+ Enumerator.new do |yielder|
109
+ errors = []
110
+
111
+ session.on_event do |event|
112
+ type = event_type(event, mapping)
113
+ data = event_data(event)
114
+ next unless type
115
+
116
+ yielder << sse_line(type, data)
117
+ end
118
+
119
+ # Emit start event
120
+ yielder << sse_line("start", { session_id: session.id })
121
+
122
+ session.run(prompt)
123
+
124
+ errors.each { |err| yielder << sse_line("error", { message: err }) }
125
+
126
+ yielder << sse_line("close", {})
127
+ rescue => e
128
+ yielder << sse_line("error", { message: e.message })
129
+ ensure
130
+ yielder << sse_line("close", {})
131
+ end
132
+ end
133
+
134
+ def event_type(event, mapping)
135
+ # Check for a direct class match
136
+ return mapping[event.class] if mapping.key?(event.class)
137
+
138
+ # Check for a superclass match (e.g. ToolExecutionStart is a kind of event)
139
+ event.class.ancestors.each do |ancestor|
140
+ return mapping[ancestor] if mapping.key?(ancestor)
141
+ end
142
+
143
+ nil
144
+ end
145
+
146
+ def event_data(event)
147
+ case event
148
+ when Events::TextDelta
149
+ { content: event.content }
150
+ when Events::ThinkingDelta
151
+ { content: event.content }
152
+ when Events::ToolCallDelta
153
+ { name: event.name, arguments: event.arguments, id: event.id }
154
+ when Events::ToolExecutionStart
155
+ { name: event.name, id: event.id, args: safe_args(event.arguments) }
156
+ when Events::ToolExecutionUpdate
157
+ { id: event.id, partial_result: event.partial_result.to_s.truncate(200) }
158
+ when Events::ToolExecutionEnd
159
+ { name: event.name, id: event.id, duration_ms: event.duration_ms, is_error: event.is_error }
160
+ when Events::SessionEnd
161
+ { turn_count: event.turn_count, tool_calls_made: event.tool_calls_made,
162
+ input_tokens: event.input_tokens, output_tokens: event.output_tokens,
163
+ cost: event.cost }
164
+ when Events::SessionStart
165
+ {}
166
+ when Events::Error
167
+ { message: event.error, recoverable: event.recoverable }
168
+ else
169
+ {}
170
+ end
171
+ end
172
+
173
+ def sse_line(type, data)
174
+ payload = data.merge(type: type)
175
+ "data: #{JSON.generate(payload)}\n\n"
176
+ end
177
+
178
+ def safe_args(args)
179
+ return {} unless args.is_a?(Hash)
180
+
181
+ safe = args.dup
182
+ %w[password secret token api_key key auth_token access_token sql command].each do |sensitive|
183
+ safe[sensitive] = "[REDACTED]" if safe.key?(sensitive)
184
+ end
185
+ safe
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ # A tool that delegates a task to a specialized sub-agent.
6
+ #
7
+ # The coordinator agent sees this as a regular tool — when the LLM calls it,
8
+ # a fresh sub-agent session runs independently with its own model, tools,
9
+ # and instructions, and returns the result.
10
+ #
11
+ # Satisfies the tool duck type (name, description, params_schema, call)
12
+ # so it can be passed directly in the tools array.
13
+ #
14
+ # @example From a filesystem definition
15
+ # # agents/web_search/agent.rb defines a WebSearch agent.
16
+ # # Use it as a sub-agent by passing its name:
17
+ #
18
+ # search = Ask::Agent::SubAgent.new("web_search")
19
+ #
20
+ # coordinator = Ask::Agent::Session.new(
21
+ # model: "gpt-4o",
22
+ # tools: [search, Ask::Tools::Shell::Bash]
23
+ # )
24
+ #
25
+ # @example Inline configuration
26
+ # search = Ask::Agent::SubAgent.new(
27
+ # name: "web_search",
28
+ # description: "Search the web for current information",
29
+ # model: "gpt-4o-mini",
30
+ # tools: [Ask::Tools::WebSearch],
31
+ # system_prompt: "You are a research assistant."
32
+ # )
33
+ #
34
+ # @example Using with a different provider
35
+ # review = Ask::Agent::SubAgent.new(
36
+ # name: "code_review",
37
+ # model: "claude-sonnet-4",
38
+ # provider: :anthropic,
39
+ # tools: [Ask::Tools::Shell::Read, Ask::Tools::Shell::Grep],
40
+ # system_prompt: "You are a senior code reviewer."
41
+ # )
42
+ class SubAgent
43
+ # @return [String] tool name visible to the LLM
44
+ attr_reader :name
45
+
46
+ # @return [String] tool description visible to the LLM
47
+ attr_reader :description
48
+
49
+ # Create a new SubAgent tool.
50
+ #
51
+ # When given a String, looks up a filesystem agent definition by name
52
+ # (matching the convention used by {Ask::Agent.new}). Model, tools,
53
+ # instructions, and other settings are read from the definition files.
54
+ #
55
+ # When given keyword arguments, configures the sub-agent inline.
56
+ #
57
+ # @overload initialize(definition_name)
58
+ # @param definition_name [String] Name of a filesystem agent definition.
59
+ # @raise [Ask::Agent::UnknownAgent] If no definition is found.
60
+ #
61
+ # @overload initialize(name:, description: nil, model:, tools: [],
62
+ # system_prompt: nil, provider: nil, max_turns: 10, **session_opts)
63
+ # @param name [String] Tool name (e.g. "web_search").
64
+ # @param description [String, nil] Tool description. Auto-generated
65
+ # from the model and tools count if not provided.
66
+ # @param model [String] Model identifier for the sub-agent session.
67
+ # @param tools [Array<Class, Object>] Tools available to the sub-agent.
68
+ # @param system_prompt [String, nil] Instructions for the sub-agent.
69
+ # @param provider [Symbol, nil] Provider override.
70
+ # @param max_turns [Integer] Max conversation turns for the sub-agent.
71
+ # @param session_opts [Hash] Additional options forwarded to Session.new.
72
+ def initialize(definition_name = nil, name: nil, description: nil, model: nil,
73
+ tools: [], system_prompt: nil, provider: nil,
74
+ max_turns: 10, **session_opts)
75
+ if definition_name
76
+ from_definition(definition_name, **session_opts)
77
+ else
78
+ @name = name
79
+ @description = description || default_description(model, tools)
80
+ @model = model
81
+ @tools = tools.map { |t| t.is_a?(Class) ? t.new : t }
82
+ @system_prompt = system_prompt
83
+ @provider = provider
84
+ @max_turns = max_turns
85
+ @session_opts = session_opts
86
+ end
87
+ end
88
+
89
+ # JSON Schema for the tool's parameter.
90
+ #
91
+ # @return [Hash]
92
+ def params_schema
93
+ {
94
+ type: "object",
95
+ properties: {
96
+ "task" => {
97
+ type: "string",
98
+ description: "The task to delegate to the sub-agent"
99
+ }
100
+ },
101
+ required: ["task"],
102
+ additionalProperties: false
103
+ }
104
+ end
105
+
106
+ # Provider-specific parameters (none by default).
107
+ #
108
+ # @return [Hash]
109
+ def provider_params
110
+ {}
111
+ end
112
+
113
+ # Execute the sub-agent with the given task.
114
+ #
115
+ # Creates a fresh session for each call, runs the task, and returns the
116
+ # result. If the sub-agent fails, returns an error result — the
117
+ # coordinator can decide how to proceed.
118
+ #
119
+ # @param args [Hash, String] Arguments from the LLM.
120
+ # @param abort_controller [Object, nil] Optional abort controller.
121
+ # @return [Ask::Result]
122
+ def call(args = {}, abort_controller = nil)
123
+ task = extract_task(args)
124
+
125
+ session_opts = {
126
+ model: @model,
127
+ tools: @tools.map(&:class),
128
+ max_turns: @max_turns
129
+ }
130
+ session_opts[:provider] = @provider if @provider
131
+ session_opts[:system_prompt] = @system_prompt if @system_prompt
132
+ session_opts.merge!(@session_opts)
133
+
134
+ session = Session.new(**session_opts)
135
+ result = session.run(task.to_s)
136
+ Ask::Result.ok(data: result.to_s)
137
+ rescue StandardError => e
138
+ Ask::Result.failure("SubAgent '#{@name}' error: #{e.message}")
139
+ end
140
+
141
+ # Human-readable representation.
142
+ #
143
+ # @return [String]
144
+ def inspect
145
+ "#<Ask::Agent::SubAgent name=#{@name.inspect}>"
146
+ end
147
+
148
+ private
149
+
150
+ def from_definition(definition_name, **session_opts)
151
+ Ask::Agent.rediscover!
152
+ entry = Ask::Agent.definitions[definition_name.to_s]
153
+ raise UnknownAgent, "Unknown agent: #{definition_name.inspect}" unless entry
154
+
155
+ klass, dir = entry
156
+ config = klass._config
157
+
158
+ @name = definition_name
159
+ @model = config[:model]
160
+ @description = "Delegate to #{definition_name} sub-agent (#{@model})"
161
+ @provider = config[:provider]
162
+
163
+ # Resolve tools from definition
164
+ resolved_tools = Ask::Agent.__send__(:resolve_definition_tools, config[:tools] || [], dir)
165
+ @tools = resolved_tools.map { |t| t.is_a?(Class) ? t.new : t }
166
+
167
+ # Load instructions from definition
168
+ prompt = klass.instructions_content
169
+ @system_prompt = prompt
170
+
171
+ @max_turns = config[:max_turns] || 10
172
+ # Merge any options from the definition config
173
+ @session_opts = (config[:options] || {}).merge(session_opts)
174
+ end
175
+
176
+ def extract_task(args)
177
+ case args
178
+ when Hash then (args["task"] || args[:task] || args.to_s).to_s
179
+ else args.to_s
180
+ end
181
+ end
182
+
183
+ def default_description(model, tools)
184
+ desc = "Delegate to a sub-agent (#{model}"
185
+ desc += " with #{tools.size} tool(s)" if tools.any?
186
+ desc + ")"
187
+ end
188
+ end
189
+ end
190
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.14.0"
5
+ VERSION = "0.20.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"
@@ -244,6 +244,29 @@ require_relative "agent/skills/load_skill_tool"
244
244
  require_relative "agent/scheduler"
245
245
  require_relative "agent/definition"
246
246
  require_relative "agent/cli"
247
+ require_relative "agent/streaming"
248
+ require_relative "agent/sub_agent"
247
249
 
248
250
  # Test helpers (loaded on demand)
249
251
  autoload :Test, "ask/agent/test"
252
+
253
+ # Convenience method on the top-level Ask module.
254
+ # Provides a quick one-shot chat without instantiating a Session directly.
255
+ #
256
+ # Ask.chat("Hello")
257
+ # Ask.chat("Tell me about X", model: "gpt-4o")
258
+ # Ask.chat("Stream this") { |chunk| puts chunk.content }
259
+ #
260
+ module Ask
261
+ def self.chat(message, model: nil, system_prompt: nil, &block)
262
+ session = Agent::Session.new(
263
+ model: model || Agent.configuration.default_model,
264
+ system_prompt: system_prompt
265
+ )
266
+ if block
267
+ session.run(message, &block)
268
+ else
269
+ session.run(message)
270
+ end
271
+ end
272
+ end
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.20.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
@@ -182,6 +196,8 @@ files:
182
196
  - lib/ask/agent/stream_transforms/pipeline.rb
183
197
  - lib/ask/agent/stream_transforms/text_buffer.rb
184
198
  - lib/ask/agent/stream_transforms/thinking_separator.rb
199
+ - lib/ask/agent/streaming.rb
200
+ - lib/ask/agent/sub_agent.rb
185
201
  - lib/ask/agent/system_context.rb
186
202
  - lib/ask/agent/telemetry.rb
187
203
  - lib/ask/agent/test.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