ask-agent 0.36.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcbe9fc4bae7e7eddc9fc206e35ef22499a9fb3a308bac6d9f48c2d1d2e12f8f
4
- data.tar.gz: ae9c72d61a839330d2bbc68b8049fe1e54680f0462d4dee2e886d8377df439b3
3
+ metadata.gz: e22262139bd8142b9e45f22e831d59360f9465aad8fea93247352591b742d6c3
4
+ data.tar.gz: 1486719187105e25f794bff150fd9aa59e902096a40c15fdb822b2e7061d9bac
5
5
  SHA512:
6
- metadata.gz: 7a34904c0d8c0d6397753262d4f3c359674893b5ddab7a85714f8eebf797db0cdd3b6a076ea2c18eb693ad1f2211677e88bf4f4a7f4e2e2f7fe5545892af5062
7
- data.tar.gz: d78f87319a5d6b63c22e1bd7d9cacde6fd6306131f746ad5b4166d509acf5dad11b03db7e9fab6f908db752f7a592f2a4dc51fdb13fb4fa30ebc39c193894acb
6
+ metadata.gz: 2eaacd8c0a7a32392546ccde9d218d271afa22a3a2d1c154bfc3665691060b9cc4e7ab8d658c25eb2b2e79cccb1ad3bbbf10cd65e36c4324e2fc408f8eb8d973
7
+ data.tar.gz: d9abb9f16f260d5ece0c403346ae0f1cb5a9edadb3e9fad0a2a56fd05c66748eb0070f16efa8441aa55275c5fc1aff724d43f0d4d6809751b932978272563fcf
data/CHANGELOG.md CHANGED
@@ -1,3 +1,30 @@
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
+
1
28
  ## [0.36.0] — 2026-08-07
2
29
 
3
30
  ### Added
@@ -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.
@@ -41,6 +41,13 @@ module Ask
41
41
  @pending_mutex = Mutex.new
42
42
  @followup_pending = false
43
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 }
44
51
  @created_at = Time.now
45
52
  @_no_tools_instructed = false
46
53
 
@@ -197,6 +204,10 @@ module Ask
197
204
  @_no_tools_instructed = true
198
205
  end
199
206
 
207
+ # Leftover queued steers from a previous run become user messages
208
+ # before this run starts.
209
+ drain_leftover_steers
210
+
200
211
  begin
201
212
  @tool_executor.telemetry = @telemetry
202
213
 
@@ -210,6 +221,7 @@ module Ask
210
221
  event_emitter: self,
211
222
  session_id: @id,
212
223
  tool_call_repair: @tool_call_repair,
224
+ steer_source: method(:drain_one_steer),
213
225
  persist: @state ? method(:persist!) : nil
214
226
  )
215
227
 
@@ -517,6 +529,20 @@ module Ask
517
529
 
518
530
  # --- Plan mode ---
519
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
+
520
546
  # Extract durable facts from this session's transcript into memory
521
547
  # (memory_learning: true). Best-effort — extraction never breaks the
522
548
  # session; failures are swallowed.
@@ -582,6 +608,45 @@ module Ask
582
608
  )
583
609
  end
584
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
+
585
650
  # --- Async (pending) tools ---
586
651
 
587
652
  # Registers a pending tool call (called by the loop when a tool
@@ -895,7 +960,17 @@ module Ask
895
960
  }
896
961
  }
897
962
  @state.set(@id, payload)
898
- @checkpoint_store.checkpoint(@id, payload) if @checkpoints
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
899
974
  end
900
975
 
901
976
  def try_auto_meta_agent
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.36.0"
5
+ VERSION = "0.37.0"
6
6
  end
7
7
  end
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.36.0
4
+ version: 0.37.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto