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 +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +82 -1
- data/lib/ask/agent/configuration.rb +1 -1
- data/lib/ask/agent/evaluator.rb +164 -0
- data/lib/ask/agent/events.rb +6 -0
- data/lib/ask/agent/loop.rb +4 -0
- data/lib/ask/agent/session.rb +72 -2
- data/lib/ask/agent/streaming.rb +190 -0
- data/lib/ask/agent/sub_agent.rb +190 -0
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +24 -1
- metadata +18 -2
- data/lib/ask/agent/extensions/permission_gate.rb +0 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 44a7d741bb255935ebfa4790a7da3d36dc4b4200ab8ebd1e061f8d31b194c798
|
|
4
|
+
data.tar.gz: ad33f40cffc85328e4cc753291db6fa5730fd7edffc52fcf4f96b34eade7a863
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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::
|
|
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
|
data/lib/ask/agent/events.rb
CHANGED
|
@@ -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)
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -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(
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/ask/agent/version.rb
CHANGED
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.
|
|
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
|