phronomy 0.11.0 → 0.12.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -8
  3. data/benchmark/bench_agent_invoke.rb +1 -0
  4. data/lib/phronomy/agent/base.rb +197 -83
  5. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  6. data/lib/phronomy/agent/concerns/retryable.rb +6 -2
  7. data/lib/phronomy/agent/invocation_context.rb +171 -0
  8. data/lib/phronomy/agent/invocation_session.rb +346 -0
  9. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  10. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  11. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  12. data/lib/phronomy/configuration.rb +0 -6
  13. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  14. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  15. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  16. data/lib/phronomy/engine/fsm_session.rb +248 -0
  17. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  18. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  19. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  20. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  21. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  22. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  23. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  24. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  25. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  26. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  27. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  28. data/lib/phronomy/generator_verifier.rb +20 -11
  29. data/lib/phronomy/multi_agent/orchestrator.rb +6 -6
  30. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  31. data/lib/phronomy/tools/mcp.rb +17 -10
  32. data/lib/phronomy/version.rb +1 -1
  33. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  34. data/lib/phronomy/workflow.rb +6 -3
  35. data/lib/phronomy/workflow_context.rb +7 -5
  36. data/lib/phronomy/workflow_runner.rb +77 -27
  37. data/lib/phronomy.rb +17 -9
  38. metadata +35 -35
  39. data/lib/phronomy/agent/checkpoint.rb +0 -208
  40. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  41. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  42. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  43. data/lib/phronomy/context.rb +0 -12
  44. data/lib/phronomy/tool.rb +0 -9
  45. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  46. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  47. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  48. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  49. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  50. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  51. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  52. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  53. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  54. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  55. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  56. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  57. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  58. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  59. /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
@@ -79,29 +79,50 @@ module Phronomy
79
79
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
80
80
 
81
81
  trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
82
- thread_id = config[:thread_id] || SecureRandom.uuid
83
- recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
84
-
85
- store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
86
- snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
87
- initial_fields = if snapshot && snapshot[:fields]
88
- snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
89
- else
90
- input
91
- end
92
-
93
- state = @state_class.new(**initial_fields)
94
- state.set_graph_metadata(thread_id: thread_id)
95
- result = if Phronomy.configuration.event_loop
96
- run_via_event_loop(state, recursion_limit: recursion_limit)
97
- else
98
- run_workflow(state, recursion_limit: recursion_limit)
99
- end
82
+ state, thread_id, recursion_limit, store = _build_initial_context(input, config)
83
+ result = run_via_event_loop(state, recursion_limit: recursion_limit)
100
84
  store&.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) if config[:thread_id]
101
85
  [result, nil]
102
86
  end
103
87
  end
104
88
 
89
+ # Registers the workflow with the EventLoop and returns a {Phronomy::Task}
90
+ # immediately without blocking the caller. The task resolves with the final
91
+ # context when the workflow finishes.
92
+ #
93
+ # This is the EventLoop-driven equivalent of spawning a thread around
94
+ # {#invoke}. No extra OS thread is created; the EventLoop's existing thread
95
+ # drives the execution.
96
+ #
97
+ # @param input [Hash] initial context field values
98
+ # @param config [Hash]
99
+ # @return [Phronomy::Task]
100
+ # @api private
101
+ def invoke_deferred(input, config: {})
102
+ state, thread_id, recursion_limit, store = _build_initial_context(input, config)
103
+ result_task = Phronomy::Task.deferred(name: "workflow-async:#{thread_id}")
104
+ Phronomy::EventLoop.instance.start
105
+ session = build_session_for(context: state, recursion_limit: recursion_limit)
106
+ if store && config[:thread_id]
107
+ # Wrap so that state is persisted when the task resolves.
108
+ persist_task = Phronomy::Task.deferred(name: "workflow-async-persist:#{thread_id}")
109
+ Phronomy::EventLoop.instance.register(session, completion: persist_task)
110
+ persist_task.on_complete do |result, error|
111
+ store.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) unless error
112
+ if error
113
+ result_task.backend.unblock(nil, error)
114
+ result_task.transition!(:failed, error: error)
115
+ else
116
+ result_task.backend.unblock(result, nil)
117
+ result_task.transition!(:completed, value: result)
118
+ end
119
+ end
120
+ else
121
+ Phronomy::EventLoop.instance.register(session, completion: result_task)
122
+ end
123
+ result_task
124
+ end
125
+
105
126
  # Generic resume. Equivalent to +send_event(state:, event: :resume, input:)+.
