phronomy 0.13.0 → 0.15.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -8,6 +8,7 @@ require_relative "runtime/timer_queue"
8
8
  require_relative "runtime/scheduler_timer_adapter"
9
9
  require_relative "runtime/task_registry"
10
10
  require_relative "runtime/runtime_metrics"
11
+ require_relative "runtime/shutdown_result"
11
12
  require_relative "runtime/timer_service"
12
13
 
13
14
  module Phronomy
@@ -47,8 +48,68 @@ module Phronomy
47
48
  #
48
49
  # @return [Runtime]
49
50
  # @api private
50
- def self.instance
51
- @instance ||= begin
51
+ @instance_mutex = Mutex.new
52
+
53
+ class << self
54
+ def instance
55
+ instance_mutex.synchronize do
56
+ @instance ||= build_default_runtime
57
+ end
58
+ end
59
+
60
+ # Compatibility setter retained for existing tests.
61
+ def instance=(runtime)
62
+ replace_default_for_test(runtime)
63
+ end
64
+
65
+ # Test-only, non-creating access to the default Runtime.
66
+ def default_if_initialized_for_test
67
+ instance_mutex.synchronize { @instance }
68
+ end
69
+
70
+ # Test-only replacement. The caller owns both Runtime lifecycles.
71
+ def replace_default_for_test(runtime)
72
+ instance_mutex.synchronize do
73
+ previous = @instance
74
+ @instance = runtime
75
+ previous
76
+ end
77
+ end
78
+
79
+ # Test-only restoration of a previously captured Runtime.
80
+ def restore_default_for_test(runtime)
81
+ instance_mutex.synchronize { @instance = runtime }
82
+ end
83
+
84
+ def reset_default!(timeout: Phronomy.configuration.event_loop_stop_grace_seconds)
85
+ runtime = instance_mutex.synchronize { @instance }
86
+ return ShutdownResult.not_started unless runtime
87
+
88
+ result = runtime.shutdown(timeout: timeout)
89
+ unless result.cleanup_complete?
90
+ raise Phronomy::RuntimeShutdownError,
91
+ "Runtime cleanup is incomplete; default Runtime was retained"
92
+ end
93
+
94
+ instance_mutex.synchronize do
95
+ @instance = nil if @instance.equal?(runtime)
96
+ end
97
+ result
98
+ end
99
+
100
+ # Does not create a Runtime or EventLoop.
101
+ def in_event_loop_context?
102
+ runtime = instance_mutex.synchronize { @instance }
103
+ runtime&.event_loop_current? || false
104
+ end
105
+
106
+ private
107
+
108
+ def instance_mutex
109
+ @instance_mutex ||= Mutex.new
110
+ end
111
+
112
+ def build_default_runtime
52
113
  scheduler = case Phronomy.configuration.runtime_backend
53
114
  when :cooperative
