ask-session 0.1.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 +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +220 -0
- data/lib/ask/session/codec.rb +55 -0
- data/lib/ask/session/event.rb +85 -0
- data/lib/ask/session/host.rb +162 -0
- data/lib/ask/session/provider_store.rb +316 -0
- data/lib/ask/session/record.rb +95 -0
- data/lib/ask/session/sink.rb +189 -0
- data/lib/ask/session/state.rb +54 -0
- data/lib/ask/session/store.rb +154 -0
- data/lib/ask/session/subscription.rb +56 -0
- data/lib/ask/session/version.rb +7 -0
- data/lib/ask/session.rb +28 -0
- data/lib/ask-session.rb +3 -0
- metadata +90 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Session
|
|
5
|
+
# Durable Store implementation backed by a generic ask-state-providers
|
|
6
|
+
# adapter (get/set/delete). Each session's record and event list persist
|
|
7
|
+
# under namespaced keys so sessions survive process restarts.
|
|
8
|
+
#
|
|
9
|
+
# Serialization is JSON with symbol-safe encoding: symbol values (status,
|
|
10
|
+
# payload symbols) round-trip exactly, and Record/Event from_h restore
|
|
11
|
+
# keys, timestamps, and nested structures.
|
|
12
|
+
#
|
|
13
|
+
# Concurrency: every public operation runs under an in-process mutex and,
|
|
14
|
+
# when the adapter exposes the provider lock API (acquire_lock /
|
|
15
|
+
# release_lock), under a cross-process store lock so read-modify-write
|
|
16
|
+
# sequences (index updates, optimistic event appends) stay atomic across
|
|
17
|
+
# processes and adapter connections. Adapters without lock methods fall
|
|
18
|
+
# back to the in-process mutex alone. Stale expected_sequence writers
|
|
19
|
+
# still fail with ConcurrencyError — locks serialize, they do not merge.
|
|
20
|
+
class ProviderStore
|
|
21
|
+
RECORD_PREFIX = "ask.session:record:"
|
|
22
|
+
EVENTS_PREFIX = "ask.session:events:"
|
|
23
|
+
INDEX_KEY = "ask.session:index"
|
|
24
|
+
STORE_LOCK_KEY = "ask.session:lock"
|
|
25
|
+
SYMBOL_TAG = "$ask_sym"
|
|
26
|
+
LOCK_TTL = 10
|
|
27
|
+
LOCK_TIMEOUT = 5
|
|
28
|
+
LOCK_RETRY_MIN_DELAY = 0.001
|
|
29
|
+
LOCK_RETRY_MAX_DELAY = 0.05
|
|
30
|
+
|
|
31
|
+
def initialize(adapter:, lock_ttl: LOCK_TTL, lock_timeout: LOCK_TIMEOUT)
|
|
32
|
+
unless adapter.respond_to?(:get) && adapter.respond_to?(:set) && adapter.respond_to?(:delete)
|
|
33
|
+
raise ArgumentError, "adapter must respond to get, set, and delete"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@adapter = adapter
|
|
37
|
+
@lock_ttl = lock_ttl
|
|
38
|
+
@lock_timeout = lock_timeout
|
|
39
|
+
@lockable = adapter.respond_to?(:acquire_lock) && adapter.respond_to?(:release_lock)
|
|
40
|
+
@mutex = Mutex.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def create(id: nil, status: :active, metadata: {}, created_at: nil)
|
|
44
|
+
record = Record.create(id: id, status: status, metadata: metadata, created_at: created_at)
|
|
45
|
+
with_store_lock do
|
|
46
|
+
if @adapter.get(record_key(record.id))
|
|
47
|
+
raise DuplicateSessionError, "Session already exists: #{record.id}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@adapter.set(record_key(record.id), dump(record.to_h))
|
|
51
|
+
@adapter.set(events_key(record.id), dump([]))
|
|
52
|
+
ids = read_index
|
|
53
|
+
ids << record.id
|
|
54
|
+
@adapter.set(INDEX_KEY, dump(ids))
|
|
55
|
+
end
|
|
56
|
+
record
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def load(id)
|
|
60
|
+
with_store_lock { read_record(id) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def load!(id)
|
|
64
|
+
load(id) || raise(NotFoundError, "Session not found: #{id}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def list
|
|
68
|
+
with_store_lock { read_index.filter_map { |id| read_record(id) } }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def append_event(event, expected_sequence:)
|
|
72
|
+
with_store_lock do
|
|
73
|
+
unless exists?(event.session_id)
|
|
74
|
+
raise NotFoundError, "Session not found: #{event.session_id}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
session_events = read_events(event.session_id)
|
|
78
|
+
current_seq = session_events.size
|
|
79
|
+
unless current_seq == expected_sequence
|
|
80
|
+
raise ConcurrencyError,
|
|
81
|
+
"Expected sequence #{expected_sequence} but got #{current_seq} for session #{event.session_id}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
unless event.seq == current_seq + 1
|
|
85
|
+
raise ConcurrencyError,
|
|
86
|
+
"Event seq #{event.seq} does not match expected next sequence #{current_seq + 1} for session #{event.session_id}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
session_events << event
|
|
90
|
+
write_events(event.session_id, session_events)
|
|
91
|
+
end
|
|
92
|
+
event
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def state(session_id)
|
|
96
|
+
State.reduce(session_id, events(session_id))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def events_after(session_id, after_seq:)
|
|
100
|
+
with_store_lock do
|
|
101
|
+
raise NotFoundError, "Session not found: #{session_id}" unless exists?(session_id)
|
|
102
|
+
|
|
103
|
+
read_events(session_id).select { |e| e.seq > after_seq }.freeze
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def current_sequence(session_id)
|
|
108
|
+
with_store_lock do
|
|
109
|
+
raise NotFoundError, "Session not found: #{session_id}" unless exists?(session_id)
|
|
110
|
+
|
|
111
|
+
read_events(session_id).size
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def events(session_id)
|
|
116
|
+
with_store_lock do
|
|
117
|
+
raise NotFoundError, "Session not found: #{session_id}" unless exists?(session_id)
|
|
118
|
+
|
|
119
|
+
read_events(session_id).dup.freeze
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def export(session_id = nil)
|
|
124
|
+
with_store_lock do
|
|
125
|
+
if session_id
|
|
126
|
+
record = read_record(session_id)
|
|
127
|
+
raise NotFoundError, "Session not found: #{session_id}" unless record
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
sessions: [record.to_h],
|
|
131
|
+
events: read_events(session_id).map(&:to_h)
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
records = read_index.filter_map { |id| read_record(id) }
|
|
135
|
+
{
|
|
136
|
+
sessions: records.map(&:to_h),
|
|
137
|
+
events: records.flat_map { |r| read_events(r.id).map(&:to_h) }
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def import(data)
|
|
144
|
+
sessions = data[:sessions] || data["sessions"] || []
|
|
145
|
+
events = data[:events] || data["events"] || []
|
|
146
|
+
|
|
147
|
+
with_store_lock do
|
|
148
|
+
pending_sessions = {}
|
|
149
|
+
pending_events = {}
|
|
150
|
+
existing_events = {}
|
|
151
|
+
|
|
152
|
+
sessions.each do |s|
|
|
153
|
+
id = s[:id] || s["id"]
|
|
154
|
+
raise SerializationError, "Session record missing id" unless id && !id.to_s.empty?
|
|
155
|
+
raise DuplicateSessionError, "Session already exists: #{id}" if exists?(id)
|
|
156
|
+
raise DuplicateSessionError, "Duplicate session in import data: #{id}" if pending_sessions.key?(id)
|
|
157
|
+
|
|
158
|
+
record = Record.from_h(s)
|
|
159
|
+
pending_sessions[record.id] = record
|
|
160
|
+
pending_events[record.id] = []
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
events.each do |e|
|
|
164
|
+
session_id = e[:session_id] || e["session_id"]
|
|
165
|
+
session_events =
|
|
166
|
+
if pending_events.key?(session_id)
|
|
167
|
+
pending_events[session_id]
|
|
168
|
+
elsif exists?(session_id)
|
|
169
|
+
existing_events[session_id] ||= read_events(session_id)
|
|
170
|
+
else
|
|
171
|
+
raise NotFoundError, "Session not found: #{session_id}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
event = Event.from_h(e)
|
|
175
|
+
expected_seq = session_events.size
|
|
176
|
+
unless event.seq == expected_seq + 1
|
|
177
|
+
raise ConcurrencyError,
|
|
178
|
+
"Expected sequence #{expected_seq + 1} but got #{event.seq} for session #{session_id}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
session_events << event
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
pending_sessions.each do |id, record|
|
|
185
|
+
@adapter.set(record_key(id), dump(record.to_h))
|
|
186
|
+
@adapter.set(events_key(id), dump(pending_events[id].map(&:to_h)))
|
|
187
|
+
end
|
|
188
|
+
existing_events.each { |id, session_events| write_events(id, session_events) }
|
|
189
|
+
|
|
190
|
+
unless pending_sessions.empty?
|
|
191
|
+
ids = read_index
|
|
192
|
+
pending_sessions.each_key { |id| ids << id }
|
|
193
|
+
@adapter.set(INDEX_KEY, dump(ids))
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
# Serialize this process's operations, then take the adapter's
|
|
201
|
+
# cross-process store lock when the provider exposes one. Adapters
|
|
202
|
+
# without lock APIs fall back to the in-process mutex alone.
|
|
203
|
+
def with_store_lock
|
|
204
|
+
@mutex.synchronize do
|
|
205
|
+
lock = acquire_store_lock if @lockable
|
|
206
|
+
begin
|
|
207
|
+
yield
|
|
208
|
+
ensure
|
|
209
|
+
release_store_lock(lock) if lock
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Spin (bounded) until the provider lock is acquired. The lock is
|
|
215
|
+
# TTL-bounded by the adapter, so a crashed holder cannot wedge the
|
|
216
|
+
# store forever; exhausting the wait budget surfaces as
|
|
217
|
+
# ConcurrencyError rather than silently dropping mutual exclusion.
|
|
218
|
+
#
|
|
219
|
+
# Transient adapter errors during acquisition (e.g. a check-then-insert
|
|
220
|
+
# race between connections on the lock row) are retried within the
|
|
221
|
+
# same budget — losing the race means the lock is held elsewhere.
|
|
222
|
+
def acquire_store_lock
|
|
223
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
|
|
224
|
+
delay = LOCK_RETRY_MIN_DELAY
|
|
225
|
+
last_error = nil
|
|
226
|
+
loop do
|
|
227
|
+
begin
|
|
228
|
+
lock = @adapter.acquire_lock(STORE_LOCK_KEY, ttl: @lock_ttl)
|
|
229
|
+
return lock if lock
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
last_error = e
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
235
|
+
message = "Timed out after #{@lock_timeout}s acquiring store lock"
|
|
236
|
+
message = "#{message} (last error: #{last_error.message})" if last_error
|
|
237
|
+
raise ConcurrencyError, message
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
sleep(delay)
|
|
241
|
+
delay = [delay * 2, LOCK_RETRY_MAX_DELAY].min
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# A failed release must never mask the operation's own outcome —
|
|
246
|
+
# the adapter's TTL reclaims the lock if the token delete failed.
|
|
247
|
+
def release_store_lock(lock)
|
|
248
|
+
@adapter.release_lock(STORE_LOCK_KEY, lock)
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def exists?(id)
|
|
254
|
+
!@adapter.get(record_key(id)).nil?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def read_record(id)
|
|
258
|
+
raw = @adapter.get(record_key(id))
|
|
259
|
+
return nil unless raw
|
|
260
|
+
|
|
261
|
+
Record.from_h(decode(JSON.parse(raw)))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def read_events(id)
|
|
265
|
+
raw = @adapter.get(events_key(id))
|
|
266
|
+
return [] unless raw
|
|
267
|
+
|
|
268
|
+
decode(JSON.parse(raw)).map { |h| Event.from_h(h) }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def write_events(id, session_events)
|
|
272
|
+
@adapter.set(events_key(id), dump(session_events.map(&:to_h)))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def read_index
|
|
276
|
+
raw = @adapter.get(INDEX_KEY)
|
|
277
|
+
raw ? decode(JSON.parse(raw)) : []
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def record_key(id)
|
|
281
|
+
"#{RECORD_PREFIX}#{id}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def events_key(id)
|
|
285
|
+
"#{EVENTS_PREFIX}#{id}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def dump(obj)
|
|
289
|
+
JSON.generate(encode(obj))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def encode(obj)
|
|
293
|
+
case obj
|
|
294
|
+
when Symbol then { SYMBOL_TAG => obj.to_s }
|
|
295
|
+
when Hash
|
|
296
|
+
obj.each_with_object({}) { |(k, v), acc| acc[k] = encode(v) }
|
|
297
|
+
when Array then obj.map { |v| encode(v) }
|
|
298
|
+
else obj
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def decode(obj)
|
|
303
|
+
case obj
|
|
304
|
+
when Hash
|
|
305
|
+
if obj.size == 1 && obj.key?(SYMBOL_TAG) && obj[SYMBOL_TAG].is_a?(String)
|
|
306
|
+
obj[SYMBOL_TAG].to_sym
|
|
307
|
+
else
|
|
308
|
+
obj.each_with_object({}) { |(k, v), acc| acc[k] = decode(v) }
|
|
309
|
+
end
|
|
310
|
+
when Array then obj.map { |v| decode(v) }
|
|
311
|
+
else obj
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Session
|
|
5
|
+
Record = Struct.new(:id, :status, :metadata, :created_at, :updated_at, :version, keyword_init: true) do
|
|
6
|
+
def self.create(id: nil, status: :active, metadata: {}, created_at: nil, updated_at: nil)
|
|
7
|
+
now = created_at || Time.now.utc
|
|
8
|
+
new(
|
|
9
|
+
id: id || "sess_#{SecureRandom.hex(8)}",
|
|
10
|
+
status: status,
|
|
11
|
+
metadata: metadata,
|
|
12
|
+
created_at: now,
|
|
13
|
+
updated_at: updated_at || now,
|
|
14
|
+
version: 0
|
|
15
|
+
).freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{
|
|
20
|
+
id: id,
|
|
21
|
+
status: status,
|
|
22
|
+
metadata: metadata,
|
|
23
|
+
created_at: created_at&.utc&.iso8601,
|
|
24
|
+
updated_at: updated_at&.utc&.iso8601,
|
|
25
|
+
version: version
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.from_h(h)
|
|
30
|
+
new(
|
|
31
|
+
id: h[:id] || h["id"],
|
|
32
|
+
status: (h[:status] || h["status"])&.to_sym,
|
|
33
|
+
metadata: deep_symbolize(h[:metadata] || h["metadata"]),
|
|
34
|
+
created_at: parse_time(h[:created_at] || h["created_at"]),
|
|
35
|
+
updated_at: parse_time(h[:updated_at] || h["updated_at"]),
|
|
36
|
+
version: h[:version] || h["version"]
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.deep_symbolize(obj)
|
|
41
|
+
case obj
|
|
42
|
+
when Hash
|
|
43
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
|
|
44
|
+
when Array
|
|
45
|
+
obj.map { |v| deep_symbolize(v) }
|
|
46
|
+
else
|
|
47
|
+
obj
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
private_class_method :deep_symbolize
|
|
51
|
+
|
|
52
|
+
def self.parse_time(value)
|
|
53
|
+
case value
|
|
54
|
+
when Time then value
|
|
55
|
+
when String then Time.parse(value).utc
|
|
56
|
+
when nil then nil
|
|
57
|
+
else
|
|
58
|
+
raise SerializationError, "Invalid time value: #{value.inspect}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
private_class_method :parse_time
|
|
62
|
+
|
|
63
|
+
def initialize(**)
|
|
64
|
+
super
|
|
65
|
+
self.metadata = deep_freeze(metadata) unless metadata.frozen?
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def with_updates(**attrs)
|
|
70
|
+
merged = {
|
|
71
|
+
id: id,
|
|
72
|
+
status: status,
|
|
73
|
+
metadata: metadata,
|
|
74
|
+
created_at: created_at,
|
|
75
|
+
updated_at: attrs[:updated_at] || Time.now.utc,
|
|
76
|
+
version: version
|
|
77
|
+
}.merge(attrs)
|
|
78
|
+
self.class.new(**merged).freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def deep_freeze(obj)
|
|
84
|
+
case obj
|
|
85
|
+
when Hash
|
|
86
|
+
obj.each_with_object({}) { |(k, v), h| h[k] = deep_freeze(v) }.freeze
|
|
87
|
+
when Array
|
|
88
|
+
obj.map { |v| deep_freeze(v) }.freeze
|
|
89
|
+
else
|
|
90
|
+
obj
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Session
|
|
5
|
+
# Bridge from the ask-runtime event-sink contract to a session Host.
|
|
6
|
+
#
|
|
7
|
+
# ask-runtime executors (ask-agent, ask-mcp, ask-sandbox-providers)
|
|
8
|
+
# report tool lifecycle through +ExecutionContext#event_sink+ by calling
|
|
9
|
+
# <tt>emit(event_type, event: event)</tt> with the immutable runtime
|
|
10
|
+
# events (+:tool_started+, +:tool_completed+, +:tool_failed+,
|
|
11
|
+
# +:tool_cancelled+, +:tool_timed_out+). This sink implements that
|
|
12
|
+
# producer side and appends the mapped session events through
|
|
13
|
+
# +Host#append+, so tool history becomes part of the event-sourced
|
|
14
|
+
# session without ask-session depending on ask-runtime.
|
|
15
|
+
#
|
|
16
|
+
# sink = host.sink("s1", trace_id: current_trace_id)
|
|
17
|
+
# context = Ask::Runtime::ExecutionContext.new(session_id: "s1", event_sink: sink)
|
|
18
|
+
#
|
|
19
|
+
# Mapping (runtime symbol -> session event type):
|
|
20
|
+
#
|
|
21
|
+
# :tool_started -> "tool.started"
|
|
22
|
+
# :tool_completed -> "tool.completed"
|
|
23
|
+
# :tool_failed -> "tool.failed"
|
|
24
|
+
# :tool_cancelled -> "tool.cancelled"
|
|
25
|
+
# :tool_timed_out -> "tool.timed_out"
|
|
26
|
+
#
|
|
27
|
+
# Payloads are extracted duck-typed from the event's public readers
|
|
28
|
+
# (+tool_name+, +tool_call_id+, +duration+, +error+, +reason+,
|
|
29
|
+
# +tool_call+, +execution_context+, +tool_result+), so no runtime
|
|
30
|
+
# classes are required. Nil values are omitted.
|
|
31
|
+
#
|
|
32
|
+
# Terminal events carry +outcome+ (+:completed+, +:failed+,
|
|
33
|
+
# +:cancelled+, +:timed_out+ — the ToolCall state vocabulary),
|
|
34
|
+
# +duration+ in seconds, and +error+ when a failure or cancellation
|
|
35
|
+
# reason is available.
|
|
36
|
+
#
|
|
37
|
+
# Guards:
|
|
38
|
+
# - A correlated event whose session id differs from the sink's
|
|
39
|
+
# session raises SessionMismatchError (never cross-write sessions).
|
|
40
|
+
# - Appends to a closed or aborted session are dropped silently:
|
|
41
|
+
# terminal sessions stop recording, but a tool run already in
|
|
42
|
+
# flight must not fail because recording ended.
|
|
43
|
+
# - A mapped type emitted without an +event:+ raises ArgumentError.
|
|
44
|
+
# - Unknown event types are ignored (additive runtime growth).
|
|
45
|
+
# - Missing sessions still raise NotFoundError (wiring bug).
|
|
46
|
+
class Sink
|
|
47
|
+
MAPPING = {
|
|
48
|
+
tool_started: "tool.started",
|
|
49
|
+
tool_completed: "tool.completed",
|
|
50
|
+
tool_failed: "tool.failed",
|
|
51
|
+
tool_cancelled: "tool.cancelled",
|
|
52
|
+
tool_timed_out: "tool.timed_out"
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
DEFAULT_OUTCOMES = {
|
|
56
|
+
"tool.completed" => :completed,
|
|
57
|
+
"tool.failed" => :failed,
|
|
58
|
+
"tool.cancelled" => :cancelled,
|
|
59
|
+
"tool.timed_out" => :timed_out
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
attr_reader :host, :session_id, :trace_id, :causation_id
|
|
63
|
+
|
|
64
|
+
def initialize(host:, session_id:, trace_id: nil, causation_id: nil)
|
|
65
|
+
@host = host
|
|
66
|
+
@session_id = session_id
|
|
67
|
+
@trace_id = trace_id
|
|
68
|
+
@causation_id = causation_id
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Producer side of the ask-runtime EventSink contract.
|
|
72
|
+
#
|
|
73
|
+
# @param event_type [Symbol, String] runtime event name
|
|
74
|
+
# @param event [Object, nil] the runtime event (required for mapped types)
|
|
75
|
+
# @return [self]
|
|
76
|
+
# @raise [ArgumentError] when a mapped type is emitted without an event
|
|
77
|
+
# @raise [SessionMismatchError] when the event correlates to another session
|
|
78
|
+
# @raise [NotFoundError] when the sink's session does not exist
|
|
79
|
+
def emit(event_type, event: nil)
|
|
80
|
+
type = MAPPING[coerce(event_type)]
|
|
81
|
+
return self unless type
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "event: is required for #{event_type.inspect}" if event.nil?
|
|
84
|
+
|
|
85
|
+
mismatched = conflicting_session_id(event)
|
|
86
|
+
if mismatched
|
|
87
|
+
raise SessionMismatchError,
|
|
88
|
+
"Event for session #{mismatched.inspect} cannot be recorded in session #{@session_id.inspect}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
append(type, event)
|
|
92
|
+
self
|
|
93
|
+
rescue InvalidTransitionError
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Whether this sink records the given runtime event type.
|
|
98
|
+
def listening?(event_type)
|
|
99
|
+
MAPPING.key?(coerce(event_type))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def coerce(event_type)
|
|
105
|
+
event_type.respond_to?(:to_sym) ? event_type.to_sym : event_type
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def append(type, event)
|
|
109
|
+
@host.append(
|
|
110
|
+
@session_id,
|
|
111
|
+
type: type,
|
|
112
|
+
payload: build_payload(type, event),
|
|
113
|
+
trace_id: @trace_id,
|
|
114
|
+
causation_id: @causation_id
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_payload(type, event)
|
|
119
|
+
payload = {}
|
|
120
|
+
add(payload, :tool_name, read(event, :tool_name))
|
|
121
|
+
add(payload, :tool_call_id, read(event, :tool_call_id))
|
|
122
|
+
add(payload, :turn, turn_of(event))
|
|
123
|
+
add(payload, :input, input_of(event)) if type == "tool.started"
|
|
124
|
+
|
|
125
|
+
unless type == "tool.started"
|
|
126
|
+
add(payload, :outcome, outcome_of(type, event))
|
|
127
|
+
add(payload, :duration, read(event, :duration))
|
|
128
|
+
add(payload, :error, error_of(event))
|
|
129
|
+
output = output_of(event)
|
|
130
|
+
add(payload, :output, output) unless output.nil?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
payload
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def add(payload, key, value)
|
|
137
|
+
payload[key] = value unless value.nil?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read(object, method)
|
|
141
|
+
object.public_send(method) if object.respond_to?(method)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def turn_of(event)
|
|
145
|
+
ctx = read(event, :execution_context)
|
|
146
|
+
read(ctx, :turn)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def input_of(event)
|
|
150
|
+
call = read(event, :tool_call)
|
|
151
|
+
read(call, :input)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def outcome_of(type, event)
|
|
155
|
+
call = read(event, :tool_call)
|
|
156
|
+
read(call, :state) || DEFAULT_OUTCOMES[type]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def error_of(event)
|
|
160
|
+
read(event, :error) || read(event, :reason) || begin
|
|
161
|
+
result = read(event, :tool_result)
|
|
162
|
+
read(result, :error_message)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def output_of(event)
|
|
167
|
+
result = read(event, :tool_result)
|
|
168
|
+
read(result, :output)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def conflicting_session_id(event)
|
|
172
|
+
found = session_id_from(event)
|
|
173
|
+
found if found && found != @session_id
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def session_id_from(event)
|
|
177
|
+
call = read(event, :tool_call)
|
|
178
|
+
from_call = read(call, :session_id)
|
|
179
|
+
return from_call if from_call
|
|
180
|
+
|
|
181
|
+
ctx = read(event, :execution_context)
|
|
182
|
+
from_ctx = read(ctx, :session_id)
|
|
183
|
+
return from_ctx if from_ctx
|
|
184
|
+
|
|
185
|
+
read(event, :session_id)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Session
|
|
5
|
+
module State
|
|
6
|
+
def self.reduce(session_id, events)
|
|
7
|
+
record = nil
|
|
8
|
+
|
|
9
|
+
events.each do |event|
|
|
10
|
+
record = apply_event(record, event)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
(record || Record.create(id: session_id, created_at: Time.now.utc)).freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.apply_event(record, event)
|
|
17
|
+
case event.type
|
|
18
|
+
when "session.created"
|
|
19
|
+
Record.new(
|
|
20
|
+
id: event.session_id,
|
|
21
|
+
status: event.payload.fetch(:status, :active),
|
|
22
|
+
metadata: event.payload.fetch(:metadata, {}),
|
|
23
|
+
created_at: event.created_at,
|
|
24
|
+
updated_at: event.created_at,
|
|
25
|
+
version: event.seq
|
|
26
|
+
)
|
|
27
|
+
when "session.status_changed", "session.ended", "session.aborted"
|
|
28
|
+
raise "No session to update" unless record
|
|
29
|
+
|
|
30
|
+
record.with_updates(
|
|
31
|
+
status: event.payload.fetch(:status, record.status),
|
|
32
|
+
version: event.seq,
|
|
33
|
+
updated_at: event.created_at
|
|
34
|
+
)
|
|
35
|
+
when "message.added", "tool.completed"
|
|
36
|
+
raise "No session to update" unless record
|
|
37
|
+
|
|
38
|
+
record.with_updates(
|
|
39
|
+
version: event.seq,
|
|
40
|
+
updated_at: event.created_at
|
|
41
|
+
)
|
|
42
|
+
else
|
|
43
|
+
raise "No session to update" unless record
|
|
44
|
+
|
|
45
|
+
record.with_updates(
|
|
46
|
+
version: event.seq,
|
|
47
|
+
updated_at: event.created_at
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :apply_event
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|