106
127
  # @param state [Object] halted context
107
128
  # @param input [Hash, nil] optional field updates to merge before resuming
@@ -142,13 +163,9 @@ module Phronomy
142
163
  event
143
164
  end
144
165
 
145
- if Phronomy.configuration.event_loop
146
- run_via_event_loop(state,
147
- recursion_limit: Phronomy.configuration.recursion_limit,
148
- resume_event: ev_to_fire, resume_phase: current_phase)
149
- else
150
- run_workflow(state, resume_event: ev_to_fire, resume_phase: current_phase)
151
- end
166
+ run_via_event_loop(state,
167
+ recursion_limit: Phronomy.configuration.recursion_limit,
168
+ resume_event: ev_to_fire, resume_phase: current_phase)
152
169
  end
153
170
 
154
171
  # Streaming execution. Yields { state: Symbol, context: Object } after each state action completes.
@@ -167,9 +184,26 @@ module Phronomy
167
184
 
168
185
  private
169
186
 
187
+ # Builds the initial WorkflowContext from input and config.
188
+ # Returns [state, thread_id, recursion_limit, store].
189
+ def _build_initial_context(input, config)
190
+ thread_id = config[:thread_id] || SecureRandom.uuid
191
+ recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
192
+ store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
193
+ snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
194
+ initial_fields = if snapshot && snapshot[:fields]
195
+ snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
196
+ else
197
+ input
198
+ end
199
+ state = @state_class.new(**initial_fields)
200
+ state.set_graph_metadata(thread_id: thread_id)
201
+ [state, thread_id, recursion_limit, store]
202
+ end
203
+
170
204
  # Builds an FSMSession for the given context. Used in EventLoop mode.
171
205
  def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
