ask-agent 0.29.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: 11cf486fa2d955d92cc6f8268f30e5a418b6f0567cdfb6fe6eac015a3352c74b
4
- data.tar.gz: 5b4990efc0b66de943f6e5c3b7940d31cca6295c54c40fafef485c6004433601
3
+ metadata.gz: 7b6eca46c976555990a13366053665b9340f4a97d578a332a8cbf9615cd5647d
4
+ data.tar.gz: 7c16e54008a44cc0eafb8ebfea51c752e0d6879749635e606142f84cdd03d682
5
5
  SHA512:
6
- metadata.gz: 3adf87ccd9b0cbb606133cb91b8baf9e24a328f96e4ed8f77266258883b13663762cbd16a3e1e34475ec0b93ab763518c4b661fa675fba37985144c01435a736
7
- data.tar.gz: b6093801427cf4c8bc56fd9b6b0bf6f9fd6bc5252ce336731149413984b5f83862e4cfeae2e9705b2517bb73e3063091950293f74cdb32109624965e52ccc760
6
+ metadata.gz: 05eb5c72881fcb3730dab2f89d51444b3f86f41413f454090331d5c9bb240b6ca4751c40f0fdc3ef10f2ea718989aa7e68e716e13570e247a094f76ae0702051
7
+ data.tar.gz: 712139e5f89850fe706268b74a08854c2f1f0be7276e4717039c73192a872d6f4a3c2566954ae4133ce10917d51ea4ae5687995b828ef024bead95a8222ac68d
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
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
+
1
14
  ## [0.29.0] — 2026-08-06
2
15
 
3
16
  ### Added
@@ -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
@@ -29,6 +29,11 @@ module Ask
29
29
  CompactionStart = Data.define(:tokens_before, :reason)
30
30
  CompactionEnd = Data.define(:tokens_before, :tokens_after, :summary)
31
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
+
32
37
  LoopDetected = Data.define(:tool_name, :repeated_count)
33
38
  MaxTurnsExceeded = Data.define(:max_turns)
34
39
 
@@ -23,7 +23,7 @@ module Ask
23
23
  reflector: nil, telemetry: true, meta_agent: nil,
24
24
  agent_dir: nil, evaluator: nil, audit_log: nil,
25
25
  skills_disclosure: true, approval: nil,
26
- tool_call_repair: nil, **chat_options)
26
+ tool_call_repair: nil, checkpoints: false, **chat_options)
27
27
  @id = id || SecureRandom.uuid
28
28
  @agent_dir = agent_dir
29
29
  @max_turns = max_turns
@@ -61,6 +61,11 @@ module Ask
61
61
  apply_system_context
62
62
 
63
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
64
69
 
65
70
  reflector_opts = reflector.is_a?(Hash) ? reflector : {}
66
71
  @reflector = if reflector
@@ -309,8 +314,21 @@ module Ask
309
314
  session = new(
310
315
  id: data[:id],
311
316
  model: data.dig(:metadata, :model),
312
- tools: data.dig(:metadata, :tools)&.map(&:constantize) || [],
313
- 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?
314
332
  )
315
333
 
316
334
  data[:messages].each do |msg|
@@ -327,6 +345,7 @@ module Ask
327
345
 
328
346
  def delete
329
347
  @deleted = true
348
+ @checkpoint_store&.delete(@id)
330
349
  @state&.delete(@id)
331
350
  end
332
351
 
@@ -336,6 +355,81 @@ module Ask
336
355
 
337
356
  def abort_requested? = @abort_requested
338
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
+
339
433
  # --- Async (pending) tools ---
340
434
 
341
435
  # Registers a pending tool call (called by the loop when a tool
@@ -556,8 +650,52 @@ module Ask
556
650
  compactor
557
651
  end
558
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
+
559
697
  def persist!
560
- @state.set(@id, {
698
+ payload = {
561
699
  id: @id,
562
700
  messages: @chat.messages.map { |m|
563
701
  {
@@ -575,7 +713,9 @@ module Ask
575
713
  created_at: @created_at.iso8601,
576
714
  updated_at: Time.now.iso8601
577
715
  }
578
- })
716
+ }
717
+ @state.set(@id, payload)
718
+ @checkpoint_store.checkpoint(@id, payload) if @checkpoints
579
719
  end
580
720
 
581
721
  def try_auto_meta_agent
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.29.0"
5
+ VERSION = "0.29.1"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -38,6 +38,7 @@ module Ask
38
38
  end
39
39
 
40
40
  autoload :ToolCallRepair, "ask/agent/tool_call_repair"
41
+ autoload :CheckpointStore, "ask/agent/checkpoint_store"
41
42
 
42
43
  module Middleware
43
44
  autoload :Base, "ask/agent/middleware/base"
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.29.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