ask-agent 0.28.0 → 0.29.1
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 +33 -0
- data/lib/ask/agent/chat.rb +13 -3
- data/lib/ask/agent/checkpoint_store.rb +133 -0
- data/lib/ask/agent/events.rb +8 -0
- data/lib/ask/agent/loop.rb +44 -2
- data/lib/ask/agent/session.rb +152 -7
- data/lib/ask/agent/tool_call_repair.rb +130 -0
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +3 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b6eca46c976555990a13366053665b9340f4a97d578a332a8cbf9615cd5647d
|
|
4
|
+
data.tar.gz: 7c16e54008a44cc0eafb8ebfea51c752e0d6879749635e606142f84cdd03d682
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 05eb5c72881fcb3730dab2f89d51444b3f86f41413f454090331d5c9bb240b6ca4751c40f0fdc3ef10f2ea718989aa7e68e716e13570e247a094f76ae0702051
|
|
7
|
+
data.tar.gz: 712139e5f89850fe706268b74a08854c2f1f0be7276e4717039c73192a872d6f4a3c2566954ae4133ce10917d51ea4ae5687995b828ef024bead95a8222ac68d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
## [0.29.1] — 2026-08-06
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- **Streamed token accounting now counts real tokens.** Stream usage arrives
|
|
6
|
+
as OpenAI-style `prompt_tokens`/`completion_tokens` (deepseek, openai,
|
|
7
|
+
most OpenAI-compatible providers), but `accumulated_tokens` read only
|
|
8
|
+
`input_tokens`/`output_tokens` — every streamed call reported 0 input and
|
|
9
|
+
~1 output token (the content-chunk fallback), so token billing, cost
|
|
10
|
+
calculation, and usage metrics under-counted by orders of magnitude. Both
|
|
11
|
+
key shapes are read now, and the content-chunk fallback applies only when
|
|
12
|
+
the stream carries no usage at all (no double counting).
|
|
13
|
+
|
|
14
|
+
## [0.29.0] — 2026-08-06
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Tool-call repair** — malformed tool calls get one internal LLM round-trip
|
|
19
|
+
to fix them before execution. When a model emits a call with unparseable
|
|
20
|
+
arguments or an unknown tool name, the loop asks the model to re-emit it
|
|
21
|
+
corrected and executes the corrected version instead of burning a turn on
|
|
22
|
+
the error. Enable with `Session.new(tool_call_repair: true)` (built-in
|
|
23
|
+
repair prompt) or pass a callable for full control:
|
|
24
|
+
`Session.new(tool_call_repair: ->(chat, calls, tools) { ... })`.
|
|
25
|
+
- Corrections are remapped to the original call ids, so tool results stay
|
|
26
|
+
consistent with the conversation history; the internal repair exchange
|
|
27
|
+
is stripped from history.
|
|
28
|
+
- Calls the model cannot correct are dropped (the model saw them in the
|
|
29
|
+
repair prompt); repair is best-effort — a failing round-trip drops the
|
|
30
|
+
malformed calls instead of failing the turn.
|
|
31
|
+
- `Events::ToolCallRepaired` fires with name, id, original and corrected
|
|
32
|
+
arguments.
|
|
33
|
+
|
|
1
34
|
## [0.28.0] — 2026-08-06
|
|
2
35
|
|
|
3
36
|
### Changed (breaking)
|
data/lib/ask/agent/chat.rb
CHANGED
|
@@ -353,13 +353,23 @@ module Ask
|
|
|
353
353
|
def accumulated_tokens(stream)
|
|
354
354
|
input = 0
|
|
355
355
|
output = 0
|
|
356
|
+
content_chunks = 0
|
|
356
357
|
stream.chunks.each do |chunk|
|
|
357
358
|
if chunk.usage
|
|
358
|
-
|
|
359
|
-
|
|
359
|
+
# Streams carry OpenAI-style prompt_tokens/completion_tokens
|
|
360
|
+
# (some providers also send input_tokens/output_tokens); reading
|
|
361
|
+
# only the latter reported 0 in / ~1 out for every streamed call.
|
|
362
|
+
input = chunk.usage[:input_tokens] || chunk.usage["input_tokens"] ||
|
|
363
|
+
chunk.usage[:prompt_tokens] || chunk.usage["prompt_tokens"] || input
|
|
364
|
+
output = chunk.usage[:output_tokens] || chunk.usage["output_tokens"] ||
|
|
365
|
+
chunk.usage[:completion_tokens] || chunk.usage["completion_tokens"] || output
|
|
360
366
|
end
|
|
361
|
-
|
|
367
|
+
content_chunks += 1 if chunk.content.to_s.length > 0
|
|
362
368
|
end
|
|
369
|
+
# No usage in the stream at all: approximate output as content
|
|
370
|
+
# chunks rather than reporting nothing. Never mixed with real
|
|
371
|
+
# usage (that would double count).
|
|
372
|
+
output = content_chunks if input.zero? && output.zero? && content_chunks.positive?
|
|
363
373
|
{ input: input, output: output }
|
|
364
374
|
end
|
|
365
375
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Agent
|
|
7
|
+
# Versioned, durable session checkpoints on any {Ask::State::Adapter}.
|
|
8
|
+
#
|
|
9
|
+
# Every checkpoint is a full session snapshot (the same payload
|
|
10
|
+
# {Session#save} writes) stored under a sequential key. The adapter only
|
|
11
|
+
# needs the minimal KV contract — +get+, +set+, +delete+ — so this works
|
|
12
|
+
# with every state provider (SQLite, Redis, Postgres, MySQL) and with
|
|
13
|
+
# custom adapters that implement nothing else. No list primitives are
|
|
14
|
+
# required.
|
|
15
|
+
#
|
|
16
|
+
# Keys (session id + suffix):
|
|
17
|
+
# "<id>:checkpoint:<seq>" — one key per checkpoint
|
|
18
|
+
# "<id>:checkpoint:head" — the current seq (the active timeline)
|
|
19
|
+
#
|
|
20
|
+
# Rolling back moves the head pointer; later checkpoints are kept, so a
|
|
21
|
+
# session can roll forward again (time travel). Forking copies the
|
|
22
|
+
# checkpoints up to a point into a new session id — the branch diverges
|
|
23
|
+
# from there.
|
|
24
|
+
class CheckpointStore
|
|
25
|
+
CHECKPOINT_KEY = ":checkpoint:"
|
|
26
|
+
HEAD_KEY = ":checkpoint:head"
|
|
27
|
+
|
|
28
|
+
# @param state_adapter [Ask::State::Adapter] backing store
|
|
29
|
+
def initialize(state_adapter)
|
|
30
|
+
@state = state_adapter
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Ask::State::Adapter] the underlying adapter
|
|
34
|
+
attr_reader :state
|
|
35
|
+
|
|
36
|
+
# Append a checkpoint. The new checkpoint becomes the head.
|
|
37
|
+
#
|
|
38
|
+
# @param session_id [String]
|
|
39
|
+
# @param data [Hash] session snapshot
|
|
40
|
+
# @return [Integer] the new checkpoint seq
|
|
41
|
+
def checkpoint(session_id, data)
|
|
42
|
+
seq = (head(session_id) || 0) + 1
|
|
43
|
+
@state.set(checkpoint_key(session_id, seq), data)
|
|
44
|
+
@state.set(head_key(session_id), seq)
|
|
45
|
+
seq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param session_id [String]
|
|
49
|
+
# @return [Integer, nil] current head seq, or nil when the session has
|
|
50
|
+
# no checkpoints
|
|
51
|
+
def head(session_id)
|
|
52
|
+
@state.get(head_key(session_id))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param session_id [String]
|
|
56
|
+
# @return [Array<Integer>] all checkpoint seqs, oldest first
|
|
57
|
+
def history(session_id)
|
|
58
|
+
current = head(session_id)
|
|
59
|
+
current ? (1..current).to_a : []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Load a checkpoint's data.
|
|
63
|
+
#
|
|
64
|
+
# @param session_id [String]
|
|
65
|
+
# @param seq [Integer, nil] checkpoint seq; defaults to the head
|
|
66
|
+
# @return [Hash, nil] the snapshot, or nil when it does not exist
|
|
67
|
+
def load(session_id, seq: nil)
|
|
68
|
+
seq ||= head(session_id)
|
|
69
|
+
return nil unless seq
|
|
70
|
+
|
|
71
|
+
@state.get(checkpoint_key(session_id, seq))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Move the head pointer to an earlier (or later) checkpoint. Later
|
|
75
|
+
# checkpoints are kept so the session can roll forward again.
|
|
76
|
+
#
|
|
77
|
+
# @param session_id [String]
|
|
78
|
+
# @param seq [Integer]
|
|
79
|
+
# @return [Integer] the seq rolled back to
|
|
80
|
+
# @raise [ArgumentError] when the checkpoint does not exist
|
|
81
|
+
def rollback(session_id, seq)
|
|
82
|
+
raise ArgumentError, "no checkpoint #{seq}" unless @state.get(checkpoint_key(session_id, seq))
|
|
83
|
+
|
|
84
|
+
@state.set(head_key(session_id), seq)
|
|
85
|
+
seq
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Copy the checkpoints up to +at_seq+ into a new session id — a branch
|
|
89
|
+
# that diverges from that point.
|
|
90
|
+
#
|
|
91
|
+
# @param session_id [String]
|
|
92
|
+
# @param at_seq [Integer, nil] checkpoint to fork from; defaults to the
|
|
93
|
+
# head
|
|
94
|
+
# @param new_id [String] id for the forked session (defaults to a new
|
|
95
|
+
# uuid)
|
|
96
|
+
# @return [String] the forked session's id
|
|
97
|
+
# @raise [ArgumentError] when the checkpoint does not exist
|
|
98
|
+
def fork(session_id, at_seq: nil, new_id: SecureRandom.uuid)
|
|
99
|
+
at_seq ||= head(session_id)
|
|
100
|
+
raise ArgumentError, "no checkpoint #{at_seq}" unless at_seq && @state.get(checkpoint_key(session_id, at_seq))
|
|
101
|
+
|
|
102
|
+
(1..at_seq).each do |seq|
|
|
103
|
+
data = @state.get(checkpoint_key(session_id, seq))
|
|
104
|
+
@state.set(checkpoint_key(new_id, seq), data)
|
|
105
|
+
end
|
|
106
|
+
@state.set(head_key(new_id), at_seq)
|
|
107
|
+
new_id
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Remove every checkpoint for a session.
|
|
111
|
+
#
|
|
112
|
+
# @param session_id [String]
|
|
113
|
+
# @return [void]
|
|
114
|
+
def delete(session_id)
|
|
115
|
+
history(session_id).each do |seq|
|
|
116
|
+
@state.delete(checkpoint_key(session_id, seq))
|
|
117
|
+
end
|
|
118
|
+
@state.delete(head_key(session_id))
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def checkpoint_key(session_id, seq)
|
|
125
|
+
"#{session_id}#{CHECKPOINT_KEY}#{seq}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def head_key(session_id)
|
|
129
|
+
"#{session_id}#{HEAD_KEY}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/ask/agent/events.rb
CHANGED
|
@@ -16,6 +16,9 @@ module Ask
|
|
|
16
16
|
MessageEnd = Data.define(:tool_calls)
|
|
17
17
|
|
|
18
18
|
ToolExecutionStart = Data.define(:name, :arguments, :id)
|
|
19
|
+
# Emitted when a malformed tool call (unparseable arguments or unknown
|
|
20
|
+
# tool) was repaired by the model before execution.
|
|
21
|
+
ToolCallRepaired = Data.define(:name, :id, :original_arguments, :corrected_arguments)
|
|
19
22
|
# Emitted when a tool returns Ask::Result.pending (async work started).
|
|
20
23
|
ToolPending = Data.define(:name, :id)
|
|
21
24
|
# Emitted when a pending (async) tool's background work completes.
|
|
@@ -26,6 +29,11 @@ module Ask
|
|
|
26
29
|
CompactionStart = Data.define(:tokens_before, :reason)
|
|
27
30
|
CompactionEnd = Data.define(:tokens_before, :tokens_after, :summary)
|
|
28
31
|
|
|
32
|
+
# Emitted when a session was rewound to an earlier checkpoint.
|
|
33
|
+
SessionRolledBack = Data.define(:session_id, :seq, :turn_count)
|
|
34
|
+
# Emitted when a session was forked from a checkpoint.
|
|
35
|
+
SessionForked = Data.define(:session_id, :forked_id, :seq)
|
|
36
|
+
|
|
29
37
|
LoopDetected = Data.define(:tool_name, :repeated_count)
|
|
30
38
|
MaxTurnsExceeded = Data.define(:max_turns)
|
|
31
39
|
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Ask
|
|
|
17
17
|
@max_consecutive_tool_turns = max_consecutive_tool_turns
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil)
|
|
20
|
+
def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil, tool_call_repair: nil)
|
|
21
21
|
raise MaxTurnsExceeded if @turn_count >= @max_turns
|
|
22
22
|
|
|
23
23
|
event_emitter.emit(Events::TurnStart.new)
|
|
@@ -86,6 +86,10 @@ module Ask
|
|
|
86
86
|
user_tool_calls = response.tool_calls.reject { |id, _| provider_results.key?(id) }
|
|
87
87
|
|
|
88
88
|
if user_tool_calls.any?
|
|
89
|
+
# Repair malformed calls (unparseable arguments, unknown tool
|
|
90
|
+
# names) with one internal LLM round-trip before executing.
|
|
91
|
+
user_tool_calls = repair_malformed_calls(user_tool_calls, tools, chat, event_emitter, tool_call_repair)
|
|
92
|
+
|
|
89
93
|
# Execute user tool calls locally
|
|
90
94
|
# Respect the session's parallel_tools setting: parallel tools run
|
|
91
95
|
# in threads (with the caller's thread-local context inherited);
|
|
@@ -162,7 +166,8 @@ module Ask
|
|
|
162
166
|
hooks: hooks,
|
|
163
167
|
event_emitter: event_emitter,
|
|
164
168
|
session_id: session_id,
|
|
165
|
-
persist: persist
|
|
169
|
+
persist: persist,
|
|
170
|
+
tool_call_repair: tool_call_repair
|
|
166
171
|
)
|
|
167
172
|
end
|
|
168
173
|
|
|
@@ -174,6 +179,43 @@ module Ask
|
|
|
174
179
|
|
|
175
180
|
private
|
|
176
181
|
|
|
182
|
+
# Repair malformed tool calls before execution. Calls with corrected
|
|
183
|
+
# versions execute in place of the originals (same ids); calls the
|
|
184
|
+
# model could not correct are dropped — the model saw them in the
|
|
185
|
+
# repair prompt. Best-effort: a failing repair round-trip drops the
|
|
186
|
+
# malformed calls instead of failing the turn.
|
|
187
|
+
def repair_malformed_calls(user_tool_calls, tools, chat, event_emitter, tool_call_repair)
|
|
188
|
+
return user_tool_calls if tool_call_repair.nil? || user_tool_calls.empty?
|
|
189
|
+
|
|
190
|
+
repairer = if tool_call_repair == true
|
|
191
|
+
@tool_call_repair ||= ToolCallRepair.new
|
|
192
|
+
elsif tool_call_repair.respond_to?(:call)
|
|
193
|
+
ToolCallRepair.new(tool_call_repair)
|
|
194
|
+
end
|
|
195
|
+
return user_tool_calls unless repairer
|
|
196
|
+
|
|
197
|
+
malformed, _valid = user_tool_calls.partition do |_id, tc|
|
|
198
|
+
ToolCallRepair.repair_info(tc, tools)
|
|
199
|
+
end
|
|
200
|
+
return user_tool_calls if malformed.empty?
|
|
201
|
+
|
|
202
|
+
corrections = repairer.call(chat: chat, calls: malformed.to_h, tools: tools)
|
|
203
|
+
|
|
204
|
+
corrections.each do |id, corrected|
|
|
205
|
+
event_emitter.emit(Events::ToolCallRepaired.new(
|
|
206
|
+
name: corrected.name,
|
|
207
|
+
id: id,
|
|
208
|
+
original_arguments: user_tool_calls[id].arguments,
|
|
209
|
+
corrected_arguments: corrected.arguments
|
|
210
|
+
))
|
|
211
|
+
user_tool_calls[id] = corrected
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
malformed_ids = malformed.map(&:first)
|
|
215
|
+
user_tool_calls.reject! { |id, _| malformed_ids.include?(id) && !corrections.key?(id) }
|
|
216
|
+
user_tool_calls
|
|
217
|
+
end
|
|
218
|
+
|
|
177
219
|
# Whether the session asked the loop to stop (barge-in). Emitters that
|
|
178
220
|
# don't support aborting (plain stubs) are treated as never aborted.
|
|
179
221
|
def aborted?(event_emitter)
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -22,7 +22,8 @@ module Ask
|
|
|
22
22
|
id: nil, system_prompt: nil, parallel_tools: true,
|
|
23
23
|
reflector: nil, telemetry: true, meta_agent: nil,
|
|
24
24
|
agent_dir: nil, evaluator: nil, audit_log: nil,
|
|
25
|
-
skills_disclosure: true, approval: nil,
|
|
25
|
+
skills_disclosure: true, approval: nil,
|
|
26
|
+
tool_call_repair: nil, checkpoints: false, **chat_options)
|
|
26
27
|
@id = id || SecureRandom.uuid
|
|
27
28
|
@agent_dir = agent_dir
|
|
28
29
|
@max_turns = max_turns
|
|
@@ -54,11 +55,17 @@ module Ask
|
|
|
54
55
|
@hooks = Hooks.new(hooks)
|
|
55
56
|
@audit_log = build_audit_log(audit_log)
|
|
56
57
|
@approval_queue = build_approval(approval)
|
|
58
|
+
@tool_call_repair = tool_call_repair
|
|
57
59
|
|
|
58
60
|
@system_context = build_system_context(system_prompt)
|
|
59
61
|
apply_system_context
|
|
60
62
|
|
|
61
63
|
@state = state || persistence
|
|
64
|
+
if checkpoints && !@state
|
|
65
|
+
raise ArgumentError, "checkpoints: requires a state: adapter"
|
|
66
|
+
end
|
|
67
|
+
@checkpoints = !!checkpoints
|
|
68
|
+
@checkpoint_store = CheckpointStore.new(@state) if @checkpoints
|
|
62
69
|
|
|
63
70
|
reflector_opts = reflector.is_a?(Hash) ? reflector : {}
|
|
64
71
|
@reflector = if reflector
|
|
@@ -129,6 +136,7 @@ module Ask
|
|
|
129
136
|
hooks: @hooks,
|
|
130
137
|
event_emitter: self,
|
|
131
138
|
session_id: @id,
|
|
139
|
+
tool_call_repair: @tool_call_repair,
|
|
132
140
|
persist: @state ? method(:persist!) : nil
|
|
133
141
|
)
|
|
134
142
|
|
|
@@ -202,7 +210,8 @@ module Ask
|
|
|
202
210
|
compactor: @compactor,
|
|
203
211
|
hooks: @hooks,
|
|
204
212
|
event_emitter: self,
|
|
205
|
-
session_id: @id
|
|
213
|
+
session_id: @id,
|
|
214
|
+
tool_call_repair: @tool_call_repair
|
|
206
215
|
)
|
|
207
216
|
|
|
208
217
|
@total_input_tokens += @loop.last_input_tokens.to_i
|
|
@@ -241,7 +250,8 @@ module Ask
|
|
|
241
250
|
compactor: @compactor,
|
|
242
251
|
hooks: @hooks,
|
|
243
252
|
event_emitter: self,
|
|
244
|
-
session_id: @id
|
|
253
|
+
session_id: @id,
|
|
254
|
+
tool_call_repair: @tool_call_repair
|
|
245
255
|
)
|
|
246
256
|
|
|
247
257
|
@total_input_tokens += @loop.last_input_tokens.to_i
|
|
@@ -304,8 +314,21 @@ module Ask
|
|
|
304
314
|
session = new(
|
|
305
315
|
id: data[:id],
|
|
306
316
|
model: data.dig(:metadata, :model),
|
|
307
|
-
|
|
308
|
-
|
|
317
|
+
# Instantiate saved tool classes defensively: tools that cannot be
|
|
318
|
+
# auto-constructed (e.g. the built-in LoadSkillTool, which needs a
|
|
319
|
+
# registry) are skipped — resolve_tools re-adds them with a proper
|
|
320
|
+
# registry. Callers can also pass their own `tools:` after load.
|
|
321
|
+
tools: data.dig(:metadata, :tools).to_a.filter_map do |name|
|
|
322
|
+
begin
|
|
323
|
+
name.constantize.new
|
|
324
|
+
rescue StandardError
|
|
325
|
+
nil
|
|
326
|
+
end
|
|
327
|
+
end,
|
|
328
|
+
state: adapter,
|
|
329
|
+
# Checkpointing is restored automatically when the session has
|
|
330
|
+
# checkpoints in the store.
|
|
331
|
+
checkpoints: !adapter.get("#{id}#{CheckpointStore::HEAD_KEY}").nil?
|
|
309
332
|
)
|
|
310
333
|
|
|
311
334
|
data[:messages].each do |msg|
|
|
@@ -322,6 +345,7 @@ module Ask
|
|
|
322
345
|
|
|
323
346
|
def delete
|
|
324
347
|
@deleted = true
|
|
348
|
+
@checkpoint_store&.delete(@id)
|
|
325
349
|
@state&.delete(@id)
|
|
326
350
|
end
|
|
327
351
|
|
|
@@ -331,6 +355,81 @@ module Ask
|
|
|
331
355
|
|
|
332
356
|
def abort_requested? = @abort_requested
|
|
333
357
|
|
|
358
|
+
# --- Checkpoints (fork, rollback, resume) ---
|
|
359
|
+
|
|
360
|
+
# @return [Array<Integer>] checkpoint seqs, oldest first
|
|
361
|
+
# @raise [RuntimeError] when checkpointing is not enabled
|
|
362
|
+
def checkpoint_history
|
|
363
|
+
require_checkpoints!
|
|
364
|
+
@checkpoint_store.history(@id)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Load a checkpoint's snapshot.
|
|
368
|
+
#
|
|
369
|
+
# @param seq [Integer, nil] checkpoint seq; defaults to the head
|
|
370
|
+
# @return [Hash, nil] the snapshot with symbol keys
|
|
371
|
+
# @raise [RuntimeError] when checkpointing is not enabled
|
|
372
|
+
def load_checkpoint(seq: nil)
|
|
373
|
+
require_checkpoints!
|
|
374
|
+
data = @checkpoint_store.load(@id, seq: seq)
|
|
375
|
+
data && self.class.deep_symbolize_keys(data)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Rewind the session to an earlier checkpoint: messages and turn count
|
|
379
|
+
# are restored from the snapshot, and the store's head moves back.
|
|
380
|
+
# Later checkpoints are kept, so the session can roll forward again.
|
|
381
|
+
#
|
|
382
|
+
# @param seq [Integer, nil] checkpoint seq (xor +turn:)
|
|
383
|
+
# @param turn [Integer, nil] roll back to the last checkpoint whose
|
|
384
|
+
# turn count equals +turn+ (xor +seq:)
|
|
385
|
+
# @return [self]
|
|
386
|
+
# @raise [ArgumentError] when the checkpoint does not exist
|
|
387
|
+
# @raise [RuntimeError] when checkpointing is not enabled or the
|
|
388
|
+
# session is running
|
|
389
|
+
def rollback!(seq: nil, turn: nil)
|
|
390
|
+
require_checkpoints!
|
|
391
|
+
raise "cannot roll back a running session" if @running
|
|
392
|
+
|
|
393
|
+
seq = resolve_checkpoint_seq(seq, turn)
|
|
394
|
+
data = load_checkpoint(seq: seq)
|
|
395
|
+
raise ArgumentError, "no checkpoint #{seq}" unless data
|
|
396
|
+
|
|
397
|
+
@checkpoint_store.rollback(@id, seq)
|
|
398
|
+
restore_from_snapshot(data)
|
|
399
|
+
emit(Events::SessionRolledBack.new(session_id: @id, seq: seq, turn_count: @turn_count))
|
|
400
|
+
self
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Fork the session at a checkpoint: a new session (new id, same model
|
|
404
|
+
# and tools) whose history is everything up to that point, backed by
|
|
405
|
+
# its own checkpoint chain. Continue the branch with +run+.
|
|
406
|
+
#
|
|
407
|
+
# @param at_seq [Integer, nil] checkpoint to fork from (xor +at_turn:)
|
|
408
|
+
# @param at_turn [Integer, nil] fork at the last checkpoint whose turn
|
|
409
|
+
# count equals +at_turn+ (xor +at_seq:)
|
|
410
|
+
# @return [Ask::Agent::Session] the forked session
|
|
411
|
+
# @raise [ArgumentError] when the checkpoint does not exist
|
|
412
|
+
# @raise [RuntimeError] when checkpointing is not enabled
|
|
413
|
+
def fork(at_seq: nil, at_turn: nil)
|
|
414
|
+
require_checkpoints!
|
|
415
|
+
|
|
416
|
+
seq = resolve_checkpoint_seq(at_seq, at_turn)
|
|
417
|
+
data = load_checkpoint(seq: seq)
|
|
418
|
+
raise ArgumentError, "no checkpoint #{seq}" unless data
|
|
419
|
+
|
|
420
|
+
forked_id = @checkpoint_store.fork(@id, at_seq: seq)
|
|
421
|
+
forked = self.class.new(
|
|
422
|
+
id: forked_id,
|
|
423
|
+
model: data[:metadata][:model],
|
|
424
|
+
tools: @tools,
|
|
425
|
+
state: @state,
|
|
426
|
+
checkpoints: true
|
|
427
|
+
)
|
|
428
|
+
restore_into(forked, data)
|
|
429
|
+
emit(Events::SessionForked.new(session_id: @id, forked_id: forked_id, seq: seq))
|
|
430
|
+
forked
|
|
431
|
+
end
|
|
432
|
+
|
|
334
433
|
# --- Async (pending) tools ---
|
|
335
434
|
|
|
336
435
|
# Registers a pending tool call (called by the loop when a tool
|
|
@@ -551,8 +650,52 @@ module Ask
|
|
|
551
650
|
compactor
|
|
552
651
|
end
|
|
553
652
|
|
|
653
|
+
def require_checkpoints!
|
|
654
|
+
raise "checkpointing is not enabled (pass state: and checkpoints: true)" unless @checkpoints
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Resolve a checkpoint seq from either an explicit seq or a turn
|
|
658
|
+
# count (the last checkpoint whose metadata turn_count matches).
|
|
659
|
+
def resolve_checkpoint_seq(seq, turn)
|
|
660
|
+
raise ArgumentError, "pass either seq: or turn:, not both" if seq && turn
|
|
661
|
+
|
|
662
|
+
if seq
|
|
663
|
+
seq
|
|
664
|
+
elsif turn
|
|
665
|
+
found = checkpoint_history.reverse_each.find do |s|
|
|
666
|
+
data = self.class.deep_symbolize_keys(@checkpoint_store.load(@id, seq: s) || {})
|
|
667
|
+
data.dig(:metadata, :turn_count) == turn
|
|
668
|
+
end
|
|
669
|
+
raise ArgumentError, "no checkpoint at turn #{turn}" unless found
|
|
670
|
+
|
|
671
|
+
found
|
|
672
|
+
else
|
|
673
|
+
raise ArgumentError, "pass either seq: or turn:"
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# Replace the session's in-memory state with a snapshot (symbol keys)
|
|
678
|
+
# and keep the legacy blob consistent.
|
|
679
|
+
def restore_from_snapshot(data)
|
|
680
|
+
restore_into(self, data)
|
|
681
|
+
@state.set(@id, data)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def restore_into(target, data)
|
|
685
|
+
target.chat.reset_messages!
|
|
686
|
+
data[:messages].each do |msg|
|
|
687
|
+
target.chat.add_message(
|
|
688
|
+
role: msg[:role].to_sym,
|
|
689
|
+
content: msg[:content],
|
|
690
|
+
tool_call_id: msg[:tool_call_id]
|
|
691
|
+
)
|
|
692
|
+
end
|
|
693
|
+
target.instance_variable_set(:@messages, target.chat.messages.dup)
|
|
694
|
+
target.instance_variable_set(:@turn_count, data.dig(:metadata, :turn_count) || 0)
|
|
695
|
+
end
|
|
696
|
+
|
|
554
697
|
def persist!
|
|
555
|
-
|
|
698
|
+
payload = {
|
|
556
699
|
id: @id,
|
|
557
700
|
messages: @chat.messages.map { |m|
|
|
558
701
|
{
|
|
@@ -570,7 +713,9 @@ module Ask
|
|
|
570
713
|
created_at: @created_at.iso8601,
|
|
571
714
|
updated_at: Time.now.iso8601
|
|
572
715
|
}
|
|
573
|
-
}
|
|
716
|
+
}
|
|
717
|
+
@state.set(@id, payload)
|
|
718
|
+
@checkpoint_store.checkpoint(@id, payload) if @checkpoints
|
|
574
719
|
end
|
|
575
720
|
|
|
576
721
|
def try_auto_meta_agent
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Agent
|
|
7
|
+
# Repairs malformed tool calls before they execute.
|
|
8
|
+
#
|
|
9
|
+
# When a model emits a tool call with unparseable arguments or an unknown
|
|
10
|
+
# tool name, execution currently fails and the model burns a turn seeing
|
|
11
|
+
# the error. Repair intercepts those calls before execution, asks the
|
|
12
|
+
# model to re-emit them corrected (one internal LLM round-trip), and
|
|
13
|
+
# executes the corrected versions — remapped to the original call ids so
|
|
14
|
+
# tool results stay consistent with the conversation history.
|
|
15
|
+
#
|
|
16
|
+
# Enable with `Session.new(tool_call_repair: true)` for the built-in
|
|
17
|
+
# repair prompt, or pass a callable for full control:
|
|
18
|
+
#
|
|
19
|
+
# Session.new(tool_call_repair: ->(chat, calls, tools) {
|
|
20
|
+
# { calls.keys.first => ToolCallInfo.new(
|
|
21
|
+
# id: calls.keys.first, name: "bash", arguments: "{}"
|
|
22
|
+
# ) }
|
|
23
|
+
# })
|
|
24
|
+
#
|
|
25
|
+
# The callable receives the chat, the malformed calls hash (id =>
|
|
26
|
+
# ToolCallInfo), and the available tools; it returns a hash of
|
|
27
|
+
# corrections keyed by the ORIGINAL call ids. Calls without a correction
|
|
28
|
+
# are dropped — the model saw them in the prompt and declined to fix
|
|
29
|
+
# them.
|
|
30
|
+
class ToolCallRepair
|
|
31
|
+
# @param tool_call [ToolCallInfo]
|
|
32
|
+
# @param tools [Array<Object>]
|
|
33
|
+
# @return [String, nil] why the call is repairable, or nil when it is
|
|
34
|
+
# well-formed and executable
|
|
35
|
+
def self.repair_info(tool_call, tools)
|
|
36
|
+
unless tools.any? { |t| t.respond_to?(:name) && t.name == tool_call.name }
|
|
37
|
+
return "unknown tool '#{tool_call.name}'"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
args = tool_call.arguments
|
|
41
|
+
return nil if args.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
parsed = JSON.parse(args.to_s)
|
|
44
|
+
return nil if parsed.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
"arguments must be a JSON object, got #{parsed.class}"
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
"arguments are not valid JSON: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param callable [Proc, nil] custom repair function; nil uses the
|
|
52
|
+
# built-in repair prompt
|
|
53
|
+
def initialize(callable = nil)
|
|
54
|
+
@callable = callable
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Ask the model to correct the malformed calls.
|
|
58
|
+
#
|
|
59
|
+
# @param chat [Ask::Agent::Chat] the session chat (history is restored
|
|
60
|
+
# after the internal round-trip)
|
|
61
|
+
# @param calls [Hash{String => ToolCallInfo}] malformed calls by id
|
|
62
|
+
# @param tools [Array<Object>]
|
|
63
|
+
# @return [Hash{String => ToolCallInfo}] corrections keyed by the
|
|
64
|
+
# original call ids
|
|
65
|
+
def call(chat:, calls:, tools:)
|
|
66
|
+
if @callable
|
|
67
|
+
normalize(@callable.call(chat, calls, tools))
|
|
68
|
+
else
|
|
69
|
+
built_in(chat, calls, tools)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def built_in(chat, calls, tools)
|
|
76
|
+
size = chat.messages.size
|
|
77
|
+
response = chat.ask(repair_prompt(calls, tools))
|
|
78
|
+
# Remove the internal repair exchange from the conversation so the
|
|
79
|
+
# history stays clean and the model never sees it.
|
|
80
|
+
chat.messages.slice!(size..)
|
|
81
|
+
|
|
82
|
+
corrections = {}
|
|
83
|
+
response.tool_calls.values.each_with_index do |tc, index|
|
|
84
|
+
original_id = calls.keys[index]
|
|
85
|
+
break unless original_id
|
|
86
|
+
|
|
87
|
+
corrections[original_id] = ToolCallInfo.new(
|
|
88
|
+
id: original_id, name: tc.name, arguments: tc.arguments
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
corrections
|
|
92
|
+
rescue StandardError
|
|
93
|
+
# Repair is best-effort: on any failure, drop the malformed calls
|
|
94
|
+
# rather than failing the turn. Restore history either way.
|
|
95
|
+
chat.messages.slice!(size..) rescue nil
|
|
96
|
+
{}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Normalize a custom callable's result to the corrections contract
|
|
100
|
+
# (original id => ToolCallInfo), dropping anything malformed.
|
|
101
|
+
def normalize(result)
|
|
102
|
+
return {} unless result.respond_to?(:each)
|
|
103
|
+
|
|
104
|
+
result.each_with_object({}) do |(id, tc), acc|
|
|
105
|
+
next unless tc.respond_to?(:name)
|
|
106
|
+
|
|
107
|
+
acc[id] = ToolCallInfo.new(id: id, name: tc.name, arguments: tc.arguments)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def repair_prompt(calls, tools)
|
|
112
|
+
lines = calls.map.with_index do |(id, tc), i|
|
|
113
|
+
reason = self.class.repair_info(tc, tools) || "invalid"
|
|
114
|
+
"#{i + 1}. call id \"#{id}\", tool \"#{tc.name}\", " \
|
|
115
|
+
"arguments: #{tc.arguments.inspect} — #{reason}"
|
|
116
|
+
end
|
|
117
|
+
tool_list = tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }.join(", ")
|
|
118
|
+
|
|
119
|
+
<<~PROMPT.strip
|
|
120
|
+
Some tool calls from your last message were invalid and could not be executed:
|
|
121
|
+
#{lines.join("\n")}
|
|
122
|
+
|
|
123
|
+
Available tools: #{tool_list.empty? ? "(none)" : tool_list}
|
|
124
|
+
|
|
125
|
+
Reply by calling the tools again with corrected arguments, in the same order as listed above. Call ONLY the tools listed above that you can correct; if a tool truly does not exist or cannot be corrected, do not call it.
|
|
126
|
+
PROMPT
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -37,6 +37,9 @@ module Ask
|
|
|
37
37
|
autoload :ApprovalPolicy, "ask/agent/policies/approval_policy"
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
autoload :ToolCallRepair, "ask/agent/tool_call_repair"
|
|
41
|
+
autoload :CheckpointStore, "ask/agent/checkpoint_store"
|
|
42
|
+
|
|
40
43
|
module Middleware
|
|
41
44
|
autoload :Base, "ask/agent/middleware/base"
|
|
42
45
|
autoload :Pipeline, "ask/agent/middleware/pipeline"
|
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.29.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -166,6 +166,7 @@ files:
|
|
|
166
166
|
- lib/ask/agent.rb
|
|
167
167
|
- lib/ask/agent/approval_queue.rb
|
|
168
168
|
- lib/ask/agent/chat.rb
|
|
169
|
+
- lib/ask/agent/checkpoint_store.rb
|
|
169
170
|
- lib/ask/agent/cli.rb
|
|
170
171
|
- lib/ask/agent/compactor.rb
|
|
171
172
|
- lib/ask/agent/configuration.rb
|
|
@@ -204,6 +205,7 @@ files:
|
|
|
204
205
|
- lib/ask/agent/telemetry.rb
|
|
205
206
|
- lib/ask/agent/test.rb
|
|
206
207
|
- lib/ask/agent/tool_abort_controller.rb
|
|
208
|
+
- lib/ask/agent/tool_call_repair.rb
|
|
207
209
|
- lib/ask/agent/tool_executor.rb
|
|
208
210
|
- lib/ask/agent/version.rb
|
|
209
211
|
homepage: https://github.com/ask-rb/ask-agent
|