ask-agent 0.24.2 → 0.25.2
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 +67 -0
- data/README.md +38 -208
- data/lib/ask/agent/compactor.rb +197 -17
- data/lib/ask/agent/configuration.rb +4 -2
- data/lib/ask/agent/loop.rb +10 -1
- data/lib/ask/agent/session.rb +8 -3
- data/lib/ask/agent/streaming.rb +10 -1
- data/lib/ask/agent/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6d5dd31344228811bd4c7993547c9e0f9d7c39c779f58250d82ad95f271bd7d
|
|
4
|
+
data.tar.gz: ba08c57e9fc7dfc410e6a835ffab1076d1f4b5704bee866f7a38e158da712d34
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d46b653b22b50dee9597238cc65ce5fe72c3840322e1ecd03efccfe6494a4494bff473f3bc3c4eff1ae56df849142a45f52bc77e23c9a93a9c240f465d7db68
|
|
7
|
+
data.tar.gz: 9c158ab0aa0be906723681458ea54d90b15cd7289647e7acb8527faf367ead4490919a84b0a2e71186b4b93945ac065db063a33ead26b2532d0d50efd2dd8bf3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,70 @@
|
|
|
1
|
+
## [0.25.2] - 2026-08-03
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- **Streaming no longer depends on ActiveSupport's `String#truncate`.** The
|
|
6
|
+
SSE event serialization (streaming.rb) and the max-consecutive-tool-turns
|
|
7
|
+
summary (loop.rb) called `String#truncate`, which only exists when
|
|
8
|
+
ActiveSupport's core extensions are loaded — so a bare `require
|
|
9
|
+
"ask-agent"` raised `NoMethodError` as soon as a tool emitted a partial
|
|
10
|
+
result. Both call sites now use a plain-Ruby truncation helper.
|
|
11
|
+
|
|
12
|
+
## [0.25.1] - 2026-08-02
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Session passes resolved tool instances to Chat.** Tool classes passed
|
|
17
|
+
as `tools: [MyTool]` were resolved for the session but handed to the
|
|
18
|
+
underlying Chat unresolved, so `ToolDef.from_tool` used `Class#name`
|
|
19
|
+
and raised `Ask::InvalidToolDefinition` on the first run. Sessions now
|
|
20
|
+
resolve tools before building the Chat; classes and instances both work.
|
|
21
|
+
|
|
22
|
+
## [0.25.0] — 2026-08-02
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Model-aware compaction reserve.** `Ask::Agent::Compactor` now derives
|
|
27
|
+
its context headroom from the model's declared `max_output_tokens`
|
|
28
|
+
(capped at 20,000) instead of a fixed 80% threshold. When the model
|
|
29
|
+
metadata is unavailable, a static 20,000-token reserve applies. A safety
|
|
30
|
+
floor clamps the reserve for tiny-window models so compaction can fire
|
|
31
|
+
usefully instead of triggering on every turn.
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# Default: compact when tokens exceed context_window - reserve
|
|
35
|
+
compactor = Ask::Agent::Compactor.new
|
|
36
|
+
|
|
37
|
+
# Legacy behavior: compact at 80% of the window
|
|
38
|
+
compactor = Ask::Agent::Compactor.new(threshold: 0.8)
|
|
39
|
+
|
|
40
|
+
# Explicit headroom
|
|
41
|
+
compactor = Ask::Agent::Compactor.new(reserve_tokens: 5_000)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **Token-aware recent tail.** `keep_recent_tokens:` preserves the last N
|
|
45
|
+
tokens of conversation verbatim (default 8,000) and summarizes only what's
|
|
46
|
+
older — recent-context fidelity depends on the active work, not message
|
|
47
|
+
counts. When not configured, the legacy fixed message-count tail
|
|
48
|
+
(`keep_count:`, default 8) is used for backward compatibility.
|
|
49
|
+
|
|
50
|
+
- **Global compaction options** on `Ask::Agent.configure`:
|
|
51
|
+
`compactor_reserve_tokens` and `compactor_keep_recent_tokens` apply to all
|
|
52
|
+
sessions. `compactor_threshold` now defaults to `nil` (reserve mode).
|
|
53
|
+
|
|
54
|
+
- **`Compactor#compact_threshold_tokens`** — public accessor for the token
|
|
55
|
+
count at which compaction triggers (window × threshold, or
|
|
56
|
+
window − reserve).
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **`microcompact!` no longer crashes on long tool results.** `Ask::Message`
|
|
61
|
+
is immutable — `content=` never existed. The method now rebuilds the
|
|
62
|
+
message in place via `map!`, preserving `tool_call_id` and metadata.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- `Compactor#extract_summary` is now public.
|
|
67
|
+
|
|
1
68
|
## [0.24.2] — 2026-07-30
|
|
2
69
|
|
|
3
70
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# ask-agent
|
|
2
2
|
|
|
3
|
-
Agent runtime for the ask-rb ecosystem.
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Agent runtime for the ask-rb ecosystem. Runs the core agent loop: think, call
|
|
4
|
+
tools, execute, feed results back, and repeat until the task is done. Built on
|
|
5
|
+
ask-core, ask-state-providers, ask-llm-providers, ask-tools, ask-skills, and
|
|
6
|
+
ask-instrumentation, and it powers the `askr` CLI.
|
|
6
7
|
|
|
7
8
|
## Installation
|
|
8
9
|
|
|
@@ -15,114 +16,12 @@ gem "ask-agent"
|
|
|
15
16
|
```ruby
|
|
16
17
|
require "ask-agent"
|
|
17
18
|
|
|
18
|
-
session = Ask::Agent::Session.new(
|
|
19
|
-
model: "gpt-4o",
|
|
20
|
-
tools: [Ask::Tools::Shell::Bash, Ask::Tools::Shell::Read]
|
|
21
|
-
)
|
|
22
|
-
|
|
19
|
+
session = Ask::Agent::Session.new(model: "gpt-4o", max_turns: 25)
|
|
23
20
|
response = session.run("What files are in the current directory?")
|
|
24
21
|
puts response
|
|
25
22
|
```
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
| Component | File | Purpose |
|
|
30
|
-
|---|---|---|
|
|
31
|
-
| `Ask::Agent::Session` | session.rb | Full agent loop — message → tool calls → results → follow-up |
|
|
32
|
-
| `Ask::Agent::Loop` | loop.rb | Turn management, loop detection, max-turn guard |
|
|
33
|
-
| `Ask::Agent::ToolExecutor` | tool_executor.rb | Parallel/sequential tool execution with retry and abort |
|
|
34
|
-
| `Ask::Agent::Compactor` | compactor.rb | Context window management with proactive/overflow compaction |
|
|
35
|
-
| `Ask::Agent::Hooks` | hooks.rb | Before/after tool lifecycle callbacks |
|
|
36
|
-
| `Ask::Agent::Events` | events.rb | Data.define event types for streaming and monitoring |
|
|
37
|
-
| `Ask::Agent::Telemetry` | telemetry.rb | File-backed telemetry for error tracking |
|
|
38
|
-
| `Ask::Agent::Reflector` | reflector.rb | Assistant response self-evaluation |
|
|
39
|
-
| `Ask::Agent::MetaAgent` | meta_agent.rb | LLM-powered self-improvement from telemetry |
|
|
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
|
-
```
|
|
122
|
-
|
|
123
|
-
## Events
|
|
124
|
-
|
|
125
|
-
Stream session execution in real-time:
|
|
24
|
+
Stream execution in real time with events:
|
|
126
25
|
|
|
127
26
|
```ruby
|
|
128
27
|
session.on_event do |event|
|
|
@@ -131,95 +30,17 @@ session.on_event do |event|
|
|
|
131
30
|
print event.content
|
|
132
31
|
when Ask::Agent::Events::ToolExecutionStart
|
|
133
32
|
puts "\nRunning #{event.name}..."
|
|
134
|
-
when Ask::Agent::Events::ToolExecutionEnd
|
|
135
|
-
puts " → #{event.duration_ms}ms #{event.is_error ? 'error' : 'ok'}"
|
|
136
33
|
end
|
|
137
34
|
end
|
|
138
35
|
```
|
|
139
36
|
|
|
140
|
-
##
|
|
141
|
-
|
|
142
|
-
Opt-in safety modules:
|
|
143
|
-
|
|
144
|
-
- **Permissions** — Access control for tools. Supports named access modes (`:full_access`, `:read_only`, `:ask_before_changes`) or custom blocked-tool lists.
|
|
145
|
-
- **RateLimiter** — Prevent runaway tool calls (configurable per-minute and per-turn limits)
|
|
146
|
-
- **AuditLog** — Immutable, append-only log of every tool call
|
|
147
|
-
|
|
148
|
-
```ruby
|
|
149
|
-
extensions = [
|
|
150
|
-
Ask::Agent::Extensions::Permissions.new(mode: :read_only),
|
|
151
|
-
Ask::Agent::Extensions::RateLimiter.new(max_calls_per_minute: 30),
|
|
152
|
-
Ask::Agent::Extensions::AuditLog.new(path: "agent.log")
|
|
153
|
-
]
|
|
154
|
-
|
|
155
|
-
session = Ask::Agent::Session.new(
|
|
156
|
-
model: "gpt-4o",
|
|
157
|
-
tools: [...],
|
|
158
|
-
hooks: {
|
|
159
|
-
before_tool: extensions.map(&:method(:before_tool_call)),
|
|
160
|
-
after_tool: extensions.select { |e| e.respond_to?(:after_tool_call) }.map(&:method(:after_tool_call))
|
|
161
|
-
}
|
|
162
|
-
)
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
## Middleware
|
|
166
|
-
|
|
167
|
-
Wrapping LLM provider calls with cross-cutting behavior:
|
|
168
|
-
|
|
169
|
-
- **RetryOnFailure** — Retry on rate limits and server errors with exponential backoff
|
|
170
|
-
- **ModelFallback** — Switch to a fallback model+provider on transient errors
|
|
171
|
-
- **LogCalls** — Log every LLM provider call
|
|
172
|
-
- **DefaultSettings** — Inject default generation parameters
|
|
173
|
-
|
|
174
|
-
```ruby
|
|
175
|
-
Ask::Agent.configure do |c|
|
|
176
|
-
c.middleware.use :retry_on_failure, max_retries: 3
|
|
177
|
-
c.middleware.use :model_fallback, fallbacks: [
|
|
178
|
-
{ model: "claude-sonnet-4", provider: :anthropic },
|
|
179
|
-
{ model: "gemini-2.0-flash", provider: :google }
|
|
180
|
-
]
|
|
181
|
-
c.middleware.use :log_calls, logger: Rails.logger
|
|
182
|
-
c.middleware.use :default_settings, temperature: 0.7
|
|
183
|
-
end
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
### ModelFallback
|
|
187
|
-
|
|
188
|
-
When the primary LLM is overloaded or down, `ModelFallback` transparently switches to a backup model+provider. Credentials for each provider are resolved automatically.
|
|
189
|
-
|
|
190
|
-
**Static fallbacks** — ordered list tried in sequence:
|
|
191
|
-
```ruby
|
|
192
|
-
c.middleware.use :model_fallback, fallbacks: [
|
|
193
|
-
{ model: "claude-sonnet-4", provider: :anthropic },
|
|
194
|
-
{ model: "gemini-2.0-flash", provider: :google }
|
|
195
|
-
]
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
**Dynamic fallbacks** — lambda that receives the error and request:
|
|
199
|
-
```ruby
|
|
200
|
-
c.middleware.use :model_fallback, fallbacks: ->(error, request) {
|
|
201
|
-
if request[:messages].sum { |m| m[:content].to_s.length } > 100_000
|
|
202
|
-
[{ model: "claude-sonnet-4", provider: :anthropic }] # long-context
|
|
203
|
-
else
|
|
204
|
-
[{ model: "gpt-4o-mini", provider: :openai }] # cheaper
|
|
205
|
-
end
|
|
206
|
-
}
|
|
207
|
-
```
|
|
37
|
+
## Declarative Agents
|
|
208
38
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
## Agents
|
|
217
|
-
|
|
218
|
-
Declarative agents follow a file convention. Each agent lives in a
|
|
219
|
-
directory under `agents/` (or `app/agents/` in Rails); the directory
|
|
220
|
-
name is the agent name, the file `agent.rb` defines the agent as a
|
|
221
|
-
`<Name>::Agent < Ask::Agent::Definition` subclass, and a sibling
|
|
222
|
-
`instructions.md` is auto-loaded as the system prompt.
|
|
39
|
+
Agents follow a file convention. Each agent lives in a directory under
|
|
40
|
+
`agents/` (or `app/agents/` in Rails); the directory name is the agent name,
|
|
41
|
+
the file `agent.rb` defines the agent as a `<Name>::Agent <
|
|
42
|
+
Ask::Agent::Definition` subclass, and a sibling `instructions.md` is
|
|
43
|
+
auto-loaded as the system prompt.
|
|
223
44
|
|
|
224
45
|
```
|
|
225
46
|
agents/
|
|
@@ -246,10 +67,22 @@ agent = Ask::Agent.new("health_check")
|
|
|
246
67
|
response = agent.run("Check server health")
|
|
247
68
|
```
|
|
248
69
|
|
|
249
|
-
Shared tools for all agents go in `agents/shared/tools/`. Per-agent
|
|
250
|
-
|
|
70
|
+
Shared tools for all agents go in `agents/shared/tools/`. Per-agent skills go
|
|
71
|
+
in `agents/<name>/skills/`, shared skills in `agents/shared/skills/`.
|
|
72
|
+
|
|
73
|
+
## Essential API
|
|
74
|
+
|
|
75
|
+
| Entry point | Purpose |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `Ask::Agent::Session.new(model:, tools: [], max_turns: 25, ...)` | Full agent loop: message, tool calls, results, follow-up |
|
|
78
|
+
| `session.run(message)` | Run the loop for one message |
|
|
79
|
+
| `session.on_event { \|e\| }` | Stream `Ask::Agent::Events` (text deltas, tool execution, evaluation) |
|
|
80
|
+
| `Ask::Agent.new("name")` | Build a session from a declarative agent definition |
|
|
81
|
+
| `Ask.chat(message)` | One-shot chat without instantiating a Session |
|
|
82
|
+
| `Ask::Agent.configure { \|c\| ... }` | Global defaults: model, provider, turns, compactor, middleware |
|
|
83
|
+
| `askr` | CLI: `askr run <agent> [prompt]`, `askr list`, `askr schedule`, `askr new`, `askr skills` |
|
|
251
84
|
|
|
252
|
-
|
|
85
|
+
### Configuration
|
|
253
86
|
|
|
254
87
|
```ruby
|
|
255
88
|
Ask::Agent.configure do |c|
|
|
@@ -263,26 +96,23 @@ Ask::Agent.configure do |c|
|
|
|
263
96
|
end
|
|
264
97
|
```
|
|
265
98
|
|
|
266
|
-
`default_provider` pins which provider serves the default model when the
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
99
|
+
`default_provider` pins which provider serves the default model when the model
|
|
100
|
+
name doesn't uniquely identify one (for example, the same model id registered
|
|
101
|
+
under multiple OpenAI-compatible providers). A `provider:` passed to
|
|
102
|
+
`Session.new` or declared in an agent `Definition` always wins over the global
|
|
103
|
+
default.
|
|
271
104
|
|
|
272
|
-
##
|
|
105
|
+
## Full documentation
|
|
273
106
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
session.save # persisted to store
|
|
279
|
-
```
|
|
107
|
+
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs.
|
|
108
|
+
https://ask-rb.github.io/ask-docs/core/agent covers ask-agent in depth,
|
|
109
|
+
including the evaluator, middleware, extensions, cost tracking, and
|
|
110
|
+
persistence. API reference: https://ask-rb.github.io/ask-docs/reference/api.
|
|
280
111
|
|
|
281
112
|
## Development
|
|
282
113
|
|
|
283
|
-
|
|
114
|
+
bundle install
|
|
284
115
|
bundle exec rake test
|
|
285
|
-
```
|
|
286
116
|
|
|
287
117
|
## License
|
|
288
118
|
|
data/lib/ask/agent/compactor.rb
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Ask
|
|
4
4
|
module Agent
|
|
5
|
+
# Context compaction for long sessions. When conversation tokens approach
|
|
6
|
+
# the model's context window, older messages are summarized and replaced
|
|
7
|
+
# with a structured summary, preserving a recent tail verbatim.
|
|
8
|
+
#
|
|
9
|
+
# Trigger modes:
|
|
10
|
+
# 1. Threshold — tokens exceed (context_window - reserve). Compact, no retry.
|
|
11
|
+
# 2. Overflow — LLM returned context overflow. Compact, then auto-retry.
|
|
12
|
+
#
|
|
13
|
+
# Reserve is model-aware: it defaults to the model's declared max output
|
|
14
|
+
# tokens (capped at {DEFAULT_RESERVE_TOKENS}), so compaction leaves exactly
|
|
15
|
+
# the headroom a single turn can consume. For models without declared
|
|
16
|
+
# limits, a static default applies. A safety floor clamps the reserve for
|
|
17
|
+
# tiny-window models so threshold compaction can fire usefully instead of
|
|
18
|
+
# triggering on every turn.
|
|
19
|
+
#
|
|
20
|
+
# The recent tail is also token-aware: {keep_recent_tokens} preserves the
|
|
21
|
+
# last N tokens of conversation verbatim (recent-context fidelity depends
|
|
22
|
+
# on the active work, not on message counts) and summarizes only what's
|
|
23
|
+
# older. When not configured, the legacy fixed message-count behavior is
|
|
24
|
+
# used for backward compatibility.
|
|
5
25
|
class Compactor
|
|
6
26
|
CONTEXT_WINDOWS = {
|
|
7
27
|
"gpt-4o" => 128_000,
|
|
@@ -15,25 +35,76 @@ module Ask
|
|
|
15
35
|
"deepseek-v4-pro" => 1_000_000,
|
|
16
36
|
}.tap { |h| h.default = 128_000 }
|
|
17
37
|
|
|
38
|
+
# Default headroom reserved for a single model turn when the model's
|
|
39
|
+
# max output tokens are unknown.
|
|
40
|
+
DEFAULT_RESERVE_TOKENS = 20_000
|
|
41
|
+
|
|
42
|
+
# Default verbatim recent-context tail preserved during compaction.
|
|
43
|
+
DEFAULT_KEEP_RECENT_TOKENS = 8_000
|
|
44
|
+
|
|
45
|
+
# Minimum reserve for tiny-window models (safety floor).
|
|
46
|
+
MIN_RESERVE_TOKENS = 1_024
|
|
47
|
+
|
|
48
|
+
# Legacy fixed message-count tail (backward compatibility).
|
|
49
|
+
DEFAULT_KEEP_COUNT = 8
|
|
50
|
+
|
|
51
|
+
# Conversations smaller than this are never compacted.
|
|
52
|
+
MIN_MESSAGES = 6
|
|
53
|
+
|
|
18
54
|
attr_accessor :chat, :llm
|
|
19
55
|
|
|
20
|
-
|
|
56
|
+
# @param threshold [Float, nil] Compact when tokens exceed
|
|
57
|
+
# context_window * threshold. When nil (default), compaction triggers
|
|
58
|
+
# at context_window - reserve_tokens (model-aware).
|
|
59
|
+
# @param strategy [Symbol] Reserved; :proactive is the only strategy.
|
|
60
|
+
# @param llm [Object, String, nil] LLM used for summarization. When nil,
|
|
61
|
+
# a heuristic summary is generated instead.
|
|
62
|
+
# @param reserve_tokens [Integer, nil] Explicit headroom for one turn.
|
|
63
|
+
# When nil, derived from the model's max output tokens (see
|
|
64
|
+
# {#derive_reserve}).
|
|
65
|
+
# @param keep_recent_tokens [Integer, nil] Explicit verbatim recent-tail
|
|
66
|
+
# budget. When nil, the legacy fixed message-count tail is preserved.
|
|
67
|
+
# @param keep_count [Integer] Legacy fixed tail in messages when
|
|
68
|
+
# keep_recent_tokens is nil.
|
|
69
|
+
# @param min_messages [Integer] Conversations with fewer messages are
|
|
70
|
+
# never compacted.
|
|
71
|
+
def initialize(threshold: nil, strategy: :proactive, llm: nil,
|
|
72
|
+
reserve_tokens: nil, keep_recent_tokens: nil,
|
|
73
|
+
keep_count: DEFAULT_KEEP_COUNT, min_messages: MIN_MESSAGES)
|
|
21
74
|
@threshold = threshold
|
|
22
75
|
@strategy = strategy
|
|
23
76
|
@llm = llm
|
|
77
|
+
@reserve_tokens = reserve_tokens
|
|
78
|
+
@keep_recent_tokens = keep_recent_tokens
|
|
79
|
+
@keep_count = keep_count
|
|
80
|
+
@min_messages = min_messages
|
|
24
81
|
@already_compacted = false
|
|
25
82
|
@overflow_recovered = false
|
|
26
83
|
end
|
|
27
84
|
|
|
28
85
|
def overflow_recovered? = @overflow_recovered
|
|
29
86
|
|
|
87
|
+
# Whether the conversation is close enough to the model's window that
|
|
88
|
+
# compaction should run. Triggers at either threshold mode
|
|
89
|
+
# (window * threshold) or reserve mode (window - reserve).
|
|
30
90
|
def should_compact?
|
|
31
91
|
return false unless @chat
|
|
32
|
-
|
|
92
|
+
estimate_total_tokens >= compact_threshold_tokens
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The token count at which compaction triggers.
|
|
96
|
+
#
|
|
97
|
+
# @return [Integer]
|
|
98
|
+
def compact_threshold_tokens
|
|
33
99
|
window = context_window
|
|
34
|
-
|
|
100
|
+
return (window * @threshold).round if @threshold
|
|
101
|
+
|
|
102
|
+
window - reserve_tokens
|
|
35
103
|
end
|
|
36
104
|
|
|
105
|
+
# Run compaction, emitting start/end events.
|
|
106
|
+
#
|
|
107
|
+
# @param event_emitter [#emit, nil]
|
|
37
108
|
def run(event_emitter: nil)
|
|
38
109
|
return unless @chat
|
|
39
110
|
|
|
@@ -45,14 +116,16 @@ module Ask
|
|
|
45
116
|
event_emitter&.emit(Events::CompactionEnd.new(tokens_before: tokens_before, tokens_after: tokens_after, summary: extract_summary))
|
|
46
117
|
end
|
|
47
118
|
|
|
119
|
+
# Summarize older messages and replace them with a summary, preserving
|
|
120
|
+
# a recent tail. The tail is either token-based ({keep_recent_tokens})
|
|
121
|
+
# or the legacy fixed message count.
|
|
48
122
|
def compact!
|
|
49
123
|
return unless @chat
|
|
50
124
|
messages = @chat.messages.dup
|
|
51
|
-
return if messages.size <
|
|
125
|
+
return if messages.size < @min_messages
|
|
52
126
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
older = messages.first(messages.size - keep_count)
|
|
127
|
+
split_index = find_split_index(messages)
|
|
128
|
+
older = messages.first(split_index)
|
|
56
129
|
return if older.empty?
|
|
57
130
|
|
|
58
131
|
summary = if @llm
|
|
@@ -65,36 +138,148 @@ module Ask
|
|
|
65
138
|
@chat.add_message(role: :system, content: "[Previous conversation summary]: #{summary}")
|
|
66
139
|
end
|
|
67
140
|
|
|
141
|
+
# Aggressive overflow fallback: clears oversized tool results in place,
|
|
142
|
+
# keeping the conversation structure intact.
|
|
68
143
|
def microcompact!
|
|
69
144
|
return unless @chat
|
|
70
|
-
@chat.messages.
|
|
71
|
-
next unless msg.role == :tool
|
|
72
|
-
msg
|
|
145
|
+
@chat.messages.map! do |msg|
|
|
146
|
+
next msg unless msg.role == :tool
|
|
147
|
+
next msg unless msg.content.to_s.length > 200
|
|
148
|
+
|
|
149
|
+
Ask::Message.new(
|
|
150
|
+
role: :tool,
|
|
151
|
+
content: "[Tool result cleared by compaction]",
|
|
152
|
+
tool_call_id: msg.tool_call_id,
|
|
153
|
+
metadata: msg.metadata
|
|
154
|
+
)
|
|
73
155
|
end
|
|
74
156
|
end
|
|
75
157
|
|
|
158
|
+
# Recover from a context-overflow error: compact once, then fall back
|
|
159
|
+
# to micro-compaction on subsequent overflows in the same session.
|
|
76
160
|
def recover_from_overflow
|
|
77
161
|
if @already_compacted then microcompact! else compact! end
|
|
78
162
|
@already_compacted = true
|
|
79
163
|
@overflow_recovered = true
|
|
80
164
|
end
|
|
81
165
|
|
|
166
|
+
# Rough token estimate: ~4 characters per token.
|
|
167
|
+
#
|
|
168
|
+
# @param text [String]
|
|
169
|
+
# @return [Integer]
|
|
82
170
|
def estimate_tokens(text)
|
|
83
171
|
(text.to_s.length / 4.0).ceil
|
|
84
172
|
end
|
|
85
173
|
|
|
174
|
+
# Total estimated tokens across all messages, including tool-call
|
|
175
|
+
# payloads.
|
|
176
|
+
#
|
|
177
|
+
# @return [Integer]
|
|
86
178
|
def estimate_total_tokens
|
|
87
179
|
return 0 unless @chat
|
|
88
180
|
@chat.messages.sum { |msg| estimate_message_tokens(msg) }
|
|
89
181
|
end
|
|
90
182
|
|
|
183
|
+
# The model's context window. Consulted from the model catalog first,
|
|
184
|
+
# then the bundled table, then the default.
|
|
185
|
+
#
|
|
186
|
+
# @return [Integer]
|
|
91
187
|
def context_window
|
|
92
|
-
|
|
93
|
-
|
|
188
|
+
info = model_info
|
|
189
|
+
return info.context_window if info&.context_window
|
|
190
|
+
|
|
191
|
+
CONTEXT_WINDOWS[@chat.model.to_s] || CONTEXT_WINDOWS.default
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Headroom reserved for one model turn. Explicit value wins; otherwise
|
|
195
|
+
# derived from the model's max output tokens with a safety floor.
|
|
196
|
+
#
|
|
197
|
+
# @return [Integer]
|
|
198
|
+
def reserve_tokens
|
|
199
|
+
@reserve_tokens || derive_reserve
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Verbatim recent-tail budget in tokens.
|
|
203
|
+
#
|
|
204
|
+
# @return [Integer]
|
|
205
|
+
def keep_recent_tokens
|
|
206
|
+
@keep_recent_tokens || DEFAULT_KEEP_RECENT_TOKENS
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The text of the most recent injected summary, or "" if none exists.
|
|
210
|
+
#
|
|
211
|
+
# @return [String]
|
|
212
|
+
def extract_summary
|
|
213
|
+
@chat.messages.each { |msg| return msg.content.to_s if msg.content.to_s.start_with?("[Previous conversation summary]") }
|
|
214
|
+
""
|
|
94
215
|
end
|
|
95
216
|
|
|
96
217
|
private
|
|
97
218
|
|
|
219
|
+
def model_info
|
|
220
|
+
Ask::ModelCatalog.find(@chat.model.to_s)
|
|
221
|
+
rescue Ask::ModelNotFound, NameError
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Derive the reserve from the model's declared max output tokens,
|
|
226
|
+
# capped at {DEFAULT_RESERVE_TOKENS}. Applies a safety floor for
|
|
227
|
+
# tiny-window models: if the reserve would consume half or more of the
|
|
228
|
+
# window, clamp it to a third so threshold compaction can fire usefully
|
|
229
|
+
# instead of triggering on every turn.
|
|
230
|
+
#
|
|
231
|
+
# @return [Integer]
|
|
232
|
+
def derive_reserve
|
|
233
|
+
info = model_info
|
|
234
|
+
max_output = info&.max_output_tokens.to_i
|
|
235
|
+
reserve = if max_output > 0
|
|
236
|
+
[DEFAULT_RESERVE_TOKENS, max_output].min
|
|
237
|
+
else
|
|
238
|
+
DEFAULT_RESERVE_TOKENS
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
window = context_window
|
|
242
|
+
if window > 0 && reserve * 2 >= window
|
|
243
|
+
reserve = [MIN_RESERVE_TOKENS, window / 3].max
|
|
244
|
+
end
|
|
245
|
+
reserve
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Index of the first message in the recent tail. Token-based when
|
|
249
|
+
# keep_recent_tokens is configured; otherwise the legacy fixed
|
|
250
|
+
# message-count tail.
|
|
251
|
+
#
|
|
252
|
+
# @param messages [Array<Ask::Message>]
|
|
253
|
+
# @return [Integer]
|
|
254
|
+
def find_split_index(messages)
|
|
255
|
+
if @keep_recent_tokens
|
|
256
|
+
find_token_split_index(messages)
|
|
257
|
+
else
|
|
258
|
+
[messages.size - @keep_count, 0].max
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Walk from the end of the conversation accumulating tokens until the
|
|
263
|
+
# keep_recent_tokens budget is consumed. The split never keeps fewer
|
|
264
|
+
# than MIN_MESSAGES recent messages — the tail always retains a recent
|
|
265
|
+
# exchange intact.
|
|
266
|
+
#
|
|
267
|
+
# @param messages [Array<Ask::Message>]
|
|
268
|
+
# @return [Integer]
|
|
269
|
+
def find_token_split_index(messages)
|
|
270
|
+
budget = keep_recent_tokens
|
|
271
|
+
index = messages.size
|
|
272
|
+
|
|
273
|
+
messages.reverse_each do |msg|
|
|
274
|
+
budget -= estimate_message_tokens(msg)
|
|
275
|
+
index -= 1
|
|
276
|
+
break if budget <= 0
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Never keep fewer than MIN_MESSAGES recent messages
|
|
280
|
+
[index, messages.size - MIN_MESSAGES].min
|
|
281
|
+
end
|
|
282
|
+
|
|
98
283
|
def estimate_message_tokens(message)
|
|
99
284
|
base = estimate_tokens(message.content.to_s)
|
|
100
285
|
if message.tool_call? && message.respond_to?(:tool_calls) && message.tool_calls
|
|
@@ -141,11 +326,6 @@ module Ask
|
|
|
141
326
|
elsif @llm.is_a?(String) then Ask::Agent::Chat.new(model: @llm)
|
|
142
327
|
else Ask::Agent::Chat.new(model: Ask::Agent.configuration.default_model) end
|
|
143
328
|
end
|
|
144
|
-
|
|
145
|
-
def extract_summary
|
|
146
|
-
@chat.messages.each { |msg| return msg.content.to_s if msg.content.to_s.start_with?("[Previous conversation summary]") }
|
|
147
|
-
""
|
|
148
|
-
end
|
|
149
329
|
end
|
|
150
330
|
end
|
|
151
331
|
end
|
|
@@ -6,7 +6,7 @@ module Ask
|
|
|
6
6
|
attr_accessor :default_model, :default_provider, :default_max_turns,
|
|
7
7
|
:compactor_enabled, :compactor_threshold, :parallel_tool_execution,
|
|
8
8
|
:max_tool_retries, :prompt_caching, :default_evaluator_model,
|
|
9
|
-
:audit_log
|
|
9
|
+
:audit_log, :compactor_reserve_tokens, :compactor_keep_recent_tokens
|
|
10
10
|
|
|
11
11
|
# @return [Middleware::Pipeline] the middleware pipeline for provider calls
|
|
12
12
|
attr_reader :middleware
|
|
@@ -18,7 +18,9 @@ module Ask
|
|
|
18
18
|
@default_model = "gpt-4o"
|
|
19
19
|
@default_max_turns = 25
|
|
20
20
|
@compactor_enabled = true
|
|
21
|
-
@compactor_threshold =
|
|
21
|
+
@compactor_threshold = nil
|
|
22
|
+
@compactor_reserve_tokens = nil
|
|
23
|
+
@compactor_keep_recent_tokens = nil
|
|
22
24
|
@parallel_tool_execution = true
|
|
23
25
|
@max_tool_retries = 3
|
|
24
26
|
@prompt_caching = true
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -94,7 +94,7 @@ module Ask
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
if @consecutive_tool_turns >= @max_consecutive_tool_turns
|
|
97
|
-
summary = all_tool_results.map { |r| r[:message]
|
|
97
|
+
summary = all_tool_results.map { |r| truncate(r[:message], 80) }.first(2).join("; ")
|
|
98
98
|
return "Based on my investigation: #{summary}"
|
|
99
99
|
end
|
|
100
100
|
|
|
@@ -137,6 +137,15 @@ module Ask
|
|
|
137
137
|
|
|
138
138
|
private
|
|
139
139
|
|
|
140
|
+
# Truncate a string for summaries without depending on ActiveSupport's
|
|
141
|
+
# String#truncate (which is not loaded by a bare `require "ask-agent"`).
|
|
142
|
+
def truncate(text, length)
|
|
143
|
+
s = text.to_s
|
|
144
|
+
return s if s.length <= length
|
|
145
|
+
|
|
146
|
+
"#{s[0, length - 3]}..."
|
|
147
|
+
end
|
|
148
|
+
|
|
140
149
|
def loop_detected?(results)
|
|
141
150
|
return false if results.empty?
|
|
142
151
|
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -41,8 +41,8 @@ module Ask
|
|
|
41
41
|
|
|
42
42
|
@telemetry = telemetry.is_a?(Telemetry) ? telemetry : Telemetry.new(enabled: !!telemetry)
|
|
43
43
|
|
|
44
|
-
@chat = build_chat(model, system_prompt, tools, **chat_options)
|
|
45
44
|
@tools = resolve_tools(tools)
|
|
45
|
+
@chat = build_chat(model, system_prompt, @tools, **chat_options)
|
|
46
46
|
@loop = Loop.new(max_turns: max_turns)
|
|
47
47
|
@tool_executor = ToolExecutor.new(max_retries: max_tool_retries, parallel: parallel_tools)
|
|
48
48
|
@compactor = compactor ? build_compactor(compactor) : nil
|
|
@@ -364,9 +364,14 @@ module Ask
|
|
|
364
364
|
end
|
|
365
365
|
|
|
366
366
|
def build_compactor(config)
|
|
367
|
+
global = Ask::Agent.configuration
|
|
367
368
|
compactor = Compactor.new(
|
|
368
|
-
threshold: config[:threshold] ||
|
|
369
|
-
strategy: config[:strategy] || :proactive
|
|
369
|
+
threshold: config[:threshold] || global.compactor_threshold,
|
|
370
|
+
strategy: config[:strategy] || :proactive,
|
|
371
|
+
reserve_tokens: config[:reserve_tokens] || global.compactor_reserve_tokens,
|
|
372
|
+
keep_recent_tokens: config[:keep_recent_tokens] || global.compactor_keep_recent_tokens,
|
|
373
|
+
keep_count: config[:keep_count],
|
|
374
|
+
min_messages: config[:min_messages]
|
|
370
375
|
)
|
|
371
376
|
compactor.chat = @chat
|
|
372
377
|
compactor
|
data/lib/ask/agent/streaming.rb
CHANGED
|
@@ -82,6 +82,15 @@ module Ask
|
|
|
82
82
|
|
|
83
83
|
private
|
|
84
84
|
|
|
85
|
+
# Truncate a string for telemetry without depending on ActiveSupport's
|
|
86
|
+
# String#truncate (which is not loaded by a bare `require "ask-agent"`).
|
|
87
|
+
def truncate(text, length)
|
|
88
|
+
s = text.to_s
|
|
89
|
+
return s if s.length <= length
|
|
90
|
+
|
|
91
|
+
"#{s[0, length - 3]}..."
|
|
92
|
+
end
|
|
93
|
+
|
|
85
94
|
def run_with_block(session, prompt, mapping)
|
|
86
95
|
errors = []
|
|
87
96
|
|
|
@@ -154,7 +163,7 @@ module Ask
|
|
|
154
163
|
when Events::ToolExecutionStart
|
|
155
164
|
{ name: event.name, id: event.id, args: safe_args(event.arguments) }
|
|
156
165
|
when Events::ToolExecutionUpdate
|
|
157
|
-
{ id: event.id, partial_result: event.partial_result
|
|
166
|
+
{ id: event.id, partial_result: truncate(event.partial_result, 200) }
|
|
158
167
|
when Events::ToolExecutionEnd
|
|
159
168
|
{ name: event.name, id: event.id, duration_ms: event.duration_ms, is_error: event.is_error }
|
|
160
169
|
when Events::SessionEnd
|
data/lib/ask/agent/version.rb
CHANGED