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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/ask/agent/artifact_store.rb +155 -0
- data/lib/ask/agent/chat.rb +42 -4
- data/lib/ask/agent/loop.rb +2 -2
- data/lib/ask/agent/session.rb +63 -7
- data/lib/ask/agent/tool_executor.rb +26 -1
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +2 -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: e2b75f968977ca6b41df200a5dbba36cc73d799bc74e91ad3a201a3b9a2c78e7
|
|
4
|
+
data.tar.gz: 19fe24495b1f70b44a320c98b77ee8c2fdf9d83a5642e4bf58c071f8e16ec45c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ask/agent/chat.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -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
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -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:
|
|
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
|
|
data/lib/ask/agent/version.rb
CHANGED
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.
|
|
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
|