ask-core 0.2.4 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b573cb0709bd479e2a888becc0ce41cacafcf5f2e48afee24b2d6af1a7b99d5
4
- data.tar.gz: c690b789d00985df0d9723c4f436c6b4a51e62f1857479af9ff2f652b59cd7d8
3
+ metadata.gz: 6f85f689da590da523c0fcf5ead1b02469865eaf957720c8965084d439f69f74
4
+ data.tar.gz: dec0a975ed1b82346e98a9d84820e8cad192db0f0e308d97a1dcbdc5303ebee9
5
5
  SHA512:
6
- metadata.gz: 4506564311573fcd2a43d277eef97157d1abafa7add2968bc336551ad1f6e4133f87b1aa2d47607963e2c0b3d0fdfb92ad332c6ff55eddb3a927276cee647a3e
7
- data.tar.gz: ec2c10ed1152e01783272fffc16ff0695334c53bae829d9a0ebb7077317c084b3587490d6bc80820e2c78fd10e19e676dfe319147f667a4a453f763fb08782a7
6
+ metadata.gz: 4cb0d8b10f05b778fd66717cb54571d486d21cc93ba95334d5ecd3517d8aa4ccacd92b78fa7dfdc26893d89f5871d18593584cf204967f34d64d1545add1efc0
7
+ data.tar.gz: f00616ed87027aedb9eeadc69445078dd203dee9910dcd014c1c89f6461544241bd1b0e08f6a39c30d60c40452b38f529424c09775e098ed6900aad4b9988279
data/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ ## [0.3.0] — 2026-07-21
2
+
3
+ ### Added
4
+
5
+ - **Pluggable state abstraction** — `Ask::State::Adapter` defines a unified interface for key-value storage, distributed locking, message queues, and ordered lists. `Ask::State::Memory` provides an in-process, thread-safe implementation backed by Hash.
6
+
7
+ ```ruby
8
+ store = Ask::State::Memory.new
9
+
10
+ # Key-value with optional TTL
11
+ store.set("key", "value", ttl: 60)
12
+ store.get("key")
13
+
14
+ # Distributed locking
15
+ lock = store.acquire_lock("resource", ttl: 10)
16
+ store.release_lock("resource", lock)
17
+
18
+ # Message queues
19
+ store.enqueue("queue", { task: "check" })
20
+ store.dequeue("queue")
21
+
22
+ # Ordered lists
23
+ store.list_append("list", "item", max_length: 100)
24
+ store.list_range("list", 0, -1)
25
+ store.list_remove("list", "item")
26
+ ```
27
+
28
+ The adapter pattern mirrors `Ask::Provider` — define the contract in ask-core, provide implementations in separate gems. Production backends (Redis, PostgreSQL) can be added by any gem without modifying ask-core.
29
+
30
+ Data types: `Ask::State::Lock` (with `#expired?`), `Ask::State::QueueEntry`.
31
+
32
+ ### Changed
33
+
34
+ - **ask-agent's `Persistence::Base` now wraps `Ask::State::Adapter`** — session persistence is backed by the unified state interface instead of a standalone abstract class. `Persistence::InMemory` delegates to `Ask::State::Memory`. Backward compatible — no API changes for users.
35
+
36
+ ### Tested
37
+
38
+ - 34 new tests for `Ask::State::Adapter` + `Ask::State::Memory`: key-value operations, TTL expiry, thread safety, locking semantics, queue FIFO order, list management, adapter subclassing, and the base class contract.
39
+
1
40
  ## [0.2.4] — 2026-07-17
2
41
 
3
42
  ### Added
data/lib/ask/provider.rb CHANGED
@@ -93,9 +93,6 @@ module Ask
93
93
  # @return [Boolean] true if the provider requires a remote API
94
94
  def remote? = !local?
95
95
 
96
- # @return [Boolean] true if all models can be assumed to exist
97
- def assume_models_exist? = self.class.assume_models_exist?
98
-
99
96
  # --- Slug / name / capabilities ---
100
97
 
101
98
  # @return [String] lowercased provider slug
@@ -199,10 +196,7 @@ module Ask
199
196
  !local?
200
197
  end
201
198
 
202
- # @return [Boolean] whether all models can be assumed to exist
203
- def assume_models_exist?
204
- false
205
- end
199
+
206
200
 
207
201
  private
208
202
 
