ask-agent 0.36.0 → 0.38.0

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: fcbe9fc4bae7e7eddc9fc206e35ef22499a9fb3a308bac6d9f48c2d1d2e12f8f
4
- data.tar.gz: ae9c72d61a839330d2bbc68b8049fe1e54680f0462d4dee2e886d8377df439b3
3
+ metadata.gz: f3486ed15f739d216a4b0dd46e8efdb29cc6cc7689a70ea4a2a6ef5477444037
4
+ data.tar.gz: 628f14e1905695cf814eb3ff49012f924d30ce13c92c25ef425ec695dc37502f
5
5
  SHA512:
6
- metadata.gz: 7a34904c0d8c0d6397753262d4f3c359674893b5ddab7a85714f8eebf797db0cdd3b6a076ea2c18eb693ad1f2211677e88bf4f4a7f4e2e2f7fe5545892af5062
7
- data.tar.gz: d78f87319a5d6b63c22e1bd7d9cacde6fd6306131f746ad5b4166d509acf5dad11b03db7e9fab6f908db752f7a592f2a4dc51fdb13fb4fa30ebc39c193894acb
6
+ metadata.gz: 7cd3f07a7dbcaa19379815eb1999f225bef3baa938ab34e018c0d1c0d755edbd5e78ba67881f65098e4b3a59b2f6696e75e1a6bfa2bba3ba17c9cb151918c9ae
7
+ data.tar.gz: 8644f8522aa5ea4719bb6e02d6dc19ce85d66f2e5e53765da02fcb8a002c67540f60ce31f787abb091a4e76b80a83fdb82d538b2866506385fa8c66e94f54d4a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,53 @@
1
+ ## [0.38.0] — 2026-08-07
2
+
3
+ ### Added
4
+
5
+ - **Artifacts — tool deliverables with a web-friendly home.**
6
+ `Session.new(artifacts: true)` collects tool-produced files into an
7
+ `Ask::Agent::ArtifactStore` on the same state adapter as sessions and
8
+ checkpoints:
9
+ - Tools attach `metadata: { artifact: { filename:, mime_type:, content: | uri: } }`
10
+ to their result. **Inline content** (small text: reports, CSVs,
11
+ patches) is stored in the state store; **external URIs** (large or
12
+ binary files) are stored as references with metadata only.
13
+ - `Session#artifacts` lists them (newest first, no content payload);
14
+ `Session#fetch_artifact(id)` retrieves the full record. `Session#delete`
15
+ cleans up.
16
+ - **Uploader hook** — `Session.new(artifacts: true, artifact_uploader:
17
+ ->(content:, filename:, mime_type:) { uri })` lifts inline content to a
18
+ URI before storage, so apps that prefer object storage never grow the
19
+ database: tools return content, the session uploads, the store keeps
20
+ the reference.
21
+ - Malformed artifacts never fail the tool — the message notes
22
+ `[artifact not stored: ...]` instead.
23
+
24
+ ## [0.37.0] — 2026-08-07
25
+
26
+ ### Added
27
+
28
+ - **Steer — concurrency-safe message injection.**
29
+ `Session#steer(message, expected_turn_id:)` lets any thread (web, CLI,
30
+ another agent) inject a message safely:
31
+ - **`:stale`** — `expected_turn_id` doesn't match the current turn id
32
+ (the caller was looking at an older state); the message is rejected.
33
+ - **`:queued`** — a turn is running; the message is held and dispatched
34
+ as the next user message at the next turn boundary (the loop now
35
+ resolves each recursive turn's message from a steer source). No more
36
+ abort-and-retry.
37
+ - **`:steered`** — the session is idle; the message enters the
38
+ conversation and the next run processes it. Queued leftovers drain at
39
+ the next run start.
40
+ - `Session#turn_id` tracks the running turn (bumped on `TurnStart`);
41
+ `Session#queued_steers` reports pending messages.
42
+
43
+ ### Fixed
44
+
45
+ - **No more duplicate tail checkpoints.** The loop persists after every
46
+ turn and `run()` persists again on the way out, so every run previously
47
+ appended a redundant checkpoint (seqs 1,2,3 for two turns). `persist!`
48
+ now skips the checkpoint when the message count and turn count are
49
+ unchanged — `checkpoint_history` is exact.
50
+
1
51
  ## [0.36.0] — 2026-08-07
2
52
 
3
53
  ### Added
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Ask
8
+ module Agent
9
+ # Session-scoped storage for tool-produced deliverables ("artifacts").
10
+ #
11
+ # Artifacts come in two kinds, chosen by the producing tool:
12
+ #
13
+ # - **content** — small text deliverables (reports, CSVs, patches,
14
+ # generated code) stored inline in the state store.
15
+ # - **uri** — large or binary deliverables stored externally (object
16
+ # storage, a file service); the store keeps the reference and
17
+ # metadata only.
18
+ #
19
+ # Metadata always lives in the state store (pure KV, same adapter as
20
+ # sessions/checkpoints/memory — works with every backend and with the
21
+ # in-process Memory fallback):
22
+ # artifact:<session_id>:<artifact_id> — one key per artifact
23
+ # artifact:<session_id>:index — JSON array of ids
24
+ #
25
+ # An optional +uploader+ callback lifts inline content to a URI before
26
+ # storage (e.g. upload to S3), so apps that prefer object storage never
27
+ # grow the database: the tool returns content, the session uploads, the
28
+ # store keeps the reference.
29
+ class ArtifactStore
30
+ KEY_PREFIX = "artifact:"
31
+ INDEX_SUFFIX = ":index"
32
+
33
+ # @param state [Ask::State::Adapter] backing store
34
+ # @param max_content_size [Integer] inline content cap (chars)
35
+ # @param uploader [Proc, nil] called with (content:, filename:,
36
+ # mime_type:) when a tool provides inline content; must return a URI
37
+ # string. When set, inline content is uploaded and the URI stored.
38
+ def initialize(state:, max_content_size: 100_000, uploader: nil)
39
+ @state = state
40
+ @max_content_size = max_content_size
41
+ @uploader = uploader
42
+ @mutex = Monitor.new
43
+ end
44
+
45
+ # @return [Ask::State::Adapter] the underlying adapter
46
+ attr_reader :state
47
+
48
+ # @return [Proc, nil] the uploader callback, if any
49
+ attr_reader :uploader
50
+
51
+ # Store an artifact for a session.
52
+ #
53
+ # @param session_id [String]
54
+ # @param filename [String] required
55
+ # @param mime_type [String, nil]
56
+ # @param content [String, nil] inline content (small text); xor +uri+
57
+ # @param uri [String, nil] external reference (large/binary); xor
58
+ # +content+
59
+ # @return [Hash] the stored record {id:, filename:, mime_type:, size:,
60
+ # created_at:, content: | uri:}
61
+ # @raise [ArgumentError] on invalid input
62
+ def store(session_id, filename:, mime_type: nil, content: nil, uri: nil)
63
+ raise ArgumentError, "filename is required" if filename.to_s.strip.empty?
64
+ raise ArgumentError, "pass either content: or uri:, not both" if content && uri
65
+ raise ArgumentError, "pass either content: or uri:" unless content || uri
66
+
67
+ if content
68
+ content = content.to_s
69
+ if content.length > @max_content_size
70
+ raise ArgumentError, "content exceeds #{@max_content_size} chars; use uri: for large artifacts"
71
+ end
72
+ if @uploader
73
+ uri = @uploader.call(content: content, filename: filename.to_s, mime_type: mime_type)
74
+ content = nil
75
+ end
76
+ end
77
+
78
+ id = SecureRandom.uuid
79
+ record = {
80
+ id: id,
81
+ filename: filename.to_s,
82
+ mime_type: mime_type,
83
+ size: content ? content.length : nil,
84
+ created_at: Time.now.iso8601
85
+ }
86
+ record[:content] = content if content
87
+ record[:uri] = uri if uri
88
+
89
+ @mutex.synchronize do
90
+ @state.set(entry_key(session_id, id), record)
91
+ @state.set(index_key(session_id), (load_index(session_id) + [id]).to_json)
92
+ end
93
+ record
94
+ end
95
+
96
+ # @param session_id [String]
97
+ # @return [Array<Hash>] artifact summaries (id, filename, mime_type,
98
+ # size, uri) newest first — content is not included
99
+ def list(session_id)
100
+ load_index(session_id)
101
+ .filter_map { |id| fetch(session_id, id) }
102
+ .reverse
103
+ .map { |r| r.slice(:id, :filename, :mime_type, :size, :uri) }
104
+ end
105
+
106
+ # @param session_id [String]
107
+ # @param id [String]
108
+ # @return [Hash, nil] the full record (content or uri)
109
+ def fetch(session_id, id)
110
+ data = @state.get(entry_key(session_id, id))
111
+ return nil unless data
112
+
113
+ symbolize(data)
114
+ end
115
+
116
+ # Remove every artifact for a session (called by Session#delete).
117
+ #
118
+ # @param session_id [String]
119
+ # @return [void]
120
+ def delete(session_id)
121
+ @mutex.synchronize do
122
+ load_index(session_id).each { |id| @state.delete(entry_key(session_id, id)) }
123
+ @state.delete(index_key(session_id))
124
+ end
125
+ nil
126
+ end
127
+
128
+ private
129
+
130
+ def load_index(session_id)
131
+ raw = @state.get(index_key(session_id))
132
+ raw ? JSON.parse(raw) : []
133
+ end
134
+
135
+ def entry_key(session_id, id)
136
+ "#{KEY_PREFIX}#{session_id}:#{id}"
137
+ end
138
+
139
+ def index_key(session_id)
140
+ "#{KEY_PREFIX}#{session_id}#{INDEX_SUFFIX}"
141
+ end
142
+
143
+ def symbolize(obj)
144
+ case obj
145
+ when Hash
146
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
147
+ when Array
148
+ obj.map { |e| symbolize(e) }
149
+ else
150
+ obj
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -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, tool_call_repair: nil)
20
+ def run_turn(chat:, message:, tools:, tool_executor:, compactor:, hooks:, event_emitter:, session_id: nil, persist: nil, tool_call_repair: nil, steer_source: nil)
21
21
  raise MaxTurnsExceeded if @turn_count >= @max_turns
