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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/ask/agent/chat.rb +13 -3
- data/lib/ask/agent/checkpoint_store.rb +133 -0
- data/lib/ask/agent/events.rb +5 -0
- data/lib/ask/agent/session.rb +145 -5
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +1 -0
- metadata +2 -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,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
|
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
|
@@ -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
|
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
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.
|
|
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
|