ask-agent 0.37.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: e22262139bd8142b9e45f22e831d59360f9465aad8fea93247352591b742d6c3
4
- data.tar.gz: 1486719187105e25f794bff150fd9aa59e902096a40c15fdb822b2e7061d9bac
3
+ metadata.gz: f3486ed15f739d216a4b0dd46e8efdb29cc6cc7689a70ea4a2a6ef5477444037
4
+ data.tar.gz: 628f14e1905695cf814eb3ff49012f924d30ce13c92c25ef425ec695dc37502f
5
5
  SHA512:
6
- metadata.gz: 2eaacd8c0a7a32392546ccde9d218d271afa22a3a2d1c154bfc3665691060b9cc4e7ab8d658c25eb2b2e79cccb1ad3bbbf10cd65e36c4324e2fc408f8eb8d973
7
- data.tar.gz: d9abb9f16f260d5ece0c403346ae0f1cb5a9edadb3e9fad0a2a56fd05c66748eb0070f16efa8441aa55275c5fc1aff724d43f0d4d6809751b932978272563fcf
6
+ metadata.gz: 7cd3f07a7dbcaa19379815eb1999f225bef3baa938ab34e018c0d1c0d755edbd5e78ba67881f65098e4b3a59b2f6696e75e1a6bfa2bba3ba17c9cb151918c9ae
7
+ data.tar.gz: 8644f8522aa5ea4719bb6e02d6dc19ce85d66f2e5e53765da02fcb8a002c67540f60ce31f787abb091a4e76b80a83fdb82d538b2866506385fa8c66e94f54d4a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
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
+
1
24
  ## [0.37.0] — 2026-08-07
2
25
 
3
26
  ### 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
@@ -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
@@ -85,6 +86,17 @@ module Ask
85
86
  ToolOutputStore.new(state: state || persistence || Ask::State::Memory.new)
86
87
  end
87
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
+
88
100
  # Plan mode — research phase gated to read-only tools until a human
89
101
  # approves the model's plan (submitted via the exit_plan_mode tool).
90
102
  @plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
@@ -105,7 +117,8 @@ module Ask
105
117
  max_retries: max_tool_retries,
106
118
  parallel: parallel_tools,
107
119
  output_offload_threshold: @offload_threshold,
108
- output_store: @output_store
120
+ output_store: @output_store,
121
+ artifact_store: @artifact_store
109
122
  )
110
123
  @compactor = compactor ? build_compactor(compactor) : nil
111
124
  @hooks = Hooks.new(hooks)
@@ -179,6 +192,9 @@ module Ask
179
192
  # @return [Ask::Agent::ToolOutputStore, nil] store for offloaded large
180
193
  # tool outputs (only when large-output offloading is enabled)
181
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
182
198
 
183
199
  def run(message, tools: nil, reset: true)
184
200
  raise "Session deleted" if @deleted
@@ -441,6 +457,7 @@ module Ask
441
457
  @deleted = true
442
458
  @checkpoint_store&.delete(@id)
443
459
  @output_store&.delete(@id)
460
+ @artifact_store&.delete(@id)
444
461
  @state&.delete(@id)
445
462
  end
446
463
 
@@ -527,6 +544,24 @@ module Ask
527
544
  forked
528
545
  end
529
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
+
530
565
  # --- Plan mode ---
531
566
 
532
567
  # Pop the next queued steer (called by the loop at each turn
@@ -884,6 +919,10 @@ module Ask
884
919
  compactor
885
920
  end
886
921
 
922
+ def require_artifacts!
923
+ raise "artifacts are not enabled (pass artifacts: true)" unless @artifact_store
924
+ end
925
+
887
926
  def require_checkpoints!
888
927
  raise "checkpointing is not enabled (pass state: and checkpoints: true)" unless @checkpoints
889
928
  end
@@ -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.37.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.37.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