22
22
 
23
23
  event_emitter.emit(Events::TurnStart.new)
@@ -157,10 +157,12 @@ module Ask
157
157
  # Aborted while tools ran? Skip the follow-up LLM call.
158
158
  return response.content.to_s if aborted?(event_emitter)
159
159
 
160
- # Recursive call — LLM processes tool results
160
+ # Recursive call — LLM processes tool results. When a steer source
161
+ # is provided, queued steer messages become the next user message
162
+ # instead of an empty continuation.
161
163
  run_turn(
162
164
  chat: chat,
163
- message: "",
165
+ message: steer_source ? steer_source.call.to_s : "",
164
166
  tools: tools,
165
167
  tool_executor: tool_executor,
166
168
  compactor: compactor,
@@ -168,7 +170,8 @@ module Ask
168
170
  event_emitter: event_emitter,
169
171
  session_id: session_id,
170
172
  persist: persist,
171
- tool_call_repair: tool_call_repair
173
+ tool_call_repair: tool_call_repair,
174
+ steer_source: steer_source
172
175
  )
173
176
  end
174
177
 
@@ -5,8 +5,7 @@ require "json"
5
5
  module Ask
6
6
  module Agent
7
7
  # Extracts durable facts from a finished session's transcript and writes
8
- # them to the session's {Memory} — the "learning" half of durable memory
9
- # (codex two-phase pattern, phase 1).
8
+ # them to the session's {Memory} — the "learning" half of durable memory.
10
9
  #
