ask-agent 0.35.0 → 0.37.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 +47 -0
- data/lib/ask/agent/loop.rb +8 -4
- data/lib/ask/agent/memory_extractor.rb +1 -2
- data/lib/ask/agent/output_read.rb +36 -0
- data/lib/ask/agent/session.rb +103 -3
- data/lib/ask/agent/tool_executor.rb +21 -1
- data/lib/ask/agent/tool_output_store.rb +94 -0
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e22262139bd8142b9e45f22e831d59360f9465aad8fea93247352591b742d6c3
|
|
4
|
+
data.tar.gz: 1486719187105e25f794bff150fd9aa59e902096a40c15fdb822b2e7061d9bac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2eaacd8c0a7a32392546ccde9d218d271afa22a3a2d1c154bfc3665691060b9cc4e7ab8d658c25eb2b2e79cccb1ad3bbbf10cd65e36c4324e2fc408f8eb8d973
|
|
7
|
+
data.tar.gz: d9abb9f16f260d5ece0c403346ae0f1cb5a9edadb3e9fad0a2a56fd05c66748eb0070f16efa8441aa55275c5fc1aff724d43f0d4d6809751b932978272563fcf
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
## [0.37.0] — 2026-08-07
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Steer — concurrency-safe message injection.**
|
|
6
|
+
`Session#steer(message, expected_turn_id:)` lets any thread (web, CLI,
|
|
7
|
+
another agent) inject a message safely:
|
|
8
|
+
- **`:stale`** — `expected_turn_id` doesn't match the current turn id
|
|
9
|
+
(the caller was looking at an older state); the message is rejected.
|
|
10
|
+
- **`:queued`** — a turn is running; the message is held and dispatched
|
|
11
|
+
as the next user message at the next turn boundary (the loop now
|
|
12
|
+
resolves each recursive turn's message from a steer source). No more
|
|
13
|
+
abort-and-retry.
|
|
14
|
+
- **`:steered`** — the session is idle; the message enters the
|
|
15
|
+
conversation and the next run processes it. Queued leftovers drain at
|
|
16
|
+
the next run start.
|
|
17
|
+
- `Session#turn_id` tracks the running turn (bumped on `TurnStart`);
|
|
18
|
+
`Session#queued_steers` reports pending messages.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **No more duplicate tail checkpoints.** The loop persists after every
|
|
23
|
+
turn and `run()` persists again on the way out, so every run previously
|
|
24
|
+
appended a redundant checkpoint (seqs 1,2,3 for two turns). `persist!`
|
|
25
|
+
now skips the checkpoint when the message count and turn count are
|
|
26
|
+
unchanged — `checkpoint_history` is exact.
|
|
27
|
+
|
|
28
|
+
## [0.36.0] — 2026-08-07
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **Large-output offloading — tool results never bloat the transcript.**
|
|
33
|
+
`Session.new(offload_large_outputs: true)` (or an Integer threshold,
|
|
34
|
+
default 4000 chars) stores tool messages above the threshold in a
|
|
35
|
+
state-backed store; the transcript keeps a short preview plus a reference
|
|
36
|
+
the model retrieves with the injected `output_read` tool:
|
|
37
|
+
- `Ask::Agent::ToolOutputStore` — pure KV on the same
|
|
38
|
+
`Ask::State::Adapter` as sessions/checkpoints/memory
|
|
39
|
+
(`output:<session_id>:<call_id>` + JSON index), works with every
|
|
40
|
+
backend; in-process Memory fallback when no `state:` is given. Stored
|
|
41
|
+
outputs are capped (`max_size:`, default 50,000 chars).
|
|
42
|
+
- `output_read` is exempt from offloading — its contract is to bring the
|
|
43
|
+
full output into context on demand.
|
|
44
|
+
- `Session#delete` cleans up the session's stored outputs.
|
|
45
|
+
- The loop now passes `session_id` to the tool executor (previously nil),
|
|
46
|
+
which offloading relies on.
|
|
47
|
+
|
|
1
48
|
## [0.35.0] — 2026-08-07
|
|
2
49
|
|
|
3
50
|
### Added
|
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)
|
|
@@ -99,6 +99,7 @@ module Ask
|
|
|
99
99
|
# when the background work completes.
|
|
100
100
|
user_results = tool_executor.execute(
|
|
101
101
|
user_tool_calls, tools, hooks: hooks, event_emitter: event_emitter,
|
|
102
|
+
session_id: session_id,
|
|
102
103
|
result_callback: lambda do |tool_call_id, result|
|
|
103
104
|
tc = user_tool_calls[tool_call_id]
|
|
104
105
|
next unless tc
|
|
@@ -156,10 +157,12 @@ module Ask
|
|
|
156
157
|
# Aborted while tools ran? Skip the follow-up LLM call.
|
|
157
158
|
return response.content.to_s if aborted?(event_emitter)
|
|
158
159
|
|
|
159
|
-
# 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.
|
|
160
163
|
run_turn(
|
|
161
164
|
chat: chat,
|
|
162
|
-
message: "",
|
|
165
|
+
message: steer_source ? steer_source.call.to_s : "",
|
|
163
166
|
tools: tools,
|
|
164
167
|
tool_executor: tool_executor,
|
|
165
168
|
compactor: compactor,
|
|
@@ -167,7 +170,8 @@ module Ask
|
|
|
167
170
|
event_emitter: event_emitter,
|
|
168
171
|
session_id: session_id,
|
|
169
172
|
persist: persist,
|
|
170
|
-
tool_call_repair: tool_call_repair
|
|
173
|
+
tool_call_repair: tool_call_repair,
|
|
174
|
+
steer_source: steer_source
|
|
171
175
|
)
|
|
172
176
|
end
|
|
173
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.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ask/tools/tool"
|
|
4
|
+
require "ask/result"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Agent
|
|
8
|
+
# Tool that retrieves an offloaded tool output by call id — the other
|
|
9
|
+
# half of large-output offloading. The transcript keeps a preview plus a
|
|
10
|
+
# reference ("output_read id: \"call_123\""); this tool fetches the full
|
|
11
|
+
# output from the session's {ToolOutputStore}.
|
|
12
|
+
#
|
|
13
|
+
# Injected into the session when large-output offloading is enabled.
|
|
14
|
+
class OutputRead < Ask::Tool
|
|
15
|
+
description "Retrieve the full output of a tool call that was truncated in the conversation. " \
|
|
16
|
+
"Use the id from the truncation note (e.g. output_read id: \"call_123\")."
|
|
17
|
+
|
|
18
|
+
param :id, type: :string, desc: "Tool call id from the truncation note", required: true
|
|
19
|
+
|
|
20
|
+
# @param store [Ask::Agent::ToolOutputStore]
|
|
21
|
+
# @param session_id [String] scopes lookups to this session
|
|
22
|
+
def initialize(store:, session_id:)
|
|
23
|
+
@store = store
|
|
24
|
+
@session_id = session_id
|
|
25
|
+
super()
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def execute(id:)
|
|
29
|
+
output = @store.fetch(@session_id, id)
|
|
30
|
+
return Ask::Result.error(message: "No stored output for id #{id.inspect}") if output.nil?
|
|
31
|
+
|
|
32
|
+
Ask::Result.ok(data: output)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -25,7 +25,8 @@ module Ask
|
|
|
25
25
|
skills_disclosure: true, approval: nil,
|
|
26
26
|
tool_call_repair: nil, checkpoints: false,
|
|
27
27
|
todos: false, plan_mode: false, memory: nil,
|
|
28
|
-
memory_learning: false,
|
|
28
|
+
memory_learning: false, offload_large_outputs: false,
|
|
29
|
+
**chat_options)
|
|
29
30
|
@id = id || SecureRandom.uuid
|
|
30
31
|
@agent_dir = agent_dir
|
|
31
32
|
@max_turns = max_turns
|
|
@@ -40,6 +41,13 @@ module Ask
|
|
|
40
41
|
@pending_mutex = Mutex.new
|
|
41
42
|
@followup_pending = false
|
|
42
43
|
@turn_count = 0
|
|
44
|
+
# Concurrency-safe steering: turn id bumped at every
|
|
45
|
+
# TurnStart; steers arriving mid-turn are queued and dispatched at
|
|
46
|
+
# the next turn boundary.
|
|
47
|
+
@turn_id = 0
|
|
48
|
+
@queued_steers = []
|
|
49
|
+
@steer_mutex = Mutex.new
|
|
50
|
+
on(Events::TurnStart) { @turn_id += 1 }
|
|
43
51
|
@created_at = Time.now
|
|
44
52
|
@_no_tools_instructed = false
|
|
45
53
|
|
|
@@ -65,6 +73,18 @@ module Ask
|
|
|
65
73
|
end
|
|
66
74
|
@memory_learning = !!memory_learning
|
|
67
75
|
|
|
76
|
+
# Large-output offloading: tool results above a size threshold are
|
|
77
|
+
# stored in a ToolOutputStore (state adapter when present, else
|
|
78
|
+
# in-process) and the transcript keeps a preview + reference.
|
|
79
|
+
@offload_threshold = case offload_large_outputs
|
|
80
|
+
when true then 4000
|
|
81
|
+
when Integer then offload_large_outputs
|
|
82
|
+
else nil
|
|
83
|
+
end
|
|
84
|
+
@output_store = if @offload_threshold
|
|
85
|
+
ToolOutputStore.new(state: state || persistence || Ask::State::Memory.new)
|
|
86
|
+
end
|
|
87
|
+
|
|
68
88
|
# Plan mode — research phase gated to read-only tools until a human
|
|
69
89
|
# approves the model's plan (submitted via the exit_plan_mode tool).
|
|
70
90
|
@plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
|
|
@@ -81,7 +101,12 @@ module Ask
|
|
|
81
101
|
@tools = resolve_tools(tools)
|
|
82
102
|
@chat = build_chat(model, system_prompt, @tools, **chat_options)
|
|
83
103
|
@loop = Loop.new(max_turns: max_turns)
|
|
84
|
-
@tool_executor = ToolExecutor.new(
|
|
104
|
+
@tool_executor = ToolExecutor.new(
|
|
105
|
+
max_retries: max_tool_retries,
|
|
106
|
+
parallel: parallel_tools,
|
|
107
|
+
output_offload_threshold: @offload_threshold,
|
|
108
|
+
output_store: @output_store
|
|
109
|
+
)
|
|
85
110
|
@compactor = compactor ? build_compactor(compactor) : nil
|
|
86
111
|
@hooks = Hooks.new(hooks)
|
|
87
112
|
@audit_log = build_audit_log(audit_log)
|
|
@@ -151,6 +176,9 @@ module Ask
|
|
|
151
176
|
# @return [Ask::Agent::Memory, nil] durable memory (only when passed
|
|
152
177
|
# via the +memory:+ option)
|
|
153
178
|
attr_reader :memory
|
|
179
|
+
# @return [Ask::Agent::ToolOutputStore, nil] store for offloaded large
|
|
180
|
+
# tool outputs (only when large-output offloading is enabled)
|
|
181
|
+
attr_reader :output_store
|
|
154
182
|
|
|
155
183
|
def run(message, tools: nil, reset: true)
|
|
156
184
|
raise "Session deleted" if @deleted
|
|
@@ -176,6 +204,10 @@ module Ask
|
|
|
176
204
|
@_no_tools_instructed = true
|
|
177
205
|
end
|
|
178
206
|
|
|
207
|
+
# Leftover queued steers from a previous run become user messages
|
|
208
|
+
# before this run starts.
|
|
209
|
+
drain_leftover_steers
|
|
210
|
+
|
|
179
211
|
begin
|
|
180
212
|
@tool_executor.telemetry = @telemetry
|
|
181
213
|
|
|
@@ -189,6 +221,7 @@ module Ask
|
|
|
189
221
|
event_emitter: self,
|
|
190
222
|
session_id: @id,
|
|
191
223
|
tool_call_repair: @tool_call_repair,
|
|
224
|
+
steer_source: method(:drain_one_steer),
|
|
192
225
|
persist: @state ? method(:persist!) : nil
|
|
193
226
|
)
|
|
194
227
|
|
|
@@ -407,6 +440,7 @@ module Ask
|
|
|
407
440
|
def delete
|
|
408
441
|
@deleted = true
|
|
409
442
|
@checkpoint_store&.delete(@id)
|
|
443
|
+
@output_store&.delete(@id)
|
|
410
444
|
@state&.delete(@id)
|
|
411
445
|
end
|
|
412
446
|
|
|
@@ -495,6 +529,20 @@ module Ask
|
|
|
495
529
|
|
|
496
530
|
# --- Plan mode ---
|
|
497
531
|
|
|
532
|
+
# Pop the next queued steer (called by the loop at each turn
|
|
533
|
+
# boundary); returns "" when nothing is queued.
|
|
534
|
+
def drain_one_steer
|
|
535
|
+
@steer_mutex.synchronize { @queued_steers.shift }.to_s
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Move any queued steers left over from a previous run into the
|
|
539
|
+
# conversation (the session was idle, so they are dispatched now).
|
|
540
|
+
def drain_leftover_steers
|
|
541
|
+
while (message = drain_one_steer) != ""
|
|
542
|
+
@chat.add_message(role: :user, content: message)
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
498
546
|
# Extract durable facts from this session's transcript into memory
|
|
499
547
|
# (memory_learning: true). Best-effort — extraction never breaks the
|
|
500
548
|
# session; failures are swallowed.
|
|
@@ -560,6 +608,45 @@ module Ask
|
|
|
560
608
|
)
|
|
561
609
|
end
|
|
562
610
|
|
|
611
|
+
# --- Steer (concurrency-safe message injection) ---
|
|
612
|
+
|
|
613
|
+
# @return [Integer] id of the turn currently running (or the last
|
|
614
|
+
# completed turn when idle)
|
|
615
|
+
attr_reader :turn_id
|
|
616
|
+
|
|
617
|
+
# Inject a message into the session safely, from any thread (web, CLI,
|
|
618
|
+
# another agent):
|
|
619
|
+
#
|
|
620
|
+
# - **:stale** — the caller's `expected_turn_id` does not match the
|
|
621
|
+
# current turn id (the caller was looking at an older state).
|
|
622
|
+
# - **:queued** — a turn is running; the message is held and dispatched
|
|
623
|
+
# as the next user message at the next turn boundary.
|
|
624
|
+
# - **:steered** — the session is idle; the message is added to the
|
|
625
|
+
# conversation and processed by the next run.
|
|
626
|
+
#
|
|
627
|
+
# @param message [String]
|
|
628
|
+
# @param expected_turn_id [Integer, nil] the turn id the caller
|
|
629
|
+
# believes is current; nil skips the check
|
|
630
|
+
# @return [Hash] {status: :stale|:queued|:steered, turn_id: Integer}
|
|
631
|
+
def steer(message, expected_turn_id: nil)
|
|
632
|
+
@steer_mutex.synchronize do
|
|
633
|
+
if expected_turn_id && expected_turn_id != @turn_id
|
|
634
|
+
return { status: :stale, turn_id: @turn_id }
|
|
635
|
+
end
|
|
636
|
+
if @running
|
|
637
|
+
@queued_steers << message.to_s
|
|
638
|
+
return { status: :queued, turn_id: @turn_id }
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
@chat.add_message(role: :user, content: message.to_s)
|
|
642
|
+
{ status: :steered, turn_id: @turn_id }
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# @return [Integer] steers queued and not yet dispatched
|
|
646
|
+
def queued_steers
|
|
647
|
+
@steer_mutex.synchronize { @queued_steers.size }
|
|
648
|
+
end
|
|
649
|
+
|
|
563
650
|
# --- Async (pending) tools ---
|
|
564
651
|
|
|
565
652
|
# Registers a pending tool call (called by the loop when a tool
|
|
@@ -772,6 +859,9 @@ module Ask
|
|
|
772
859
|
resolved << MemoryWrite.new(memory: @memory, session_id: @id) unless resolved.any? { |t| t.name == "memory_write" }
|
|
773
860
|
resolved << MemorySearch.new(memory: @memory) unless resolved.any? { |t| t.name == "memory_search" }
|
|
774
861
|
end
|
|
862
|
+
if @output_store
|
|
863
|
+
resolved << OutputRead.new(store: @output_store, session_id: @id) unless resolved.any? { |t| t.name == "output_read" }
|
|
864
|
+
end
|
|
775
865
|
resolved
|
|
776
866
|
end
|
|
777
867
|
|
|
@@ -870,7 +960,17 @@ module Ask
|
|
|
870
960
|
}
|
|
871
961
|
}
|
|
872
962
|
@state.set(@id, payload)
|
|
873
|
-
|
|
963
|
+
# Checkpoint only when the conversation actually changed since the
|
|
964
|
+
# last one: the loop persists after every turn and run() persists
|
|
965
|
+
# again on the way out, so without this check every run would append
|
|
966
|
+
# a duplicate tail checkpoint.
|
|
967
|
+
if @checkpoints
|
|
968
|
+
head = @checkpoint_store.load(@id)
|
|
969
|
+
head_messages = head ? (head["messages"] || head[:messages] || []) : []
|
|
970
|
+
head_turn = head ? (head.dig("metadata", "turn_count") || head.dig(:metadata, :turn_count)) : nil
|
|
971
|
+
unchanged = head_messages.size == payload[:messages].size && head_turn == @turn_count
|
|
972
|
+
@checkpoint_store.checkpoint(@id, payload) unless unchanged
|
|
973
|
+
end
|
|
874
974
|
end
|
|
875
975
|
|
|
876
976
|
def try_auto_meta_agent
|
|
@@ -11,10 +11,12 @@ module Ask
|
|
|
11
11
|
|
|
12
12
|
attr_reader :total_executions
|
|
13
13
|
|
|
14
|
-
def initialize(max_retries: 3, parallel: true)
|
|
14
|
+
def initialize(max_retries: 3, parallel: true, output_offload_threshold: nil, output_store: nil)
|
|
15
15
|
@max_retries = max_retries
|
|
16
16
|
@parallel = parallel
|
|
17
17
|
@total_executions = 0
|
|
18
|
+
@output_offload_threshold = output_offload_threshold
|
|
19
|
+
@output_store = output_store
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
attr_writer :telemetry
|
|
@@ -185,6 +187,15 @@ module Ask
|
|
|
185
187
|
result[:result].to_s
|
|
186
188
|
end
|
|
187
189
|
|
|
190
|
+
# Large outputs never enter the transcript: store the full message
|
|
191
|
+
# and keep a short preview plus a reference the model can retrieve
|
|
192
|
+
# with the output_read tool. output_read's own result is exempt —
|
|
193
|
+
# its contract is to bring the full output into context on demand.
|
|
194
|
+
if @output_offload_threshold && message.length > @output_offload_threshold &&
|
|
195
|
+
tool_call.name != "output_read"
|
|
196
|
+
message = offload_message(message, tool_call.id)
|
|
197
|
+
end
|
|
198
|
+
|
|
188
199
|
inner = result[:result]
|
|
189
200
|
status = if result[:is_error] == true
|
|
190
201
|
"error"
|
|
@@ -236,6 +247,15 @@ module Ask
|
|
|
236
247
|
{ result: e.message, is_error: true, error: e.class.name }
|
|
237
248
|
end
|
|
238
249
|
|
|
250
|
+
# Store a large tool message in the output store and return a short
|
|
251
|
+
# preview that references it, so the transcript never carries the full
|
|
252
|
+
# output.
|
|
253
|
+
def offload_message(message, tool_call_id)
|
|
254
|
+
@output_store.store(@session_id, tool_call_id, message)
|
|
255
|
+
preview = message[0, 300]
|
|
256
|
+
"#{preview}\n...(output truncated: #{message.length} chars — full output via output_read id: \"#{tool_call_id}\")"
|
|
257
|
+
end
|
|
258
|
+
|
|
239
259
|
def retryable_error_name?(error_name)
|
|
240
260
|
return false unless error_name
|
|
241
261
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Agent
|
|
7
|
+
# State-backed storage for large tool outputs, keeping them out of the
|
|
8
|
+
# conversation transcript.
|
|
9
|
+
#
|
|
10
|
+
# When a tool result exceeds the session's offload threshold, the
|
|
11
|
+
# executor stores the full output here and the transcript keeps a short
|
|
12
|
+
# preview plus a reference the model can retrieve with the output_read
|
|
13
|
+
# tool (and the web UI can fetch from the same store).
|
|
14
|
+
#
|
|
15
|
+
# Storage shape (pure KV — works with every Ask::State::Adapter backend
|
|
16
|
+
# including custom get/set/delete adapters):
|
|
17
|
+
# output:<session_id>:<call_id> — one key per offloaded output
|
|
18
|
+
# output:<session_id>:index — JSON array of call ids (write order)
|
|
19
|
+
#
|
|
20
|
+
# store = Ask::Agent::ToolOutputStore.new(state: adapter)
|
|
21
|
+
# store.store(session_id, "call_1", huge_output)
|
|
22
|
+
# store.fetch(session_id, "call_1") # => huge_output
|
|
23
|
+
# store.delete(session_id) # session cleanup
|
|
24
|
+
class ToolOutputStore
|
|
25
|
+
KEY_PREFIX = "output:"
|
|
26
|
+
INDEX_SUFFIX = ":index"
|
|
27
|
+
|
|
28
|
+
# @param state [Ask::State::Adapter] backing store
|
|
29
|
+
# @param max_size [Integer] stored outputs are truncated to this many
|
|
30
|
+
# characters (with a truncation marker)
|
|
31
|
+
def initialize(state:, max_size: 50_000)
|
|
32
|
+
@state = state
|
|
33
|
+
@max_size = max_size
|
|
34
|
+
@mutex = Monitor.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Ask::State::Adapter] the underlying adapter
|
|
38
|
+
attr_reader :state
|
|
39
|
+
|
|
40
|
+
# Store an output for a tool call (idempotent per call id — a later
|
|
41
|
+
# store with the same call id replaces the earlier one).
|
|
42
|
+
#
|
|
43
|
+
# @param session_id [String]
|
|
44
|
+
# @param call_id [String]
|
|
45
|
+
# @param content [String]
|
|
46
|
+
# @return [String] the stored content (possibly truncated)
|
|
47
|
+
def store(session_id, call_id, content)
|
|
48
|
+
stored = content.to_s
|
|
49
|
+
stored = "#{stored[0, @max_size]}\n...(output truncated)" if stored.length > @max_size
|
|
50
|
+
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@state.set(entry_key(session_id, call_id), stored)
|
|
53
|
+
index = load_index(session_id)
|
|
54
|
+
@state.set(index_key(session_id), (index + [call_id]).uniq.to_json)
|
|
55
|
+
end
|
|
56
|
+
stored
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param session_id [String]
|
|
60
|
+
# @param call_id [String]
|
|
61
|
+
# @return [String, nil] the stored output, or nil when absent
|
|
62
|
+
def fetch(session_id, call_id)
|
|
63
|
+
@state.get(entry_key(session_id, call_id))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Remove every output for a session (called by Session#delete).
|
|
67
|
+
#
|
|
68
|
+
# @param session_id [String]
|
|
69
|
+
# @return [void]
|
|
70
|
+
def delete(session_id)
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
load_index(session_id).each { |call_id| @state.delete(entry_key(session_id, call_id)) }
|
|
73
|
+
@state.delete(index_key(session_id))
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def load_index(session_id)
|
|
81
|
+
raw = @state.get(index_key(session_id))
|
|
82
|
+
raw ? JSON.parse(raw) : []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def entry_key(session_id, call_id)
|
|
86
|
+
"#{KEY_PREFIX}#{session_id}:#{call_id}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def index_key(session_id)
|
|
90
|
+
"#{KEY_PREFIX}#{session_id}#{INDEX_SUFFIX}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -47,6 +47,8 @@ module Ask
|
|
|
47
47
|
autoload :MemoryWrite, "ask/agent/memory_write"
|
|
48
48
|
autoload :MemorySearch, "ask/agent/memory_search"
|
|
49
49
|
autoload :MemoryExtractor, "ask/agent/memory_extractor"
|
|
50
|
+
autoload :ToolOutputStore, "ask/agent/tool_output_store"
|
|
51
|
+
autoload :OutputRead, "ask/agent/output_read"
|
|
50
52
|
|
|
51
53
|
module Middleware
|
|
52
54
|
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.37.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -189,6 +189,7 @@ files:
|
|
|
189
189
|
- lib/ask/agent/middleware/model_fallback.rb
|
|
190
190
|
- lib/ask/agent/middleware/pipeline.rb
|
|
191
191
|
- lib/ask/agent/middleware/retry_on_failure.rb
|
|
192
|
+
- lib/ask/agent/output_read.rb
|
|
192
193
|
- lib/ask/agent/persistence/base.rb
|
|
193
194
|
- lib/ask/agent/persistence/in_memory.rb
|
|
194
195
|
- lib/ask/agent/policies/approval_policy.rb
|
|
@@ -215,6 +216,7 @@ files:
|
|
|
215
216
|
- lib/ask/agent/tool_abort_controller.rb
|
|
216
217
|
- lib/ask/agent/tool_call_repair.rb
|
|
217
218
|
- lib/ask/agent/tool_executor.rb
|
|
219
|
+
- lib/ask/agent/tool_output_store.rb
|
|
218
220
|
- lib/ask/agent/version.rb
|
|
219
221
|
homepage: https://github.com/ask-rb/ask-agent
|
|
220
222
|
licenses:
|