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 +4 -4
- data/CHANGELOG.md +50 -0
- data/lib/ask/agent/artifact_store.rb +155 -0
- data/lib/ask/agent/loop.rb +7 -4
- data/lib/ask/agent/memory_extractor.rb +1 -2
- data/lib/ask/agent/session.rb +116 -2
- 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,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
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -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.
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|