ask-agent 0.37.0 → 0.39.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: e2b75f968977ca6b41df200a5dbba36cc73d799bc74e91ad3a201a3b9a2c78e7
4
+ data.tar.gz: 19fe24495b1f70b44a320c98b77ee8c2fdf9d83a5642e4bf58c071f8e16ec45c
5
5
  SHA512:
6
- metadata.gz: 2eaacd8c0a7a32392546ccde9d218d271afa22a3a2d1c154bfc3665691060b9cc4e7ab8d658c25eb2b2e79cccb1ad3bbbf10cd65e36c4324e2fc408f8eb8d973
7
- data.tar.gz: d9abb9f16f260d5ece0c403346ae0f1cb5a9edadb3e9fad0a2a56fd05c66748eb0070f16efa8441aa55275c5fc1aff724d43f0d4d6809751b932978272563fcf
6
+ metadata.gz: 5c9057c8b69cbe470beb46d9c656bf56ea15abf1d5328a93bc1a66cbef9c8f69f77aa0cfeb9f64cce264c60278f4df0c9290e3d89b71c42816ff2755d91b3e5e
7
+ data.tar.gz: 6df2b868b1430e00c9f2fab030ab48b1128de64b23a29b22f9c70963b977c777e20aaf75d5da529f772998116c453763b935ca92d4495efa21bd04efe309caf4
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
@@ -53,8 +53,9 @@ module Ask
53
53
  @prompt_caching = prompt_caching.nil? ? config.prompt_caching : prompt_caching
54
54
  end
55
55
 
56
- def ask(message = nil, &block)
57
- @messages << Ask::Message.new(role: :user, content: message.to_s) if message
56
+ def ask(message = nil, attachments: nil, &block)
57
+ validate_attachment_modalities!(attachments)
58
+ @messages << Ask::Message.new(role: :user, content: merge_attachments(message, attachments)) if message || attachments
58
59
 
59
60
  stream = block_given?
60
61
  tool_defs = @tools.map { |t| Ask::ToolDef.from_tool(t) }
@@ -79,10 +80,11 @@ module Ask
79
80
  response_msg
80
81
  end
81
82
 
82
- def add_message(role:, content: nil, tool_call_id: nil, tool_calls: nil)
83
+ def add_message(role:, content: nil, tool_call_id: nil, tool_calls: nil, attachments: nil)
84
+ validate_attachment_modalities!(attachments) if role == :user
83
85
  @messages << Ask::Message.new(
84
86
  role: role,
85
- content: content,
87
+ content: merge_attachments(content, attachments),
86
88
  tool_call_id: tool_call_id,
87
89
  tool_calls: tool_calls
88
90
  )
@@ -112,6 +114,42 @@ module Ask
112
114
 
113
115
  MAX_CHAT_RETRIES = 3
114
116
 
117
+ # Merge attachments into message content as content blocks: a plain
118
+ # string becomes a Text block followed by the attachment blocks;
119
+ # Array content has the blocks appended. Returns the content
120
+ # unchanged when there are no attachments.
121
+ def merge_attachments(content, attachments)
122
+ return content if attachments.nil? || attachments.empty?
123
+
124
+ blocks = content.is_a?(Array) ? content.dup : (content ? [Ask::Content::Text.new(content.to_s)] : [])
125
+ Ask::Attachment.wrap_all(attachments).each do |item|
126
+ blocks << (item.is_a?(Ask::Attachment) ? item.to_content : item)
127
+ end
128
+ blocks
129
+ end
130
+
131
+ # :inline attachments must be within the model's input modalities.
132
+ # :context attachments are plain text (a manifest line) and always
133
+ # supported. The check is skipped when the catalog has no modality
134
+ # info for the model.
135
+ def validate_attachment_modalities!(attachments)
136
+ return if attachments.nil? || attachments.empty?
137
+
138
+ inline = Ask::Attachment.wrap_all(attachments).select { |a| a.is_a?(Ask::Attachment) && a.inline? }
139
+ return if inline.empty?
140
+
141
+ modalities = @model_info.respond_to?(:modalities) ? @model_info.modalities : nil
142
+ supported = modalities.is_a?(Hash) ? Array(modalities[:input] || modalities["input"]) : []
143
+ return if supported.empty? || supported.include?("*")
144
+
145
+ unsupported = inline.reject { |a| supported.include?(a.type.to_s) }
146
+ return if unsupported.empty?
147
+
148
+ raise Ask::Agent::UnsupportedAttachmentError,
149
+ "Model #{@model_id} cannot receive #{unsupported.map(&:type).uniq.join(', ')} attachments " \
150
+ "(supported input modalities: #{supported.join(', ')})"
151
+ end
152
+
115
153
  def provider
116
154
  @test_provider || @provider ||= build_provider
117
155
  end
@@ -17,12 +17,12 @@ 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, steer_source: 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, attachments: nil)
21
21
  raise MaxTurnsExceeded if @turn_count >= @max_turns
22
22
 
23
23
  event_emitter.emit(Events::TurnStart.new)
24
24
 
25
- response = chat.ask(message) do |chunk|
25
+ response = chat.ask(message, attachments: attachments) do |chunk|
26
26
  if chunk.content.to_s.strip.length > 0
27
27
  event_emitter.emit(Events::TextDelta.new(content: chunk.content))
28
28
  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,8 +192,11 @@ 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
- def run(message, tools: nil, reset: true)
199
+ def run(message, tools: nil, reset: true, attachments: nil)
184
200
  raise "Session deleted" if @deleted
185
201
  raise "Session already running" if @running
186
202
 