11
10
  # One LLM call with a structured-output prompt: the model reads the
12
11
  # memory-relevant messages and returns a JSON list of durable facts.
@@ -26,6 +26,7 @@ module Ask
26
26
  tool_call_repair: nil, checkpoints: false,
27
27
  todos: false, plan_mode: false, memory: nil,
28
28
  memory_learning: false, offload_large_outputs: false,
29
+ artifacts: false, artifact_uploader: nil,
29
30
  **chat_options)
30
31
  @id = id || SecureRandom.uuid
31
32
  @agent_dir = agent_dir
@@ -41,6 +42,13 @@ module Ask
41
42
  @pending_mutex = Mutex.new
42
43
  @followup_pending = false
43
44
  @turn_count = 0
45
+ # Concurrency-safe steering: turn id bumped at every
46
+ # TurnStart; steers arriving mid-turn are queued and dispatched at
47
+ # the next turn boundary.
48
+ @turn_id = 0
49
+ @queued_steers = []
50
+ @steer_mutex = Mutex.new
51
+ on(Events::TurnStart) { @turn_id += 1 }
44
52
  @created_at = Time.now
45
53
  @_no_tools_instructed = false
46
54
 
@@ -78,6 +86,17 @@ module Ask
78
86
  ToolOutputStore.new(state: state || persistence || Ask::State::Memory.new)