172
- Phronomy::Workflow::FSMSession.new(
206
+ Phronomy::FSMSession.new(
173
207
  id: context.thread_id,
174
208
  context: context,
175
209
  entry_point: @entry_point,
@@ -190,6 +224,9 @@ module Phronomy
190
224
  # Blocks the calling thread on a completion queue until the workflow
191
225
  # finishes, halts at a wait state, or raises an error.
192
226
  def run_via_event_loop(context, recursion_limit:, resume_event: nil, resume_phase: nil)
227
+ # Ensure EventLoop is running. In tests, reset_runtime! resets the
228
+ # singleton without restarting it; start is idempotent when already alive.
229
+ Phronomy::EventLoop.instance.start
193
230
  session = build_session_for(
194
231
  context: context, recursion_limit: recursion_limit,
195
232
  resume_event: resume_event, resume_phase: resume_phase
@@ -201,6 +238,19 @@ module Phronomy
201
238
  end
202
239
 
203
240
  def run_workflow(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
241
+ # Mark the current thread as a synchronous execution context.
242
+ # This allows WorkflowContext field mutations via setters without raising
243
+ # WorkflowContextOwnershipError (which would otherwise fire since EventLoop
244
+ # is always active and run_workflow runs on the caller's thread).
245
+ Thread.current[:phronomy_sync_execution] = Thread.current[:phronomy_sync_execution].to_i + 1
246
+ _run_workflow_body(ctx, resume_event: resume_event, resume_phase: resume_phase,
247
+ recursion_limit: recursion_limit, &event_block)
248
+ ensure
249
+ depth = Thread.current[:phronomy_sync_execution].to_i - 1
250
+ Thread.current[:phronomy_sync_execution] = (depth > 0) ? depth : nil
251
+ end
252
+
253
+ def _run_workflow_body(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
204
254
  if resume_event
205
255
  # -- Resume from a wait state -------------------------------------------
206
256
  # Fire the external event on a tracker positioned at the wait state.
@@ -231,7 +281,7 @@ module Phronomy
231
281
  "Action in state #{current_state.inspect} timed out after #{timeout_secs}s"
232
282
  end
233
283
  end
234
- task_result = result.await
284
+ task_result = result.wait_result
235
285
  ctx = task_result if task_result.is_a?(Phronomy::WorkflowContext)
236
286
  elsif result.is_a?(Phronomy::WorkflowContext)
237
287
  ctx = result
data/lib/phronomy.rb CHANGED
@@ -11,11 +11,18 @@ loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
11
11
  # RAG: Zeitwerk would infer "Rag" — override to "RAG".
12
12
  loader.inflector.inflect("rag" => "RAG")
13
13
  # FSMSession: Zeitwerk would infer "FsmSession" — override to "FSMSession".
14
+ # Phronomy::FSMSession is the top-level cooperative execution engine shared by
15
+ # WorkflowRunner and Agent::InvocationSession.
14
16
  loader.inflector.inflect("fsm_session" => "FSMSession")
15
17
  # LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
16
18
  loader.inflector.inflect("llm_adapter" => "LLMAdapter")
17
19
  # LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
18
20
  loader.inflector.inflect("ruby_llm" => "RubyLLM")
21
+ # Collapse engine/ so that its contents autoload directly under Phronomy::
22
+ # (no Engine:: prefix). e.g. engine/event_loop.rb => Phronomy::EventLoop.
23
+ # This allows the execution engine to be organised in its own subdirectory
24
+ # without changing any class names or callers.
25
+ loader.collapse("#{__dir__}/phronomy/engine")
19
26
  loader.setup
20
27
 
21
28
  require_relative "phronomy/version"
@@ -99,14 +106,6 @@ module Phronomy
99
106
  end
100
107
  end
101
108
 
102
- # Raised when {Agent::Base#resume} (or the class-level equivalent) is called
103
- # with a {Agent::Checkpoint} whose +checkpoint_id+ has already been consumed
104
- # by a previous +resume+ call on the same store.
105
- #
106
- # This protects against duplicate resume executions caused by webhook retries
107
- # or queue message redelivery.
108
- class CheckpointAlreadyResumedError < Error; end
109
-
110
109
  # Raised when an operation is submitted to a {BlockingAdapterPool} that has
111
110
  # already been shut down via {BlockingAdapterPool#shutdown}.
112
111
  class PoolShutdownError < Error; end
@@ -187,8 +186,17 @@ module Phronomy
187
186
  # config.around { |ex| Phronomy.reset_runtime! ; ex.run ; Phronomy.reset_runtime! }
188
187
  # @api public
189
188
  def reset_runtime!
190
- Phronomy::EventLoop.reset!
189
+ # Do NOT stop the EventLoop here. Since Phase 2, all Agent#invoke calls
190
+ # go through FSMSession + EventLoop. Stopping and restarting the EventLoop
191
+ # on every after(:each) hook would add thread creation overhead per test.
192
+ # run_via_event_loop and _invoke_via_fsm restart it lazily if it was reset.
193
+ #
194
+ # Preserve event_loop_stop_grace_seconds from the current configuration
195
+ # so that test suites that set it to 0 (for fast teardown) keep that value
196
+ # across configuration resets.
197
+ prev_grace = @configuration&.event_loop_stop_grace_seconds
191
198
  @configuration = Configuration.new
199
+ @configuration.event_loop_stop_grace_seconds = prev_grace if prev_grace
192
200
  end
193
201
  end
194
202
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-16 00:00:00.000000000 Z
11
+ date: 2026-07-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -105,34 +105,54 @@ files:
105
105
  - lib/phronomy/agent.rb
106
106
  - lib/phronomy/agent/base.rb
107
107
  - lib/phronomy/agent/before_completion_context.rb
108
- - lib/phronomy/agent/checkpoint.rb
109
- - lib/phronomy/agent/checkpoint_store.rb
110
108
  - lib/phronomy/agent/concerns/before_completion.rb
111
109
  - lib/phronomy/agent/concerns/error_translation.rb
112
110
  - lib/phronomy/agent/concerns/filterable.rb
113
111
  - lib/phronomy/agent/concerns/retryable.rb
114
- - lib/phronomy/agent/concerns/suspendable.rb
115
112
  - lib/phronomy/agent/context/capability/base.rb
116
113
  - lib/phronomy/agent/context/capability/scope_policy.rb
117
114
  - lib/phronomy/agent/context/instruction/prompt_template.rb
118
115
  - lib/phronomy/agent/context/knowledge/base.rb
119
116
  - lib/phronomy/agent/context/knowledge/entity_knowledge.rb
120
117
  - lib/phronomy/agent/context/knowledge/static_knowledge.rb
118
+ - lib/phronomy/agent/invocation_context.rb
119
+ - lib/phronomy/agent/invocation_session.rb
120
+ - lib/phronomy/agent/phase_machine_builder.rb
121
121
  - lib/phronomy/agent/runner.rb
122
122
  - lib/phronomy/agent/shared_state.rb
123
- - lib/phronomy/agent/suspend_signal.rb
123
+ - lib/phronomy/agent/suspended_session_registry.rb
124
+ - lib/phronomy/agent/tool_call_intercepted.rb
124
125
  - lib/phronomy/agent/tool_executor.rb
125
- - lib/phronomy/concurrency/async_queue.rb
126
- - lib/phronomy/concurrency/blocking_adapter_pool.rb
127
- - lib/phronomy/concurrency/cancellation_scope.rb
128
- - lib/phronomy/concurrency/cancellation_token.rb
129
- - lib/phronomy/concurrency/concurrency_gate.rb
130
- - lib/phronomy/concurrency/deadline.rb
131
- - lib/phronomy/concurrency/gate_registry.rb
132
- - lib/phronomy/concurrency/pool_registry.rb
133
126
  - lib/phronomy/configuration.rb
134
- - lib/phronomy/context.rb
135
127
  - lib/phronomy/diagnostics.rb
128
+ - lib/phronomy/engine/concurrency/async_queue.rb
129
+ - lib/phronomy/engine/concurrency/blocking_adapter_pool.rb
130
+ - lib/phronomy/engine/concurrency/cancellation_scope.rb
131
+ - lib/phronomy/engine/concurrency/cancellation_token.rb
132
+ - lib/phronomy/engine/concurrency/concurrency_gate.rb
133
+ - lib/phronomy/engine/concurrency/deadline.rb
134
+ - lib/phronomy/engine/concurrency/gate_registry.rb
135
+ - lib/phronomy/engine/concurrency/pool_registry.rb
136
+ - lib/phronomy/engine/event_loop.rb
137
+ - lib/phronomy/engine/fsm_session.rb
138
+ - lib/phronomy/engine/runtime.rb
139
+ - lib/phronomy/engine/runtime/deterministic_scheduler.rb
140
+ - lib/phronomy/engine/runtime/fake_scheduler.rb
141
+ - lib/phronomy/engine/runtime/runtime_metrics.rb
142
+ - lib/phronomy/engine/runtime/scheduler.rb
143
+ - lib/phronomy/engine/runtime/scheduler_timer_adapter.rb
144
+ - lib/phronomy/engine/runtime/task_registry.rb
145
+ - lib/phronomy/engine/runtime/thread_scheduler.rb
146
+ - lib/phronomy/engine/runtime/timer_queue.rb
147
+ - lib/phronomy/engine/runtime/timer_service.rb
148
+ - lib/phronomy/engine/task.rb
149
+ - lib/phronomy/engine/task/backend.rb
150
+ - lib/phronomy/engine/task/deferred_backend.rb
151
+ - lib/phronomy/engine/task/fiber_backend.rb
152
+ - lib/phronomy/engine/task/immediate_backend.rb
153
+ - lib/phronomy/engine/task/mapped_backend.rb
154
+ - lib/phronomy/engine/task/thread_backend.rb
155
+ - lib/phronomy/engine/task_group.rb
136
156
  - lib/phronomy/eval.rb
137
157
  - lib/phronomy/eval/comparison.rb
138
158
  - lib/phronomy/eval/dataset.rb
@@ -146,7 +166,6 @@ files:
146
166
  - lib/phronomy/eval/scorer/includes_scorer.rb
147
167
  - lib/phronomy/eval/scorer/llm_judge.rb
148
168
  - lib/phronomy/event.rb
149
- - lib/phronomy/event_loop.rb
150
169
  - lib/phronomy/filter.rb
151
170
  - lib/phronomy/filter/base.rb
152
171
  - lib/phronomy/filter/prompt_injection_filter.rb
@@ -171,31 +190,13 @@ files:
171
190
  - lib/phronomy/output_parser/structured_parser.rb
172
191
  - lib/phronomy/ruby_llm_patches.rb
173
192
  - lib/phronomy/runnable.rb
174
- - lib/phronomy/runtime.rb
175
- - lib/phronomy/runtime/deterministic_scheduler.rb
176
- - lib/phronomy/runtime/fake_scheduler.rb
177
- - lib/phronomy/runtime/runtime_metrics.rb
178
- - lib/phronomy/runtime/scheduler.rb
179
- - lib/phronomy/runtime/scheduler_timer_adapter.rb
180
- - lib/phronomy/runtime/task_registry.rb
181
- - lib/phronomy/runtime/thread_scheduler.rb
182
- - lib/phronomy/runtime/timer_queue.rb
183
- - lib/phronomy/runtime/timer_service.rb
184
193
  - lib/phronomy/state_store/base.rb
185
194
  - lib/phronomy/state_store/in_memory.rb
186
- - lib/phronomy/task.rb
187
- - lib/phronomy/task/backend.rb
188
- - lib/phronomy/task/fiber_backend.rb
189
- - lib/phronomy/task/immediate_backend.rb
190
- - lib/phronomy/task/mapped_backend.rb
191
- - lib/phronomy/task/thread_backend.rb
192
- - lib/phronomy/task_group.rb
193
195
  - lib/phronomy/testing.rb
194
196
  - lib/phronomy/testing/fake_clock.rb
195
197
  - lib/phronomy/testing/fake_scheduler.rb
196
198
  - lib/phronomy/testing/scheduler_helpers.rb
197
199
  - lib/phronomy/token_usage.rb
198
- - lib/phronomy/tool.rb
199
200
  - lib/phronomy/tools/agent.rb
200
201
  - lib/phronomy/tools/mcp.rb
201
202
  - lib/phronomy/tools/vector_search.rb
@@ -221,7 +222,6 @@ files:
221
222
  - lib/phronomy/vector_store/splitter/recursive_splitter.rb
222
223
  - lib/phronomy/version.rb
223
224
  - lib/phronomy/workflow.rb
224
- - lib/phronomy/workflow/fsm_session.rb
225
225
  - lib/phronomy/workflow/phase_machine_builder.rb
226
226
  - lib/phronomy/workflow_context.rb
227
227
  - lib/phronomy/workflow_runner.rb
@@ -1,208 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Phronomy
6
- module Agent
7
- # Encapsulates the suspended state of an agent invocation.
8
- #
9
- # A Checkpoint is returned as the +:checkpoint+ key of the result hash when
10
- # an approval-required tool is encountered and no synchronous
11
- # on_approval_required handler has been registered.
12
- #
13
- # Pass the checkpoint to Agent::Base#resume to continue execution after
14
- # obtaining an approval decision from the user or an external system.
15
- #
16
- # @example Suspend and resume
17
- # result = agent.invoke("Do task X")
18
- # if result[:suspended]
19
- # approved = prompt_user(result[:checkpoint].pending_tool_name)
20
- # result = agent.resume(result[:checkpoint], approved: approved)
21
- # end
22
- # puts result[:output]
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
-
36
- # @return [String, nil] the thread_id from the invocation config
37
- attr_reader :thread_id
38
-
39
- # @return [String, Hash] the original input passed to #invoke; stored so
40
- # that #resume can re-apply dynamic system instructions (e.g. Proc or
41
- # PromptTemplate-based instructions that depend on the input value).
42
- attr_reader :original_input
43
-
44
- # @return [Array<RubyLLM::Message>] conversation messages up to and including
45
- # the assistant message that requested the pending tool call
46
- attr_reader :messages
47
-
48
- # @return [String] the name of the tool awaiting approval
49
- attr_reader :pending_tool_name
50
-
51
- # @return [Hash] the arguments the LLM passed to the pending tool
52
- attr_reader :pending_tool_args
53
-
54
- # @return [String] the tool_call_id from the LLM response (required to
55
- # inject the tool result message on resume)
56
- attr_reader :pending_tool_call_id
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+
61
- # @param thread_id [String, nil]
62
- # @param original_input [String, Hash] the input passed to the original #invoke call
63
- # @param messages [Array<RubyLLM::Message>]
64
- # @param pending_tool_name [String]
65
- # @param pending_tool_args [Hash]
66
- # @param pending_tool_call_id [String]
67
- # @api public
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
73
- @thread_id = thread_id
74
- @original_input = original_input
75
- @messages = messages.dup.freeze
76
- @pending_tool_name = pending_tool_name
77
- @pending_tool_args = pending_tool_args
78
- @pending_tool_call_id = pending_tool_call_id
79
- end
80
-
81
- # Converts this checkpoint to a plain Hash suitable for JSON / Marshal serialization.
82
- #
83
- # All values are plain Ruby objects (String, Symbol, Hash, Array, Numeric,
84
- # nil). +RubyLLM::Message+ objects in +:messages+ are deep-converted so that
85
- # any embedded +RubyLLM::ToolCall+ objects are also serialized as plain hashes.
86
- #
87
- # @example Round-trip via JSON
88
- # json = JSON.generate(checkpoint.to_h)
89
- # checkpoint2 = Phronomy::Agent::Checkpoint.from_h(JSON.parse(json))
90
- #
91
- # @return [Hash]
92
- # @api public
93
- def to_h
94
- {
95
- checkpoint_id: @checkpoint_id,
96
- agent_class: @agent_class,
97
- requested_at: @requested_at&.iso8601,
98
- thread_id: @thread_id,
99
- original_input: @original_input,
100
- messages: @messages.map { |m| serialize_message(m) },
101
- pending_tool_name: @pending_tool_name,
102
- pending_tool_args: @pending_tool_args,
103
- pending_tool_call_id: @pending_tool_call_id
104
- }
105
- end
106
-
107
- # Reconstructs a +Checkpoint+ from a plain Hash (e.g. produced by {#to_h}
108
- # and deserialized from JSON or Marshal).
109
- #
110
- # Hash keys may be either Symbols or Strings; both are accepted.
111
- # +RubyLLM::ToolCall+ objects inside message +:tool_calls+ arrays are
112
- # reconstructed from their hash representations.
113
- #
114
- # @param h [Hash] a hash previously produced by {#to_h}
115
- # @return [Checkpoint]
116
- # @api public
117
- def self.from_h(h)
118
- h = h.transform_keys { |k|
119
- begin
120
- k.to_sym
121
- rescue
122
- k
123
- end
124
- }
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
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,
132
- thread_id: h[:thread_id],
133
- original_input: h[:original_input],
134
- messages: messages,
135
- pending_tool_name: h[:pending_tool_name]&.to_s,
136
- pending_tool_args: h[:pending_tool_args] ? h[:pending_tool_args].transform_keys { |k|
137
- begin
138
- k.to_sym
139
- rescue
140
- k
141
- end
142
- } : {},
143
- pending_tool_call_id: h[:pending_tool_call_id]&.to_s
144
- )
145
- end
146
-
147
- private
148
-
149
- # Converts a +RubyLLM::Message+ to a plain Hash, ensuring that any
150
- # embedded +RubyLLM::ToolCall+ objects in +:tool_calls+ are also converted.
151
- #
152
- # @param msg [RubyLLM::Message]
153
- # @return [Hash]
154
- # @api private
155
- def serialize_message(msg)
156
- h = msg.to_h
157
- return h unless h[:tool_calls]
158
-
159
- h.merge(tool_calls: h[:tool_calls].map { |tc|
160
- tc.respond_to?(:to_h) ? tc.to_h : tc
161
- })
162
- end
163
-
164
- # Reconstructs a +RubyLLM::Message+ from a plain Hash.
165
- # +RubyLLM::ToolCall+ entries in +:tool_calls+ are re-instantiated.
166
- #
167
- # @param h [Hash]
168
- # @return [RubyLLM::Message]
169
- # @api private
170
- def self.deserialize_message(h)
171
- h = h.transform_keys { |k|
172
- begin
173
- k.to_sym
174
- rescue
175
- k
176
- end
177
- }
178
- if h[:tool_calls]
179
- h = h.merge(tool_calls: Array(h[:tool_calls]).map { |tc|
180
- next tc if tc.is_a?(RubyLLM::ToolCall)
181
-
182
- tc = tc.transform_keys { |k|
183
- begin
184
- k.to_sym
185
- rescue
186
- k
187
- end
188
- }
189
- RubyLLM::ToolCall.new(
190
- id: tc[:id].to_s,
191
- name: tc[:name].to_s,
192
- arguments: (tc[:arguments] || {}).transform_keys { |k|
193
- begin
194
- k.to_sym
195
- rescue
196
- k
197
- end
198
- },
199
- thought_signature: tc[:thought_signature]
200
- )
201
- })
202
- end
203
- RubyLLM::Message.new(h)
204
- end
205
- private_class_method :deserialize_message
206
- end
207
- end
208
- end
@@ -1,97 +0,0 @@
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