phronomy 0.9.0 → 0.10.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: d91e0fb85732153a69d268b41bdfe865791dd8f007e8bed983269284478af002
4
- data.tar.gz: c334678280139ac7934b6804b06e282051218472985c022823d26913a3f64905
3
+ metadata.gz: f8bcaf54fe256791053b04c22df3b58595d267b2c0ee16b67369fe2ecb36f19d
4
+ data.tar.gz: 9be01ec03a79ec5d557a3c51fc442992cd012a07e682a43cfa4c9e0358110fc8
5
5
  SHA512:
6
- metadata.gz: 393567f7c01633ea20160101705b0fde21ddd009a4950f1cb44a106285500b90a3bec88d4c9681cebb7656d0529c09c9e7c52da42e3e12f103231423921b43aa
7
- data.tar.gz: 03f5d2e764df9d3becb782ecdec0bf42f03b0f3fc7414efaad2334fe1d047443ef3180e1993244cad92c305607113d0afe2915caa6ff53d14c05c779a61f6b4b
6
+ metadata.gz: 44de75b5cc59ddf380aeb0be3abcf2a2c566cb8a1db6c35540525b86c74e7e0c06e75a3581e30276a25eea7f5f0334226e05b54d7d9694ed8f2b881d04a54e60
7
+ data.tar.gz: 76821f0cad1a918b762bb1d5737f902cc67b726f8dafb0dcf646b5cfc8dd4173527733a8a04e92e71ebea9a152fdb6ebaea20d9ccde3037f32d7fc32f5b05497
data/CHANGELOG.md CHANGED
@@ -9,8 +9,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ---
13
+
14
+ ## [0.10.0] - 2026-06-08
15
+
16
+ ### Added
17
+
18
+ - **`Task#map` — transform a Task's completed value** (#384):
19
+ `task.map { |result| ctx.merge(answer: result[:output]) }` returns a new `Task`
20
+ whose completed value is the block's return value. Primary use-case: wire
21
+ `Agent::Base#invoke_async` into a Workflow entry action so the agent result
22
+ reaches `WorkflowContext` through the standard `:action_completed` path without
23
+ requiring any changes to `FSMSession`. If the source task fails or is cancelled,
24
+ the mapped task propagates the error without calling the block.
25
+
26
+ ### Removed
27
+
28
+ - **`Agent::Base#run_as_child` removed** (#384):
29
+ Introduced when agents had their own FSM (`Agent::FSM`). After `Agent::FSM` was
30
+ removed in v0.9.0, the `:child_completed` event payload was silently discarded by
31
+ `FSMSession`, so `ctx.answer` was never populated. Migrate to
32
+ `invoke_async + Task#map`:
33
+
34
+ ```ruby
35
+ # Before (removed)
36
+ entry :translate, ->(ctx) { TranslationAgent.new.run_as_child(ctx.query, ctx: ctx) }
37
+ transition from: :translate, on: :child_completed, to: :done
38
+
39
+ # After (recommended)
40
+ entry :translate, ->(ctx) {
41
+ TranslationAgent.new.invoke_async(ctx.query).map { |r| ctx.merge(answer: r[:output]) }
42
+ }
43
+ transition from: :translate, to: :done
44
+ ```
45
+
12
46
  ### Added
13
47
 
48
+ - **`Task#map` — transform a Task's completed value** (post-v0.9.0):
49
+ `task.map { |result| ctx.merge(answer: result[:output]) }` returns a new `Task`
50
+ whose completed value is the block's return value. The primary use-case is
51
+ connecting `Agent::Base#invoke_async` to a Workflow entry action so the agent
52
+ result populates the `WorkflowContext` through the standard `:action_completed`
53
+ path. If the source task fails or is cancelled, the mapped task propagates the
54
+ error without calling the block.
55
+
14
56
  - **`Phronomy::Diagnostics` and `SchedulerReentrancyError`** (#278, #279):
15
57
  `Phronomy::Diagnostics` exposes a snapshot of current scheduler state
16
58
  (`pending_count`, `active_tasks`, `pool_utilization`, etc.) for debugging and
@@ -174,10 +216,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
174
216
  tasks are treated the same as errors and follow the existing `on_error:` policy (`:raise`
175
217
  or `:skip`).
176
218
 
177
- - **MCP `HttpTransport` custom authentication headers** (#144): `McpTool.from_server` now
178
- accepts `headers: {}`, forwarded all the way to `HttpTransport#initialize`. Arbitrary
179
- headers (e.g. `Authorization: Bearer …`) are injected into every JSON-RPC request,
180
- enabling use of MCP servers that require bearer tokens or API keys.
219
+ - **MCP `HttpTransport` custom authentication headers** (#144): `Phronomy::Tools::Mcp::HttpTransport#initialize`
220
+ now accepts `headers: {}`. Arbitrary headers (e.g. `Authorization: Bearer …`) are injected
221
+ into every JSON-RPC request, enabling use of MCP servers that require bearer tokens or
222
+ API keys. Threading `headers:` through `Mcp.from_server` is tracked in issue #144 and
223
+ pending in PR #151.
181
224
 
182
225
  - **`StdioTransport` — `env:`, `cwd:`, and `startup_timeout:` options** (#145):
183
226
  Three new keyword arguments are now accepted when constructing a `StdioTransport` (and
@@ -226,8 +269,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
226
269
  `dispatch_parallel` and `fan_out` accept `cancellation_token:` and automatically
227
270
  inject it into every worker task's config unless the task already supplies its own.
228
271
 
272
+ ### Added (post-v0.9.0)
273
+
274
+ - **`Agent::Base#run_as_child` removed** (post-v0.9.0):
275
+ `run_as_child` was introduced when agents had their own FSM. After `Agent::FSM`
276
+ was removed, the `:child_completed` event payload was silently discarded by
277
+ `FSMSession`, meaning `ctx.answer` was never populated. The method has been
278
+ removed. Use `invoke_async + Task#map` instead:
279
+
280
+ ```ruby
281
+ # Before (deprecated)
282
+ entry :translate, ->(ctx) { TranslationAgent.new.run_as_child(ctx.query, ctx: ctx) }
283
+ transition from: :translate, on: :child_completed, to: :done
284
+
285
+ # After (recommended)
286
+ entry :translate, ->(ctx) {
287
+ TranslationAgent.new.invoke_async(ctx.query).map { |r| ctx.merge(answer: r[:output]) }
288
+ }
289
+ transition from: :translate, to: :done
290
+ ```
291
+
292
+ - **`Phronomy::Agent::CheckpointStore` — idempotency store for HITL resume** (post-v0.9.0):
293
+ New in-memory store tracks consumed checkpoint IDs. Calling `Agent::Base#resume` twice
294
+ with the same checkpoint raises `Phronomy::CheckpointAlreadyResumedError` instead of
295
+ silently re-executing the approved tool. Custom stores can be injected via
296
+ `agent.checkpoint_store = MyRedis::CheckpointStore.new`. Duck-type contract:
297
+ `consumed?(id)`, `consume!(id)`, and optionally `cleanup!(id)` / `clear!`.
298
+
299
+ - **`checkpoint_id`, `agent_class`, `requested_at` on `Checkpoint`; `Agent::Base.resume` class method** (post-v0.9.0):
300
+ `Checkpoint` now carries a UUID `checkpoint_id` (idempotency key), `agent_class`
301
+ (fully-qualified class name), and `requested_at` (UTC timestamp). The new class-level
302
+ `Agent::Base.resume(checkpoint, approved:)` method instantiates the correct agent class
303
+ automatically and delegates to `#resume`, simplifying job-queue resume flows.
304
+
305
+ - **`CheckpointStore#cleanup!` and `#clear!`** (post-v0.9.0):
306
+ Optional methods on the `CheckpointStore` duck-type contract. `cleanup!(checkpoint_id)`
307
+ removes a single checkpoint entry; `clear!` wipes all tracking state.
308
+
229
309
  ### Removed
230
310
 
311
+ - **`Phronomy::ReactAgent` class removed** (post-v0.9.0):
312
+ Use `Phronomy::Agent::Base` directly. `ReactAgent` had no distinct public API beyond
313
+ `Agent::Base` and was not listed in the stability table.
314
+
315
+ - **`Phronomy::Agent::FSM` class removed** (post-v0.9.0, internal):
316
+ The agent invocation path is now unified through `Agent::Base#invoke` with inline logic.
317
+ No public API impact.
318
+
319
+ - **`Phronomy::Agent::Lifecycle::FSMSession` and `::PhaseMachineBuilder` moved to `Workflow` namespace** (post-v0.9.0, internal):
320
+ These internal classes now live at `Phronomy::Workflow::FSMSession` and
321
+ `Phronomy::Workflow::PhaseMachineBuilder`. No public API impact.
322
+
231
323
  - **BREAKING: `Agent::Base#run_as_child` drops `&result_writer` block parameter** (#265):
232
324
  The optional block form `run_as_child(input, ctx: ctx) { |r| ctx.answer = r[:output] }`
233
325
  is no longer supported. The result is now delivered **exclusively** as the
data/README.md CHANGED
@@ -54,8 +54,9 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
54
54
  | Feature | Stability |
55
55
  |---|---|
56
56
  | **Workflow EventLoop Mode** — Opt-in event-driven execution: `Phronomy.configure { \|c\| c.event_loop = true }` | Experimental |
57
- | **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#run_as_child` (child-FSM pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
57
+ | **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#invoke_async` + `Task#map` (child-agent pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
58
58
  | **`invoke_async` / `call_async`** — `Agent::Base#invoke_async` and `Workflow#invoke_async` return a `Task`; `Agent::Context::Capability::Base#call_async` similarly; compatible with EventLoop and standalone contexts | Experimental |
59
+ | **`Task#map`** — transforms a `Task`'s completed value via a block; returns a new `Task` whose value is the block's return value; if the source task fails or is cancelled the mapped task propagates the error without calling the block; primary use-case: `invoke_async.map { \|r\| ctx.merge(answer: r[:output]) }` to wire agent results into a `WorkflowContext` | Experimental |
59
60
  | **CancellationToken** — Cooperative cancellation via `cancel!`/`cancelled?`/`raise_if_cancelled!`; `timeout_after(seconds)` for monotonic-clock deadlines; optional `deadline:` (wall-clock) for backward compatibility; passed as `config: { cancellation_token: token }` to agents and `dispatch_parallel`; injected into `tool.execute` when the method declares a `cancellation_token:` keyword | Experimental |
60
61
  | **`dispatch_parallel` / `fan_out` `force_kill:` option** — `force_kill: false` (default) leaves timed-out workers running and raises `TimeoutError` immediately; `force_kill: true` restores the old `Thread#kill` behaviour with a `logger.warn` | Beta |
61
62
  | **`execution_mode` DSL on `Agent::Context::Capability::Base`** — Declares how a tool's `execute` should be dispatched: `:cooperative` (same scheduler thread), `:blocking_io` (default; offloaded to `BlockingAdapterPool`), `:cpu_bound`, `:external_process` | Experimental |
@@ -76,6 +77,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
76
77
  | **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + stateful workers with sequential task assignment (worker-local message history persisted across tasks) | Beta |
77
78
  | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
78
79
  | **`ScopePolicy`** — Configurable policy callable that maps (tool, scope, agent) to `:allow`/`:approve`/`:reject`; default policy auto-routes high-risk scopes through the approval gate | Experimental |
80
+ | **HITL Checkpoint/Resume** — `Agent::Base#invoke` returns `{ suspended: true, checkpoint: Checkpoint }` when an approval-required tool is encountered without a synchronous handler; `Agent::Base#resume(checkpoint, approved:)` resumes execution; `Agent::Base.resume(checkpoint, approved:)` (class-level) resolves the agent class automatically; `Checkpoint#to_h` / `Checkpoint.from_h` for serialization; `Agent::Base#checkpoint_store=` for custom idempotency backends; `CheckpointAlreadyResumedError` raised on duplicate resume | Experimental |
79
81
 
80
82
  > **Public API boundary**: The tables above are the complete list of classes, modules, and features
81
83
  > intended for gem consumers. Every entry has an associated stability label.
@@ -198,20 +200,18 @@ final = app.send_event(state: state, event: :approve)
198
200
  puts "Approved: #{final.approved}" # => true
199
201
  ```
200
202
 
201
- In EventLoop mode (`c.event_loop = true`), `Agent#run_as_child` spawns a child agent
202
- asynchronously. When the child succeeds, `:child_completed` is dispatched with the result
203
- `{ output:, messages:, usage: }` as its payload; when it fails, `:child_failed` is
204
- dispatched. Always declare both transitions to avoid a stuck workflow:
203
+ In EventLoop mode (`c.event_loop = true`), use `invoke_async + Task#map` to run an agent
204
+ asynchronously inside a Workflow entry action. The mapped Task returns a `WorkflowContext`,
205
+ which `FSMSession` picks up via the standard `:action_completed` path:
205
206
 
206
207
  ```ruby
207
- # EventLoop mode: workflow that runs an agent as a child FSM.
208
- # The result { output:, messages:, usage: } arrives as the :child_completed event
209
- # payload — write it back to the context in the target state's entry action.
210
- entry :run_agent, ->(ctx) {
211
- MyAgent.new.run_as_child(ctx.query, ctx: ctx)
208
+ # EventLoop mode: workflow that runs an agent and captures the result.
209
+ entry :translate, ->(ctx) {
210
+ TranslationAgent.new.invoke_async(ctx.query).map do |result|
211
+ ctx.merge(answer: result[:output]) # returns WorkflowContext
212
+ end
212
213
  }
213
- transition from: :run_agent, on: :child_completed, to: :done
214
- transition from: :run_agent, on: :child_failed, to: :handle_error
214
+ transition from: :translate, to: :done # no on: needed
215
215
  ```
216
216
 
217
217
  ### Multi-Agent — Agent-as-Tool pattern
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "checkpoint_store"
4
5
  require_relative "concerns/retryable"
5
6
  require_relative "concerns/guardrailable"
6
7
  require_relative "concerns/before_completion"
@@ -374,6 +375,27 @@ module Phronomy
374
375
  @context_overhead = val.to_i
375
376
  end
376
377
  end
378
+
379
+ # Resumes a suspended invocation identified by +checkpoint+ without
380
+ # requiring the original agent instance to be kept in memory.
381
+ #
382
+ # Validates that the checkpoint was created by this agent class, then
383
+ # instantiates a fresh agent and delegates to {Suspendable#resume}.
384
+ #
385
+ # @param checkpoint [Phronomy::Agent::Checkpoint]
386
+ # @param approved [Boolean] +true+ to execute the pending tool; +false+ to deny
387
+ # @param config [Hash] same runtime options as {#invoke}
388
+ # @return [Hash] same shape as {#invoke} — may contain +suspended: true+ if
389
+ # another approval-required tool is encountered during continuation
390
+ # @raise [ArgumentError] when +checkpoint.agent_class+ does not match this class
391
+ # @api public
392
+ def resume(checkpoint, approved:, config: {})
393
+ if checkpoint.agent_class && checkpoint.agent_class != name
394
+ raise ArgumentError,
395
+ "checkpoint belongs to #{checkpoint.agent_class}, cannot resume with #{name}"
396
+ end
397
+ new.resume(checkpoint, approved: approved, config: config)
398
+ end
377
399
  end
378
400
 
379
401
  # Registers an anonymous handoff tool class on this agent instance.
@@ -442,12 +464,35 @@ module Phronomy
442
464
  if invocation_context
443
465
  thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
444
466
  end
445
- if Phronomy.configuration.event_loop
446
- _invoke_via_event_loop(input, messages: messages, thread_id: thread_id, config: config)
447
- else
448
- _check_scheduler_reentrancy
449
- invoke_async(input, messages: messages, thread_id: thread_id, config: config).await
467
+ _check_scheduler_reentrancy
468
+
469
+ timeout_sec = self.class.invoke_timeout
470
+ unless timeout_sec
471
+ return invoke_async(input, messages: messages, thread_id: thread_id, config: config).await
472
+ end
473
+
474
+ # invoke_timeout: create a CancellationScope with deadline, pass its token
475
+ # to the async invocation, and use scope.pop_queue so the calling thread
476
+ # unblocks as soon as either the result arrives or the deadline fires.
477
+ scope = Phronomy::Concurrency::CancellationScope.new(parent_token: config[:cancellation_token])
478
+ scope.deadline_in(timeout_sec)
479
+ effective_config = config.merge(cancellation_token: scope.token)
480
+ task = invoke_async(input, messages: messages, thread_id: thread_id, config: effective_config)
481
+
482
+ # Bridge the task result to an AsyncQueue so scope.pop_queue can observe the deadline.
483
+ completion_queue = Phronomy::Concurrency::AsyncQueue.new
484
+ Phronomy::Runtime.instance.spawn(name: "invoke-timeout-bridge:#{(self.class.name || "agent").downcase}") do
485
+ completion_queue.push(task.await)
486
+ rescue => e
487
+ completion_queue.push(e)
450
488
  end
489
+
490
+ result = scope.pop_queue(completion_queue) do
491
+ raise Phronomy::TimeoutError,
492
+ "Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
493
+ end
494
+ raise result if result.is_a?(Exception)
495
+ result
451
496
  end
452
497
 
453
498
  # Invokes this agent asynchronously and returns a {Phronomy::Task}.
@@ -487,60 +532,13 @@ module Phronomy
487
532
  end
488
533
  end
489
534
 
490
- # Registers this agent as a child {AgentFSM} inside the given Workflow context.
491
- #
492
- # Use this method from a Workflow entry action (running on the EventLoop thread)
493
- # instead of {#invoke}, which would raise a deadlock error because +invoke+ blocks
494
- # on a +Thread::Queue+ when EventLoop mode is active.
495
- #
496
- # The agent runs asynchronously in a background IO thread. When it finishes, the
497
- # parent {FSMSession} receives a +:child_completed+ event whose payload is the
498
- # result hash +{ output:, messages:, usage: }+. Declare an +on: :child_completed+
499
- # transition in your Workflow to advance to the next state.
500
- #
501
- # The result is delivered exclusively as the +:child_completed+ event payload.
502
- # The parent Workflow task is the sole owner of the parent +WorkflowContext+ and
503
- # applies the result after receiving the event — no background thread writes to
504
- # the parent context directly.
505
- #
506
- # @example
507
- # entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
508
- # transition from: :run_agent, on: :child_completed, to: :process_result
509
- #
510
- # @param input [String, Hash] user input passed to the agent
511
- # @param ctx [Object] a WorkflowContext that responds to +#thread_id+
512
- # @param messages [Array] prior conversation history
513
- # @param config [Hash] invocation config (forwarded to +_invoke_impl+)
514
- # @return [nil] the caller must not wait on any return value;
515
- # the result arrives as a +:child_completed+ event
516
- # @raise [Phronomy::Error] when EventLoop mode is not enabled
517
- # @api public
518
- def run_as_child(input, ctx:, messages: [], config: {})
519
- unless Phronomy.configuration.event_loop
520
- raise Phronomy::Error,
521
- "run_as_child requires EventLoop mode. " \
522
- "Enable with: Phronomy.configure { |c| c.event_loop = true }"
523
- end
524
-
525
- fsm = Agent::FSM.new(
526
- agent: self,
527
- input: input,
528
- messages: messages,
529
- thread_id: "#{ctx.thread_id}_agent_#{SecureRandom.uuid}",
530
- config: config,
531
- parent_id: ctx.thread_id
532
- )
533
- Phronomy::EventLoop.instance.enqueue_child(fsm)
534
- nil
535
- end
536
-
537
535
  # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
538
536
  # as they are produced by the underlying LLM.
539
537
  #
540
538
  # Events emitted (in order):
541
539
  # :token — each content delta from the LLM
542
- # :tool_call — when the LLM requests a tool (ReactAgent subclasses only)
543
- # :tool_result — after a tool completes (ReactAgent subclasses only)
540
+ # :tool_call — when the LLM requests a tool
541
+ # :tool_result — after a tool completes
544
542
  # :done — final event carrying output, messages, and usage
545
543
  # :error — if an unrecoverable error occurs
546
544
  #
@@ -587,42 +585,6 @@ module Phronomy
587
585
  [effective_thread_id, effective_config]
588
586
  end
589
587
 
590
- def _invoke_via_event_loop(input, messages:, thread_id:, config:)
591
- if Phronomy::EventLoop.current?
592
- raise Phronomy::Error,
593
- "Cannot call Agent#invoke (EventLoop mode) from within an EventLoop " \
594
- "entry action. Use agent.run_as_child(input, ctx: ctx) instead."
595
- end
596
-
597
- timeout_sec = self.class.invoke_timeout
598
- effective_config, scope = if timeout_sec
599
- s = Phronomy::Concurrency::CancellationScope.new(parent_token: config[:cancellation_token])
600
- s.deadline_in(timeout_sec)
601
- [config.merge(cancellation_token: s.token), s]
602
- else
603
- [config, nil]
604
- end
605
-
606
- fsm = Agent::FSM.new(
607
- agent: self,
608
- input: input,
609
- messages: messages,
610
- thread_id: thread_id || SecureRandom.uuid,
611
- config: effective_config
612
- )
613
- completion_queue = Phronomy::EventLoop.instance.register(fsm)
614
- result = if scope
615
- scope.pop_queue(completion_queue) do
616
- raise Phronomy::TimeoutError,
617
- "Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
618
- end
619
- else
620
- completion_queue.pop
621
- end
622
- raise result if result.is_a?(Exception)
623
- result
624
- end
625
-
626
588
  def _check_scheduler_reentrancy
627
589
  return unless Phronomy::Task.current
628
590
 
@@ -851,12 +813,30 @@ module Phronomy
851
813
  # wrap it in a retry loop without duplicating the LLM interaction logic.
852
814
  def invoke_once(input, messages: [], thread_id: nil, config: {})
853
815
  trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
854
- Agent::InvocationPipeline.new(self).run(
816
+ run_input_guardrails!(input)
817
+
818
+ user_message = extract_message(input)
819
+ chat = build_chat
820
+ context = build_context(
855
821
  input,
856
- messages: messages,
857
- thread_id: thread_id,
858
- config: config
822
+ messages: messages, thread_id: thread_id, config: config,
823
+ budget: build_token_budget, instruction: build_instructions(input),
824
+ tools: self.class.tools + _handoff_tools
825
+ )
826
+ _apply_context_to_chat(chat, context)
827
+
828
+ run_before_completion_hooks!(chat, config)
829
+ _register_suspension_hook!(chat)
830
+ check_cancellation!(config, "invocation cancelled before LLM call")
831
+
832
+ result, usage = _complete_with_suspension_guard(
833
+ chat, user_message, config,
834
+ thread_id: thread_id, original_input: input
859
835
  )
836
+ next [result, usage] if result[:suspended]
837
+
838
+ run_output_guardrails!(result[:output])
839
+ [result, usage]
860
840
  end
861
841
  end
862
842
 
@@ -877,6 +857,36 @@ module Phronomy
877
857
  context[:messages].each { |msg| chat.messages << msg }
878
858
  end
879
859
 
860
+ # Submits the LLM call via LLMAdapter and handles SuspendSignal.
861
+ # Sets/clears the chat cancellation token around the call so that
862
+ # ParallelToolChat can observe cancellation without Thread.current.
863
+ # Returns [result_hash, usage_or_nil].
864
+ def _complete_with_suspension_guard(chat, user_message, config, thread_id:, original_input:)
865
+ chat.cancellation_token = config[:cancellation_token] if chat.respond_to?(:cancellation_token=)
866
+ begin
867
+ adapter = Phronomy.configuration.llm_adapter
868
+ response = adapter.complete_async(chat, user_message, config: config).await
869
+ rescue SuspendSignal => signal
870
+ checkpoint = Checkpoint.new(
871
+ checkpoint_id: SecureRandom.uuid,
872
+ agent_class: self.class.name,
873
+ requested_at: Time.now.utc,
874
+ thread_id: thread_id,
875
+ original_input: original_input,
876
+ messages: chat.messages.dup,
877
+ pending_tool_name: signal.tool_name,
878
+ pending_tool_args: signal.args,
879
+ pending_tool_call_id: signal.tool_call_id
880
+ )
881
+ return [{output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}, nil]
882
+ ensure
883
+ chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
884
+ end
885
+ output = response.content
886
+ usage = Phronomy::TokenUsage.from_tokens(response.tokens)
887
+ [{output: output, messages: chat.messages, usage: usage}, usage]
888
+ end
889
+
880
890
  def _drain_stream(chat, user_message, config, &block)
881
891
  adapter = Phronomy.configuration.llm_adapter
882
892
  chunk_queue = Phronomy::Concurrency::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
@@ -920,12 +930,12 @@ module Phronomy
920
930
  end
921
931
 
922
932
  # Returns the chat class to instantiate for this invocation.
923
- # When EventLoop mode is enabled ({Phronomy.configuration.event_loop}),
933
+ # When {Phronomy.configuration.parallel_tool_execution} is true,
924
934
  # returns {ParallelToolChat} so that concurrent tool dispatch is enabled.
925
935
  # Falls back to +nil+ otherwise, signalling {#build_chat} to use the
926
936
  # standard +RubyLLM.chat+ factory.
927
937
  def build_chat_class
928
- Phronomy.configuration.event_loop ? Phronomy::MultiAgent::ParallelToolChat : nil
938
+ Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
929
939
  end
930
940
 
931
941
  def build_chat
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Phronomy
4
6
  module Agent
5
7
  # Encapsulates the suspended state of an agent invocation.
@@ -19,6 +21,18 @@ module Phronomy
19
21
  # end
20
22
  # puts result[:output]
21
23
  class Checkpoint
24
+ # @return [String] a globally unique identifier for this checkpoint;
25
+ # used as an idempotency key when guarding against duplicate resumes
26
+ attr_reader :checkpoint_id
27
+
28
+ # @return [String, nil] the fully-qualified name of the agent class that
29
+ # created this checkpoint (e.g. +"MyApp::ReviewAgent"+); used by the
30
+ # class-level +resume+ method to validate the correct agent is used
31
+ attr_reader :agent_class
32
+
33
+ # @return [Time] the UTC timestamp when this checkpoint was created
34
+ attr_reader :requested_at
35
+
22
36
  # @return [String, nil] the thread_id from the invocation config
23
37
  attr_reader :thread_id
24
38
 
@@ -41,6 +55,9 @@ module Phronomy
41
55
  # inject the tool result message on resume)
42
56
  attr_reader :pending_tool_call_id
43
57
 
58
+ # @param checkpoint_id [String] unique identifier; defaults to a new UUID
59
+ # @param agent_class [String, nil] fully-qualified agent class name
60
+ # @param requested_at [Time] when the checkpoint was created; defaults to +Time.now.utc+
44
61
  # @param thread_id [String, nil]
45
62
  # @param original_input [String, Hash] the input passed to the original #invoke call
46
63
  # @param messages [Array<RubyLLM::Message>]
@@ -48,7 +65,11 @@ module Phronomy
48
65
  # @param pending_tool_args [Hash]
49
66
  # @param pending_tool_call_id [String]
50
67
  # @api public
51
- def initialize(thread_id:, original_input:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:)
68
+ def initialize(thread_id:, original_input:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:,
69
+ checkpoint_id: SecureRandom.uuid, agent_class: nil, requested_at: Time.now.utc)
70
+ @checkpoint_id = checkpoint_id
71
+ @agent_class = agent_class
72
+ @requested_at = requested_at
52
73
  @thread_id = thread_id
53
74
  @original_input = original_input
54
75
  @messages = messages.dup.freeze
@@ -71,6 +92,9 @@ module Phronomy
71
92
  # @api public
72
93
  def to_h
73
94
  {
95
+ checkpoint_id: @checkpoint_id,
96
+ agent_class: @agent_class,
97
+ requested_at: @requested_at&.iso8601,
74
98
  thread_id: @thread_id,
75
99
  original_input: @original_input,
76
100
  messages: @messages.map { |m| serialize_message(m) },
@@ -99,7 +123,12 @@ module Phronomy
99
123
  end
100
124
  }
101
125
  messages = Array(h[:messages]).map { |m| deserialize_message(m) }
126
+ requested_at_raw = h[:requested_at]
127
+ requested_at = requested_at_raw ? Time.parse(requested_at_raw.to_s).utc : nil
102
128
  new(
129
+ checkpoint_id: h[:checkpoint_id]&.to_s || SecureRandom.uuid,
130
+ agent_class: h[:agent_class]&.to_s,
131
+ requested_at: requested_at || Time.now.utc,
103
132
  thread_id: h[:thread_id],
104
133
  original_input: h[:original_input],
105
134
  messages: messages,
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Default in-memory idempotency store for {Checkpoint} resume operations.
6
+ #
7
+ # Tracks consumed checkpoint IDs so that calling {Agent::Base#resume} twice
8
+ # with the same checkpoint raises {Phronomy::CheckpointAlreadyResumedError}
9
+ # instead of silently executing the approved tool a second time.
10
+ #
11
+ # This implementation is *not thread-safe*. It assumes a single agent instance
12
+ # is accessed from only one thread at a time, which is the expected usage pattern.
13
+ # Agent instances themselves are not thread-safe (state like +@messages+, +@config+
14
+ # is not protected), so concurrent calls to the same agent instance are unsupported.
15
+ #
16
+ # Each agent instance gets its own store by default, so no sharing occurs unless
17
+ # the caller explicitly assigns the same store object to multiple agents.
18
+ #
19
+ # For distributed environments (multiple processes or background jobs), swap this
20
+ # for a custom implementation backed by Redis, ActiveRecord, or another shared store.
21
+ # *Your custom store implementation is responsible for ensuring thread-safety* if
22
+ # your application shares the same store instance across multiple threads.
23
+ #
24
+ # @example Plugging in a custom store
25
+ # agent = MyAgent.new
26
+ # agent.checkpoint_store = MyRedis::CheckpointStore.new
27
+ #
28
+ # @example Duck-type contract required by any replacement
29
+ # # consumed?(checkpoint_id) => Boolean
30
+ # # consume!(checkpoint_id) => void; raises CheckpointAlreadyResumedError if duplicate
31
+ # # cleanup!(checkpoint_id) => void (optional); removes tracking for the checkpoint
32
+ # # clear! => void (optional); removes all tracked checkpoints
33
+ #
34
+ # @api public
35
+ class CheckpointStore
36
+ def initialize
37
+ @consumed = Set.new
38
+ end
39
+
40
+ # Returns +true+ if the given checkpoint ID has already been consumed.
41
+ #
42
+ # @param checkpoint_id [String]
43
+ # @return [Boolean]
44
+ # @api public
45
+ def consumed?(checkpoint_id)
46
+ @consumed.include?(checkpoint_id)
47
+ end
48
+
49
+ # Marks +checkpoint_id+ as consumed, or raises if it was already consumed.
50
+ #
51
+ # @param checkpoint_id [String]
52
+ # @raise [Phronomy::CheckpointAlreadyResumedError]
53
+ # @return [void]
54
+ # @api public
55
+ def consume!(checkpoint_id)
56
+ if @consumed.include?(checkpoint_id)
57
+ raise Phronomy::CheckpointAlreadyResumedError,
58
+ "checkpoint #{checkpoint_id} has already been resumed"
59
+ end
60
+ @consumed.add(checkpoint_id)
61
+ nil
62
+ end
63
+
64
+ # Removes tracking for a specific checkpoint ID.
65
+ #
66
+ # Use this to explicitly discard a checkpoint when the application
67
+ # determines it is no longer needed (e.g., user abandons an approval
68
+ # workflow).
69
+ #
70
+ # This method is optional in the duck-type contract. Custom store
71
+ # implementations may choose not to implement it.
72
+ #
73
+ # @param checkpoint_id [String]
74
+ # @return [void]
75
+ # @api public
76
+ def cleanup!(checkpoint_id)
77
+ @consumed.delete(checkpoint_id)
78
+ nil
79
+ end
80
+
81
+ # Removes all tracked checkpoint IDs.
82
+ #
83
+ # Use this for test cleanup, periodic maintenance, or application
84
+ # shutdown.
85
+ #
86
+ # This method is optional in the duck-type contract. Custom store
87
+ # implementations may choose not to implement it.
88
+ #
89
+ # @return [void]
90
+ # @api public
91
+ def clear!
92
+ @consumed.clear
93
+ nil
94
+ end
95
+ end
96
+ end
97
+ end
@@ -49,7 +49,7 @@ module Phronomy
49
49
 
50
50
  private
51
51
 
52
- # Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
52
+ # Retry loop for #invoke.
53
53
  def _invoke_impl(input, messages: [], thread_id: nil, config: {})
54
54
  # Fail fast when the token is already cancelled before any LLM call.
55
55
  if (token = config[:cancellation_token]) && token.cancelled?