@@ -214,6 +230,7 @@ module Ask
214
230
  response = @loop.run_turn(
215
231
  chat: @chat,
216
232
  message: message,
233
+ attachments: attachments,
217
234
  tools: active_tools,
218
235
  tool_executor: @tool_executor,
219
236
  compactor: @compactor,
@@ -427,7 +444,7 @@ module Ask
427
444
  data[:messages].each do |msg|
428
445
  session.chat.add_message(
429
446
  role: msg[:role].to_sym,
430
- content: msg[:content],
447
+ content: deserialize_content(msg[:content]),
431
448
  tool_call_id: msg[:tool_call_id]
432
449
  )
433
450
  end
@@ -441,6 +458,7 @@ module Ask
441
458
  @deleted = true
442
459
  @checkpoint_store&.delete(@id)
443
460
  @output_store&.delete(@id)
461
+ @artifact_store&.delete(@id)
444
462
  @state&.delete(@id)
445
463
  end
446
464
 
@@ -527,6 +545,24 @@ module Ask
527
545
  forked
528
546
  end
529
547
 
548
+ # --- Artifacts (tool deliverables) ---
549
+
550
+ # @return [Array<Hash>] artifact summaries for this session (id,
551
+ # filename, mime_type, size, uri), newest first
552
+ # @raise [RuntimeError] when artifacts are not enabled
553
+ def artifacts
554
+ require_artifacts!
555
+ @artifact_store.list(@id)
556
+ end
557
+
558
+ # @param id [String] artifact id
559
+ # @return [Hash, nil] the full record (content or uri)
560
+ # @raise [RuntimeError] when artifacts are not enabled
561
+ def fetch_artifact(id)
562
+ require_artifacts!
563
+ @artifact_store.fetch(@id, id)
564
+ end
565
+
530
566
  # --- Plan mode ---
531
567
 
532
568
  # Pop the next queued steer (called by the loop at each turn
@@ -627,8 +663,11 @@ module Ask
627
663
  # @param message [String]
628
664
  # @param expected_turn_id [Integer, nil] the turn id the caller
629
665
  # believes is current; nil skips the check
666
+ # @param attachments [Array<Ask::Attachment>, nil] files to attach
667
+ # (applied when the session is idle; queued steers keep the
668
+ # message text only — the loop resolves queued messages as text)
630
669
  # @return [Hash] {status: :stale|:queued|:steered, turn_id: Integer}
631
- def steer(message, expected_turn_id: nil)
670
+ def steer(message, expected_turn_id: nil, attachments: nil)
632
671
  @steer_mutex.synchronize do
633
672
  if expected_turn_id && expected_turn_id != @turn_id
634
673
  return { status: :stale, turn_id: @turn_id }
@@ -638,7 +677,7 @@ module Ask
638
677
  return { status: :queued, turn_id: @turn_id }
639
678
  end
640
679
  end
641
- @chat.add_message(role: :user, content: message.to_s)
680
+ @chat.add_message(role: :user, content: message.to_s, attachments: attachments)
642
681
  { status: :steered, turn_id: @turn_id }
643
682
  end
644
683
 
@@ -884,6 +923,10 @@ module Ask
884
923
  compactor
885
924
  end
886
925
 
926
+ def require_artifacts!
927
+ raise "artifacts are not enabled (pass artifacts: true)" unless @artifact_store
928
+ end
929
+
887
930
  def require_checkpoints!
888
931
  raise "checkpointing is not enabled (pass state: and checkpoints: true)" unless @checkpoints
889
932
  end
@@ -920,7 +963,7 @@ module Ask
920
963
  data[:messages].each do |msg|
921
964
  target.chat.add_message(
922
965
  role: msg[:role].to_sym,
923
- content: msg[:content],
966
+ content: self.class.deserialize_content(msg[:content]),
924
967
  tool_call_id: msg[:tool_call_id]
925
968
  )
926
969
  end
@@ -938,13 +981,26 @@ module Ask
938
981
  @tools.reject { |t| t.is_a?(Ask::Skills::LoadSkillTool) }
939
982
  end
940
983
 
984
+ # Persist content blocks as their +to_h+ hashes so attachments
985
+ # survive save/load; plain messages stay strings.
986
+ def self.serialize_content(message)
987
+ message.content_blocks ? message.content_blocks.map(&:to_h) : message.content.to_s
988
+ end
989
+
990
+ # Rebuild message content from a persisted value: block hashes are
991
+ # reconstructed via Ask::Content.from_h (deep_symbolize_keys may have
992
+ # symbol keys — from_h normalizes).
993
+ def self.deserialize_content(content)
994
+ content.is_a?(Array) ? content.map { |block| Ask::Content.from_h(block) } : content
995
+ end
996
+
941
997
  def persist!
942
998
  payload = {
943
999
  id: @id,
944
1000
  messages: @chat.messages.map { |m|
945
1001
  {
946
1002
  role: m.role,
947
- content: m.content.to_s,
1003
+ content: self.class.serialize_content(m),
948
1004
  tool_call_id: m.tool_call_id,
949
1005
  created_at: Time.now.iso8601
950
1006
  }
@@ -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.39.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -18,6 +18,7 @@ module Ask
18
18
  class ToolExecutionError < Error; end
19
19
  class CompactionFailed < Error; end
20
20
  class SessionNotPersisted < Error; end
21
+ class UnsupportedAttachmentError < Error; end
21
22
 
22
23
  class UnknownAgent < Error; end
23
24
 
@@ -49,6 +50,7 @@ module Ask
49
50
  autoload :MemoryExtractor, "ask/agent/memory_extractor"
50
51
  autoload :ToolOutputStore, "ask/agent/tool_output_store"
51
52
  autoload :OutputRead, "ask/agent/output_read"
53
+ autoload :ArtifactStore, "ask/agent/artifact_store"
52
54
 
53
55
  module Middleware
54
56
  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.39.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