79
87
  end
80
88
 
89
+ # Tool deliverables (artifacts): metadata[:artifact] on tool results
90
+ # is collected into the store — inline content for small text,
91
+ # external URIs for large binaries (uploader lifts content to a URI
92
+ # when provided).
93
+ @artifact_store = if artifacts
94
+ ArtifactStore.new(
95
+ state: state || persistence || Ask::State::Memory.new,
96
+ uploader: artifact_uploader
97
+ )
98
+ end
99
+
81
100
  # Plan mode — research phase gated to read-only tools until a human
82
101
  # approves the model's plan (submitted via the exit_plan_mode tool).
83
102
  @plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
@@ -98,7 +117,8 @@ module Ask
98
117
  max_retries: max_tool_retries,
99
118
  parallel: parallel_tools,
100
119
  output_offload_threshold: @offload_threshold,
101
- output_store: @output_store
120
+ output_store: @output_store,
121
+ artifact_store: @artifact_store
102
122
  )
103
123
  @compactor = compactor ? build_compactor(compactor) : nil
104
124
  @hooks = Hooks.new(hooks)
@@ -172,6 +192,9 @@ module Ask
172
192
  # @return [Ask::Agent::ToolOutputStore, nil] store for offloaded large
173
193
  # tool outputs (only when large-output offloading is enabled)
174
194
  attr_reader :output_store
195
+ # @return [Ask::Agent::ArtifactStore, nil] store for tool deliverables
196
+ # (only when the +artifacts:+ option is enabled)
197
+ attr_reader :artifact_store
175
198
 
176
199
  def run(message, tools: nil, reset: true)
177
200
  raise "Session deleted" if @deleted
@@ -197,6 +220,10 @@ module Ask
197
220
  @_no_tools_instructed = true
198
221
  end
199
222
 
223
+ # Leftover queued steers from a previous run become user messages
224
+ # before this run starts.
225
+ drain_leftover_steers
226
+
200
227
  begin
201
228
  @tool_executor.telemetry = @telemetry
202
229
 
@@ -210,6 +237,7 @@ module Ask
210
237
  event_emitter: self,
211
238
  session_id: @id,
212
239
  tool_call_repair: @tool_call_repair,
240
+ steer_source: method(:drain_one_steer),
213
241
  persist: @state ? method(:persist!) : nil
214
242
  )
215
243
 
@@ -429,6 +457,7 @@ module Ask
429
457
  @deleted = true
430
458
  @checkpoint_store&.delete(@id)
431
459
  @output_store&.delete(@id)
460
+ @artifact_store&.delete(@id)
432
461
  @state&.delete(@id)
433
462
  end
434
463
 
@@ -515,8 +544,40 @@ module Ask
515
544
  forked
516
545
  end
517
546
 
547
+ # --- Artifacts (tool deliverables) ---
548
+
549
+ # @return [Array<Hash>] artifact summaries for this session (id,
550
+ # filename, mime_type, size, uri), newest first
551
+ # @raise [RuntimeError] when artifacts are not enabled
552
+ def artifacts
553
+ require_artifacts!
554
+ @artifact_store.list(@id)
555
+ end
556
+
557
+ # @param id [String] artifact id
558
+ # @return [Hash, nil] the full record (content or uri)
559
+ # @raise [RuntimeError] when artifacts are not enabled
560
+ def fetch_artifact(id)
561
+ require_artifacts!
562
+ @artifact_store.fetch(@id, id)
563
+ end
564
+
518
565
  # --- Plan mode ---
519
566
 
567
+ # Pop the next queued steer (called by the loop at each turn
568
+ # boundary); returns "" when nothing is queued.
569
+ def drain_one_steer
570
+ @steer_mutex.synchronize { @queued_steers.shift }.to_s
571
+ end
572
+
573
+ # Move any queued steers left over from a previous run into the
574
+ # conversation (the session was idle, so they are dispatched now).
575
+ def drain_leftover_steers
576
+ while (message = drain_one_steer) != ""
577
+ @chat.add_message(role: :user, content: message)
578
+ end
579
+ end
580
+
520
581
  # Extract durable facts from this session's transcript into memory
521
582
  # (memory_learning: true). Best-effort — extraction never breaks the
522
583
  # session; failures are swallowed.
@@ -582,6 +643,45 @@ module Ask
582
643
  )
583
644
  end
584
645
 
646
+ # --- Steer (concurrency-safe message injection) ---
647
+
648
+ # @return [Integer] id of the turn currently running (or the last
649
+ # completed turn when idle)
650
+ attr_reader :turn_id
651
+
652
+ # Inject a message into the session safely, from any thread (web, CLI,
653
+ # another agent):
654
+ #
655
+ # - **:stale** — the caller's `expected_turn_id` does not match the
656
+ # current turn id (the caller was looking at an older state).
657
+ # - **:queued** — a turn is running; the message is held and dispatched
658
+ # as the next user message at the next turn boundary.
659
+ # - **:steered** — the session is idle; the message is added to the
660
+ # conversation and processed by the next run.
661
+ #
662
+ # @param message [String]
663
+ # @param expected_turn_id [Integer, nil] the turn id the caller
664
+ # believes is current; nil skips the check
665
+ # @return [Hash] {status: :stale|:queued|:steered, turn_id: Integer}
666
+ def steer(message, expected_turn_id: nil)
667
+ @steer_mutex.synchronize do
668
+ if expected_turn_id && expected_turn_id != @turn_id
669
+ return { status: :stale, turn_id: @turn_id }
670
+ end
671
+ if @running
672
+ @queued_steers << message.to_s
673
+ return { status: :queued, turn_id: @turn_id }
674
+ end
675
+ end
676
+ @chat.add_message(role: :user, content: message.to_s)
677
+ { status: :steered, turn_id: @turn_id }
678
+ end
679
+
680
+ # @return [Integer] steers queued and not yet dispatched
681
+ def queued_steers
682
+ @steer_mutex.synchronize { @queued_steers.size }
683
+ end
684
+
585
685
  # --- Async (pending) tools ---
586
686
 
587
687
  # Registers a pending tool call (called by the loop when a tool
@@ -819,6 +919,10 @@ module Ask
819
919
  compactor
820
920
  end
821
921
 
922
+ def require_artifacts!
923
+ raise "artifacts are not enabled (pass artifacts: true)" unless @artifact_store
924
+ end
925
+
822
926
  def require_checkpoints!
823
927
  raise "checkpointing is not enabled (pass state: and checkpoints: true)" unless @checkpoints
824
928
  end
@@ -895,7 +999,17 @@ module Ask
895
999
  }
896
1000
  }
897
1001
  @state.set(@id, payload)