54
115
  Phronomy.configuration.logger&.warn(
@@ -74,14 +135,6 @@ module Phronomy
74
135
  end
75
136
  end
76
137
 
77
- # Replaces the process-wide default Runtime. Useful in tests.
78
- # @param runtime [Runtime]
79
- # @return [Runtime]
80
- # @api private
81
- def self.instance=(runtime)
82
- @instance = runtime
83
- end
84
-
85
138
  # Returns +true+ when the calling thread is executing inside an active
86
139
  # scheduler task (i.e. {Task.current} is non-nil). Code running inside
87
140
  # a {Runtime#spawn} block is always in a scheduler context.
@@ -118,38 +171,29 @@ module Phronomy
118
171
  # @return [Scheduler]
119
172
  attr_reader :scheduler
120
173
 
174
+ # @return [Symbol] current Runtime lifecycle state
175
+ # @api private
176
+ def state
177
+ @lifecycle_mutex.synchronize { @state }
178
+ end
179
+
121
180
  # @param scheduler [Scheduler] execution backend (default: {ThreadScheduler})
122
181
  # @api private
123
182
  def initialize(scheduler: ThreadScheduler.new)
124
183
  @scheduler = scheduler
184
+ @event_loop_scheduler = ThreadScheduler.new
125
185
  @task_registry = TaskRegistry.new
126
186
  @metrics = RuntimeMetrics.new
127
- @gate_registry = Phronomy::Concurrency::GateRegistry.new
128
- @pool_registry = Phronomy::Concurrency::PoolRegistry.new
129
187
  @timer_service = TimerService.new(scheduler)
130
- end
131
-
132
- # Returns (or lazily creates) the {ConcurrencyGate} for the named resource.
133
- #
134
- # Gate caps are read from the global {Phronomy::Configuration} when the gate
135
- # is first accessed; subsequent calls return the cached gate. To change the
136
- # cap at runtime, call {#reset_gate} first.
137
- #
138
- # @param name [:agent, :tool, :workflow, :llm, :vector] resource name
139
- # @return [ConcurrencyGate]
140
- # @api private
141
- def gate(name)
142
- @gate_registry.get(name.to_sym)
143
- end
144
-
145
- # Drops the cached gate for +name+ so that the next call to {#gate} rebuilds
146
- # it from the current configuration. Useful in tests.
147
- #
148
- # @param name [Symbol]
149
- # @return [void]
150
- # @api private
151
- def reset_gate(name)
152
- @gate_registry.reset(name.to_sym)
188
+ @pool_registry = Phronomy::Concurrency::PoolRegistry.new(
189
+ timer_queue_provider: -> { @timer_service.timer_queue }
190
+ )
191
+ @lifecycle_mutex = Mutex.new
192
+ @shutdown_mutex = Mutex.new
193
+ @state = :running
194
+ @event_loop = nil
195
+ @failure = nil
196
+ @shutdown_result = nil
153
197
  end
154
198
 
155
199
  # Cooperative yield point.
@@ -226,6 +270,7 @@ module Phronomy
226
270
  # @return [TaskGroup]
227
271
  # @api private
228
272
  def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
273
+ ensure_accepting_work!
229
274
  TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
230
275
  end
231
276
 
@@ -246,6 +291,7 @@ module Phronomy
246
291
  # @return [Task]
247
292
  # @api private
248
293
  def spawn(name: nil, &block)
294
+ ensure_accepting_work!
249
295
  type = _task_type(name)
250
296
  spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
251
297
  @metrics.record_start(type)
@@ -302,11 +348,15 @@ module Phronomy
302
348
  # constructing a Runtime with custom pool options or by replacing the
303
349
  # shared Runtime via {.instance=} in tests.
304
350
  #
305
- # @param pool_size [Integer] worker thread count (default: 10)
306
- # @param queue_size [Integer] max pending operations (default: 100)
351
+ # @param pool_size [Integer] worker thread count
352
+ # (default: {Phronomy::Configuration#blocking_io_pool_size}, currently 10)
353
+ # @param queue_size [Integer] max pending operations
354
+ # (default: {Phronomy::Configuration#blocking_io_queue_size}, currently 100)
307
355
  # @return [BlockingAdapterPool]
308
356
  # @api private
309
- def blocking_io(pool_size: 10, queue_size: 100)
357
+ def blocking_io(pool_size: Phronomy.configuration.blocking_io_pool_size,
358
+ queue_size: Phronomy.configuration.blocking_io_queue_size)
359
+ ensure_accepting_work!
310
360
  @pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
311
361
  end
312
362
 
@@ -326,6 +376,7 @@ module Phronomy
326
376
  # @return [BlockingAdapterPool]
327
377
  # @api private
328
378
  def pool(name, size: 10, queue_size: 100)
379
+ ensure_accepting_work!
329
380
  @pool_registry.named_pool(name, size: size, queue_size: queue_size)
330
381
  end
331
382
 
@@ -346,33 +397,172 @@ module Phronomy
346
397
  # @return [TimerQueue, SchedulerTimerAdapter]
347
398
  # @api private
348
399
  def timer_queue
400
+ ensure_accepting_work!
349
401
  @timer_service.timer_queue
350
402
  end
351
403
 
352
- # Waits for all registered tasks to finish, then shuts down the
353
- # EventLoop (if active), blocking adapter pool, named pools, and timer queue
354
- # (if they were started).
355
- #
356
- # When EventLoop mode is enabled, all pending Workflow and Agent FSM events
357
- # are drained before pools are shut down, ensuring in-flight sessions
358
- # complete cleanly.
359
- #
360
- # Call this before process exit to avoid leaving orphaned threads or
361
- # pending work items.
362
- #
363
- # @return [void]
404
+ # Returns the Runtime-owned EventLoop, creating it once on first use.
405
+ # During draining an existing loop remains available, but an unused loop
406
+ # is never created after shutdown begins.
407
+ # @api private
408
+ def event_loop
409
+ @lifecycle_mutex.synchronize do
410
+ case @state
411
+ when :running
412
+ @event_loop ||= EventLoop.new(runtime: self)
413
+ when :draining
414
+ return @event_loop if @event_loop
415
+
416
+ raise Phronomy::RuntimeShutdownError,
417
+ "EventLoop was not initialized before Runtime shutdown began"
418
+ else
419
+ raise Phronomy::RuntimeShutdownError,
420
+ "Runtime is #{@state}; EventLoop is unavailable"
421
+ end
422
+ end
423
+ end
424
+
425
+ # Does not create an EventLoop.
426
+ # @api private
427
+ def event_loop_current?
428
+ event_loop = @lifecycle_mutex.synchronize { @event_loop }
429
+ event_loop&.current? || false
430
+ end
431
+
432
+ # Internal EventLoop service spawn. Always uses a real OS thread and is
433
+ # deliberately excluded from the normal TaskRegistry drain.
364
434
  # @api private
365
- def shutdown
366
- @task_registry.drain
367
- # Drain EventLoop events before stopping pools so that in-flight
368
- # Workflow / Agent FSM sessions can complete their final LLM calls.
369
- Phronomy::EventLoop.instance.stop(drain: true)
370
- @pool_registry.shutdown
371
- @timer_service.shutdown
435
+ def __spawn_event_loop_service(&block)
436
+ @event_loop_scheduler.spawn(name: "event-loop", parent: nil, &block)
437
+ end
438
+
439
+ # Called only for an unexpected dispatcher failure.
440
+ # @api private
441
+ def __event_loop_failed(error)
442
+ @lifecycle_mutex.synchronize do
443
+ return if @shutdown_result || @state == :terminated
444
+
445
+ @failure ||= error
446
+ @state = :failed
447
+ end
448
+ end
449
+
450
+ # Synchronous, bounded Runtime shutdown. Must be invoked from an external
451
+ # management thread, lifecycle hook, or test teardown—not a Phronomy Task.
452
+ #
453
+ # +timeout+ bounds TaskRegistry and EventLoop graceful shutdown. Existing
454
+ # pool and timer shutdown contracts are unchanged by this proposal.
455
+ # @return [Runtime::ShutdownResult]
456
+ # @api public
457
+ def shutdown(
458
+ timeout: Phronomy.configuration.event_loop_stop_grace_seconds,
459
+ cancel_grace: timeout
460
+ )
461
+ if Phronomy::Task.current
462
+ raise Phronomy::RuntimeShutdownReentrancyError,
463
+ "Runtime#shutdown must be called from an external management thread"
464
+ end
465
+ validate_timeout!(timeout, :timeout)
466
+ validate_timeout!(cancel_grace, :cancel_grace)
467
+
468
+ @shutdown_mutex.synchronize do
469
+ return @shutdown_result if @shutdown_result
470
+
471
+ deadline = monotonic_now + timeout
472
+ event_loop = @lifecycle_mutex.synchronize do
473
+ @state = :draining unless @state == :failed
474
+ @event_loop
475
+ end
476
+ event_loop&.begin_draining
477
+
478
+ task_status = drain_runtime_work(event_loop, deadline)
479
+
480
+ @lifecycle_mutex.synchronize do
481
+ @state = :stopping unless @state == :failed
482
+ end
483
+
484
+ event_loop_status = if event_loop
485
+ event_loop.shutdown(deadline: deadline, cancel_grace: cancel_grace)
486
+ else
487
+ :not_started
488
+ end
489
+
490
+ subsystem_error = shutdown_pools_and_timer
491
+ final_task_status = @task_registry.empty? ? :empty : task_status
492
+ cleanup_complete = final_task_status == :empty &&
493
+ (!event_loop || !event_loop.task_alive?) &&
494
+ event_loop_status != :cancel_timeout &&
495
+ subsystem_error.nil?
496
+
497
+ failure = @lifecycle_mutex.synchronize { @failure } || subsystem_error
498
+ runtime_outcome = if failure || event_loop_status == :failed
499
+ :failed
500
+ else
501
+ :terminated
502
+ end
503
+ result = ShutdownResult.new(
504
+ runtime_outcome: runtime_outcome,
505
+ cleanup_status: cleanup_complete ? :complete : :incomplete,
506
+ event_loop_status: event_loop_status,
507
+ task_registry_status: final_task_status,
508
+ error: failure
509
+ )
510
+
511
+ @lifecycle_mutex.synchronize do
512
+ @state = cleanup_complete ? runtime_outcome : :failed
513
+ @shutdown_result = result
514
+ end
515
+ result
516
+ end
372
517
  end
373
518
 
374
519
  private
375
520
 
521
+ def ensure_accepting_work!
522
+ current_state = @lifecycle_mutex.synchronize { @state }
523
+ return if %i[running draining].include?(current_state)
524
+
525
+ raise Phronomy::RuntimeShutdownError,
526
+ "Runtime is #{current_state}; new work is not accepted"
527
+ end
528
+
529
+ def drain_runtime_work(event_loop, deadline)
530
+ loop do
531
+ task_status = @task_registry.drain_until(deadline)
532
+ return :timeout if task_status == :timeout
533
+
534
+ event_loop_idle = !event_loop || event_loop.wait_until_idle(deadline)
535
+ return :timeout unless event_loop_idle
536
+ return :empty if @task_registry.empty?
537
+ end
538
+ end
539
+
540
+ def shutdown_pools_and_timer
541
+ error = nil
542
+ begin
543
+ @pool_registry.shutdown
544
+ rescue => e
545
+ error ||= e
546
+ ensure
547
+ begin
548
+ @timer_service.shutdown
549
+ rescue => e
550
+ error ||= e
551
+ end
552
+ end
553
+ error
554
+ end
555
+
556
+ def validate_timeout!(value, name)
557
+ return if value.is_a?(Numeric) && value >= 0
558
+
559
+ raise ArgumentError, "#{name} must be a non-negative Numeric"
560
+ end
561
+
562
+ def monotonic_now
563
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
564
+ end
565
+
376
566
  TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
377
567
  private_constant :TASK_TYPE_PREFIXES
378
568
 
@@ -212,17 +212,12 @@ module Phronomy
212
212
  # If this task fails or is cancelled, the mapped task also fails/is
213
213
  # cancelled with the same error. The block is never called in error cases.
214
214
  #
215
- # The primary use-case is transforming an agent result into a
216
- # {WorkflowContext} so that a Workflow entry action can return a Task
217
- # whose value is picked up by {FSMSession} via the existing
218
- # +:action_completed+ path:
215
+ # The transformation runs from the source Task's completion callback.
216
+ # It is a generic value-composition API; Workflow entry actions do not await
217
+ # either the source Task or the mapped Task.
219
218
  #
220
- # @example Returning agent output into a Workflow state field
221
- # entry :translate, ->(ctx) {
222
- # TranslationAgent.new.invoke_async(ctx.query).map do |result|
223
- # ctx.merge(answer: result[:output]) # returns WorkflowContext
224
- # end
225
- # }
219
+ # @example Transforming an Agent result outside a Workflow entry action
220
+ # output_task = agent.invoke_async("hello").map { |result| result[:output] }
226
221
  #
227
222
  # @yield [value] the completed value of this task
228
223
  # @yieldreturn [Object] the value for the mapped task
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Immutable event struct used for inter-FSM communication via EventLoop.
4
+ # Immutable event used for EventLoop communication.
5
5
  #
6
- # @param type [Symbol] event identifier (:start, :state_completed,
7
- # :finished, :halted, :error, or any user-defined name)
8
- # @param target_id [String] FSMSession identifier — matches WorkflowContext#thread_id
9
- # @param payload [Object] optional data attached to the event:
10
- # - final/halted context for :finished/:halted
11
- # - Exception for :error
12
- # - nil for :start / :state_completed
6
+ # User-defined Workflow events carry application-owned payloads unchanged.
7
+ # Correlation identifiers, stale-event decisions, and domain interpretation
8
+ # remain application concerns.
9
+ #
10
+ # @param type [Symbol] event identifier
11
+ # @param target_id [String] FSMSession identifier
12
+ # @param payload [Object] optional event data
13
13
  Event = Data.define(:type, :target_id, :payload)
14
14
  end