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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4f30ce069402dd4a125120e13e63fbbb438ca2270cd683a199d8f774d93b40c
4
- data.tar.gz: db1e37fea28111e58ac75723d483019c6e13bec7ed0fa4b3ed0c81dcbda0621d
3
+ metadata.gz: d6d5dd31344228811bd4c7993547c9e0f9d7c39c779f58250d82ad95f271bd7d
4
+ data.tar.gz: ba08c57e9fc7dfc410e6a835ffab1076d1f4b5704bee866f7a38e158da712d34
5
5
  SHA512:
6
- metadata.gz: 2d4562819a0a62a758e173ef3473800880c3da920a1bf00466933a1ff39a488e6cfc35b82e89429add6c5a255f4593888e48c191bed4c286324da0a5132bec5a
7
- data.tar.gz: d1f94898e2155cf63d8ef04e6e458582ef9fed2c5b74adf007a27c1562bd0958165a79070612b5a08cca87b18e16e216fc6efd5352e79a39e6f76d94b0d4fe5a
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. The core agent loop: think call tools → execute → feed back → repeat.
4
-
5
- Ported from `RubyLLM::Conductor` into the `Ask::Agent` namespace.
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
- ## Components
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
- ## Extensions
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
- **Custom eligible errors** by default rate limits, server errors, and service unavailable:
210
- ```ruby
211
- c.middleware.use :model_fallback,
212
- fallbacks: [{ model: "claude-sonnet-4", provider: :anthropic }],
213
- eligible_errors: [Ask::RateLimitError, Ask::ServerError]
214
- ```
215
-
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
- skills go in `agents/<name>/skills/`, shared skills in `agents/shared/skills/`.
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
- ## Configuration
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
- model name doesn't uniquely identify one (for example, the same model id
268
- registered under multiple OpenAI-compatible providers). A `provider:`
269
- passed to `Session.new` or declared in an agent `Definition` always wins
270
- over the global default.
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
- ## Persistence
105
+ ## Full documentation
273
106
 
274
- ```ruby
275
- store = Ask::Agent::Persistence::InMemory.new
276
- session = Ask::Agent::Session.new(model: "gpt-4o", persistence: store)
277
- session.run("Hello")
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
- ```bash
114
+ bundle install
284
115
  bundle exec rake test
285
- ```
286
116
 
287
117
  ## License
288
118
 
@@ -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
- def initialize(threshold: 0.8, strategy: :proactive, llm: nil)
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
- current = estimate_total_tokens
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
- current >= window * @threshold
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 < 6
125
+ return if messages.size < @min_messages
52
126
 
53
- keep_count = [messages.size, 8].min
54
- recent = messages.last(keep_count)
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.each do |msg|
71
- next unless msg.role == :tool
72
- msg.content = "[Tool result cleared by compaction]" if msg.content.to_s.length > 200
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
- model = @chat.model.to_s
93
- CONTEXT_WINDOWS[model]
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 = 0.8
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
@@ -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].to_s.truncate(80) }.first(2).join("; ")
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
 
@@ -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] || 0.8,
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
@@ -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.to_s.truncate(200) }
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.24.2"
5
+ VERSION = "0.25.2"
6
6
  end
7
7
  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.24.2
4
+ version: 0.25.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto