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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/ask/agent/artifact_store.rb +155 -0
- data/lib/ask/agent/session.rb +40 -1
- data/lib/ask/agent/tool_executor.rb +26 -1
- 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: f3486ed15f739d216a4b0dd46e8efdb29cc6cc7689a70ea4a2a6ef5477444037
|
|
4
|
+
data.tar.gz: 628f14e1905695cf814eb3ff49012f924d30ce13c92c25ef425ec695dc37502f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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,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
|
|
data/lib/ask/agent/version.rb
CHANGED
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.
|
|
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
|