data/lib/ask/state.rb ADDED
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "forwardable"
5
+
6
+ module Ask
7
+ # Pluggable state backend for agent sessions, channel providers, and
8
+ # any other ask-rb component that needs durable key-value storage,
9
+ # distributed locking, message queues, or ordered lists.
10
+ #
11
+ # The {State::Adapter} abstract base defines the contract.
12
+ # {State::Memory} provides an in-process implementation backed by Hash.
13
+ # Production apps can provide Redis, PostgreSQL, or other backends by
14
+ # subclassing {State::Adapter}.
15
+ #
16
+ # @example Using the in-memory adapter
17
+ # store = Ask::State::Memory.new
18
+ # store.set("key", "value")
19
+ # store.get("key") # => "value"
20
+ # store.delete("key")
21
+ #
22
+ # @example Acquiring a distributed lock
23
+ # lock = store.acquire_lock("resource-1", ttl: 10)
24
+ # if lock
25
+ # begin
26
+ # # critical section
27
+ # ensure
28
+ # store.release_lock("resource-1", lock)
29
+ # end
30
+ # end
31
+ #
32
+ # @example Using a message queue
33
+ # store.enqueue("queue-name", { task: "check_health" })
34
+ # entry = store.dequeue("queue-name")
35
+ # # => { id: "uuid", value: { task: "check_health" }, enqueued_at: timestamp }
36
+ module State
37
+ Lock = Data.define(:id, :token, :expires_at) do
38
+ def expired?(now = Time.now)
39
+ expires_at && now >= expires_at
40
+ end
41
+ end
42
+
43
+ QueueEntry = Data.define(:id, :value, :enqueued_at)
44
+
45
+ # Abstract base class for state backends.
46
+ # Subclasses must implement all methods.
47
+ class Adapter
48
+ # Key-value storage
49
+
50
+ # @param key [String] the key
51
+ # @return [Object, nil] the stored value, or nil if not found
52
+ def get(key)
53
+ raise NotImplementedError
54
+ end
55
+
56
+ # @param key [String] the key
57
+ # @param value [Object] the value (must be JSON-serializable)
58
+ # @param ttl [Integer, nil] time-to-live in seconds (nil = no expiry)
59
+ def set(key, value, ttl: nil)
60
+ raise NotImplementedError
61
+ end
62
+
63
+ # @param key [String] the key
64
+ def delete(key)
65
+ raise NotImplementedError
66
+ end
67
+
68
+ # Atomically set a value only if the key does not already exist.
69
+ # @param key [String] the key
70
+ # @param value [Object] the value
71
+ # @return [Boolean] true if the value was set, false if the key already exists
72
+ def set_if_not_exists(key, value, ttl: nil)
73
+ raise NotImplementedError
74
+ end
75
+
76
+ # Distributed locking
77
+
78
+ # Acquire a lock for a key. Returns nil if the lock is held by another owner.
79
+ # @param key [String] the resource to lock
80
+ # @param ttl [Integer] lock time-to-live in seconds (default 10)
81
+ # @return [Lock, nil] the lock if acquired, nil if already held
82
+ def acquire_lock(key, ttl: 10)
83
+ raise NotImplementedError
84
+ end
85
+
86
+ # Release a lock. Only the lock owner can release it.
87
+ # @param key [String] the resource to unlock
88
+ # @param lock [Lock] the lock returned by {#acquire_lock}
89
+ # @return [Boolean] true if released, false if lock was already expired or not held
90
+ def release_lock(key, lock)
91
+ raise NotImplementedError
92
+ end
93
+
94
+ # Message queues
95
+
96
+ # Push an item onto a named queue.
97
+ # @param queue [String] the queue name
98
+ # @param value [Object] the value to enqueue
99
+ # @return [QueueEntry] the enqueued entry
100
+ def enqueue(queue, value)
101
+ raise NotImplementedError
102
+ end
103
+
104
+ # Pop the next item from a named queue.
105
+ # @param queue [String] the queue name
106
+ # @return [QueueEntry, nil] the next entry, or nil if the queue is empty
107
+ def dequeue(queue)
108
+ raise NotImplementedError
109
+ end
110
+
111
+ # @param queue [String] the queue name
112
+ # @return [Integer] the number of items in the queue
113
+ def queue_depth(queue)
114
+ raise NotImplementedError
115
+ end
116
+
117
+ # Ordered lists
118
+
119
+ # Append a value to an ordered list. Trims to max_length (keeps newest).
120
+ # @param key [String] the list key
121
+ # @param value [Object] the value to append
122
+ # @param max_length [Integer, nil] maximum list length (nil = no limit)
123
+ def list_append(key, value, max_length: nil)
124
+ raise NotImplementedError
125
+ end
126
+
127
+ # Return a slice of the list.
128
+ # @param key [String] the list key
129
+ # @param start [Integer] starting index (0-based)
130
+ # @param stop [Integer] ending index (inclusive, -1 for all)
131
+ # @return [Array<Object>] the list slice
132
+ def list_range(key, start = 0, stop = -1)
133
+ raise NotImplementedError
134
+ end
135
+
136
+ # Remove all occurrences of a value from a list.
137
+ # @param key [String] the list key
138
+ # @param value [Object] the value to remove
139
+ # @return [Integer] number of removed elements
140
+ def list_remove(key, value)
141
+ raise NotImplementedError
142
+ end
143
+
144
+ # Lifecycle
145
+
146
+ # Optional: called when the adapter is no longer needed.
147
+ def close
148
+ # no-op by default
149
+ end
150
+ end
151
+
152
+ # In-process state backend backed by a Hash.
153
+ # All operations are thread-safe via a Mutex.
154
+ # Data is not persisted — lost on process exit.
155
+ class Memory < Adapter
156
+ def initialize
157
+ @data = {}
158
+ @locks = {}
159
+ @queues = {}
160
+ @lists = {}
161
+ @mutex = Mutex.new
162
+ end
163
+
164
+ # -- key-value --
165
+
166
+ def get(key)
167
+ @mutex.synchronize do
168
+ expiry = @data[key]&.dig(:expires_at)
169
+ return nil if expiry && Time.now >= expiry
170
+
171
+ @data[key]&.dig(:value)
172
+ end
173
+ end
174
+
175
+ def set(key, value, ttl: nil)
176
+ @mutex.synchronize do
177
+ @data[key] = {
178
+ value: value,
179
+ expires_at: ttl ? Time.now + ttl : nil
180
+ }
181
+ end
182
+ end
183
+
184
+ def delete(key)
185
+ @mutex.synchronize { @data.delete(key) }
186
+ end
187
+
188
+ def set_if_not_exists(key, value, ttl: nil)
189
+ @mutex.synchronize do
190
+ if @data.key?(key)
191
+ expiry = @data[key][:expires_at]
192
+ return false if expiry.nil? || Time.now < expiry
193
+
194
+ # Key expired — treat as nonexistent
195
+ @data.delete(key)
196
+ end
197
+
198
+ @data[key] = {
199
+ value: value,
200
+ expires_at: ttl ? Time.now + ttl : nil
201
+ }
202
+ true
203
+ end
204
+ end
205
+
206
+ # -- locking --
207
+
208
+ def acquire_lock(key, ttl: 10)
209
+ @mutex.synchronize do
210
+ existing = @locks[key]
211
+ if existing.nil? || existing.expired?
212
+ lock = Lock.new(
213
+ id: key,
214
+ token: SecureRandom.hex(16),
215
+ expires_at: Time.now + ttl
216
+ )
217
+ @locks[key] = lock
218
+ lock
219
+ end
220
+ end
221
+ end
222
+
223
+ def release_lock(key, lock)
224
+ @mutex.synchronize do
225
+ current = @locks[key]
226
+ if current && current.token == lock.token && !current.expired?
227
+ @locks.delete(key)
228
+ true
229
+ else
230
+ false
231
+ end
232
+ end
233
+ end
234
+
235
+ # -- queues --
236
+
237
+ def enqueue(queue, value)
238
+ @mutex.synchronize do
239
+ @queues[queue] ||= []
240
+ entry = QueueEntry.new(
241
+ id: SecureRandom.uuid,
242
+ value: value,
243
+ enqueued_at: Time.now
244
+ )
245
+ @queues[queue] << entry
246
+ entry
247
+ end
248
+ end
249
+
250
+ def dequeue(queue)
251
+ @mutex.synchronize do
252
+ q = @queues[queue]
253
+ return nil unless q&.any?
254
+
255
+ q.shift
256
+ end
257
+ end
258
+
259
+ def queue_depth(queue)
260
+ @mutex.synchronize do
261
+ (@queues[queue] || []).length
262
+ end
263
+ end
264
+
265
+ # -- lists --
266
+
267
+ def list_append(key, value, max_length: nil)
268
+ @mutex.synchronize do
269
+ @lists[key] ||= []
270
+ @lists[key] << value
271
+ @lists[key].shift if max_length && @lists[key].length > max_length
272
+ end
273
+ end
274
+
275
+ def list_range(key, start = 0, stop = -1)
276
+ @mutex.synchronize do
277
+ list = @lists[key] || []
278
+ return list if start == 0 && stop == -1
279
+
280
+ stop = list.length - 1 if stop == -1 || stop >= list.length
281
+ return [] if start > stop
282
+
283
+ list[start..stop] || []
284
+ end
285
+ end
286
+
287
+ def list_remove(key, value)
288
+ @mutex.synchronize do
289
+ list = @lists[key]
290
+ return 0 unless list
291
+
292
+ before = list.length
293
+ list.delete(value)
294
+ before - list.length
295
+ end
296
+ end
297
+
298
+ # -- lifecycle --
299
+
300
+ def close
301
+ @mutex.synchronize do
302
+ @data.clear
303
+ @locks.clear
304
+ @queues.clear
305
+ @lists.clear
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
data/lib/ask/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ask
4
- VERSION = "0.2.4"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/ask.rb CHANGED
@@ -21,3 +21,4 @@ require_relative "ask/stream"
21
21
  require_relative "ask/conversation"
22
22
  require_relative "ask/provider"
23
23
  require_relative "ask/models"
24
+ require_relative "ask/state"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -69,6 +69,7 @@ files:
69
69
  - lib/ask/models.rb
70
70
  - lib/ask/provider.rb
71
71
  - lib/ask/result.rb
72
+ - lib/ask/state.rb
72
73
  - lib/ask/stream.rb
73
74
  - lib/ask/tool_def.rb
74
75
  - lib/ask/version.rb