898
- @checkpoint_store.checkpoint(@id, payload) if @checkpoints
1002
+ # Checkpoint only when the conversation actually changed since the
1003
+ # last one: the loop persists after every turn and run() persists
1004
+ # again on the way out, so without this check every run would append
1005
+ # a duplicate tail checkpoint.
1006
+ if @checkpoints
1007
+ head = @checkpoint_store.load(@id)
1008
+ head_messages = head ? (head["messages"] || head[:messages] || []) : []
1009
+ head_turn = head ? (head.dig("metadata", "turn_count") || head.dig(:metadata, :turn_count)) : nil
1010
+ unchanged = head_messages.size == payload[:messages].size && head_turn == @turn_count
1011
+ @checkpoint_store.checkpoint(@id, payload) unless unchanged
1012
+ end
899
1013
  end
900
1014
 
901
1015
  def try_auto_meta_agent
@@ -11,12 +11,13 @@ module Ask
11
11
 
12
12
  attr_reader :total_executions
13
13
 
14
- def initialize(max_retries: 3, parallel: true, output_offload_threshold: nil, output_store: nil)
14
+ def initialize(max_retries: 3, parallel: true, output_offload_threshold: nil, output_store: nil, artifact_store: nil)
15
15
  @max_retries = max_retries
16
16
  @parallel = parallel
17
17
  @total_executions = 0
18
18
  @output_offload_threshold = output_offload_threshold
19
19
  @output_store = output_store
20
+ @artifact_store = artifact_store
20
21
  end
21
22
 
22
23
  attr_writer :telemetry
@@ -196,6 +197,19 @@ module Ask
196
197
  message = offload_message(message, tool_call.id)
197
198
  end
198
199
 
200
+ # Collect tool-produced deliverables (metadata[:artifact]) into the
201
+ # session's artifact store. Best-effort: a malformed artifact is
202
+ # noted in the message, never a tool failure.
203
+ if @artifact_store && result[:result].respond_to?(:metadata) &&
204
+ (artifact = result[:result].metadata[:artifact])
205
+ begin
206
+ attrs = symbolize_artifact(artifact)
207
+ @artifact_store.store(@session_id, **attrs)
208
+ rescue ArgumentError => e
209
+ message += "\n[artifact not stored: #{e.message}]"
210
+ end
211
+ end
212
+
199
213
  inner = result[:result]
200
214
  status = if result[:is_error] == true
201
215
  "error"
@@ -256,6 +270,17 @@ module Ask
256
270
  "#{preview}\n...(output truncated: #{message.length} chars — full output via output_read id: \"#{tool_call_id}\")"
257
271
  end
258
272
 
273
+ # Normalize an artifact hash from tool metadata (accepts string keys)
274
+ # to the store's keyword contract.
275
+ def symbolize_artifact(artifact)
276
+ {
277
+ filename: artifact[:filename] || artifact["filename"],
278
+ mime_type: artifact[:mime_type] || artifact["mime_type"],
279
+ content: artifact[:content] || artifact["content"],
280
+ uri: artifact[:uri] || artifact["uri"]
281
+ }
282
+ end
283
+
259
284
  def retryable_error_name?(error_name)
260
285
  return false unless error_name
261
286
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.36.0"
5
+ VERSION = "0.38.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -49,6 +49,7 @@ module Ask
49
49
  autoload :MemoryExtractor, "ask/agent/memory_extractor"
50
50
  autoload :ToolOutputStore, "ask/agent/tool_output_store"
51
51
  autoload :OutputRead, "ask/agent/output_read"
52
+ autoload :ArtifactStore, "ask/agent/artifact_store"
52
53
 
53
54
  module Middleware
54
55
  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.36.0
4
+ version: 0.38.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -165,6 +165,7 @@ files:
165
165
  - lib/ask-agent.rb
166
166
  - lib/ask/agent.rb
167
167
  - lib/ask/agent/approval_queue.rb
168
+ - lib/ask/agent/artifact_store.rb
168
169
  - lib/ask/agent/chat.rb
169
170
  - lib/ask/agent/checkpoint_store.rb
170
171
  - lib/ask/agent/cli.rb