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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36e26d74ffa1c3e3e97b8e172f4fef2ada07c1c194488a8f550e8b77b7473ee1
4
- data.tar.gz: 82568a4cc3ed19617293416d10f2f3194c1a18079a1426431a2d878afee75fa0
3
+ metadata.gz: 7b6eca46c976555990a13366053665b9340f4a97d578a332a8cbf9615cd5647d
4
+ data.tar.gz: 7c16e54008a44cc0eafb8ebfea51c752e0d6879749635e606142f84cdd03d682
5
5
  SHA512:
6
- metadata.gz: cfd202f83bb95e3349282c3771e591f93062976b58c964d65e928e09a6408818ef969200c10b68c4a307e0c0d9fabde112aaf20b9776110a5040590370ecd317
7
- data.tar.gz: 7d3ae82b872659e9e27217bc98cf7dfffee28f5209ee5f351dcc944564a63a777888fdad780de218956019d38f223996ebd6372c7be84cf33fdbf9a1eeb4e5a7
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)
@@ -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
- input = chunk.usage[:input_tokens] || chunk.usage["input_tokens"] || input
359
- output = chunk.usage[:output_tokens] || chunk.usage["output_tokens"] || output
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
- output += 1 if chunk.content.to_s.length > 0
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
@@ -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
 
@@ -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)
@@ -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, **chat_options)
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
- tools: data.dig(:metadata, :tools)&.map(&:constantize) || [],
308
- state: adapter
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
- @state.set(@id, {
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.28.0"
5
+ VERSION = "0.29.1"
6
6
  end
7
7
  end
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.28.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