phronomy 0.7.0 → 0.8.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Developer-facing diagnostics for blocking operation detection (Issue #279).
5
+ #
6
+ # Provides debug dump utilities that can be called from an IRB / Rails console
7
+ # or in test helpers to inspect the current state of the Runtime.
8
+ #
9
+ # @example Enable diagnostics and print a dump
10
+ # Phronomy.configure { |c| c.scheduler_debug = true }
11
+ # Phronomy::Diagnostics.dump
12
+ module Diagnostics
13
+ # Prints a formatted summary of the current Runtime state to +$stderr+
14
+ # (or the supplied IO).
15
+ #
16
+ # Includes:
17
+ # - BlockingAdapterPool: active workers, queue depth, abandoned count
18
+ # - EventLoop: last / max / average lag in milliseconds
19
+ #
20
+ # @param out [IO] output destination (default: $stderr)
21
+ # @return [void]
22
+ # @api public
23
+ def self.dump(out: $stderr)
24
+ snap = Phronomy::Metrics.snapshot
25
+
26
+ out.puts "[Phronomy::Diagnostics] Runtime state dump"
27
+ out.puts " BlockingAdapterPool:"
28
+ out.puts " pool_size : #{snap[:blocking_pool_size]}"
29
+ out.puts " active_count : #{snap[:blocking_pool_active]}"
30
+ out.puts " queue_depth : #{snap[:blocking_pool_queue_length]}"
31
+ out.puts " abandoned_total : #{snap[:blocking_pool_abandoned_total]}"
32
+ out.puts " EventLoop:"
33
+ out.puts " last_lag_ms : #{snap[:event_loop_lag_last_ms]}"
34
+ out.puts " max_lag_ms : #{snap[:event_loop_lag_max_ms]}"
35
+ out.puts " average_lag_ms : #{snap[:event_loop_lag_average_ms]}"
36
+ end
37
+
38
+ # Returns the diagnostics state as a plain Hash (useful for JSON export).
39
+ #
40
+ # @return [Hash]
41
+ # @api public
42
+ def self.snapshot
43
+ Phronomy::Metrics.snapshot
44
+ end
45
+
46
+ # Raises an error if +invoke+ (blocking) is called from inside an EventLoop
47
+ # action, preventing accidental scheduler stalls.
48
+ #
49
+ # Called by Agent::Base#invoke and Workflow#invoke before executing.
50
+ #
51
+ # @raise [Phronomy::SchedulerReentrancyError] when called from EventLoop thread
52
+ # @return [void]
53
+ # @api private
54
+ def self.assert_not_in_event_loop!
55
+ return unless Phronomy::EventLoop.current?
56
+
57
+ raise Phronomy::SchedulerReentrancyError,
58
+ "Blocking invoke called from inside an EventLoop action. " \
59
+ "Use invoke_async instead."
60
+ end
61
+ end
62
+ end
@@ -4,8 +4,8 @@ module Phronomy
4
4
  # Embeddings adapters for converting text into vector representations.
5
5
  #
6
6
  # Sub-classes are auto-loaded by Zeitwerk:
7
- # Phronomy::Embeddings::Base
8
- # Phronomy::Embeddings::RubyLLMEmbeddings
7
+ # Phronomy::Agent::Context::Knowledge::Embeddings::Base
8
+ # Phronomy::Agent::Context::Knowledge::Embeddings::RubyLLMEmbeddings
9
9
  module Embeddings
10
10
  end
11
11
  end
@@ -28,29 +28,30 @@ module Phronomy
28
28
  # @param concurrency [Integer] number of parallel threads (default: 1, sequential)
29
29
  # @return [Array<EvalResult>]
30
30
  # @api public
31
+ # mutant:disable - concurrency default value mutations (0/2) are genuine equivalent because sequential and concurrent paths produce identical results; if concurrency<=1 boundary mutations (==1 / <1 / <=0 / .eql? / .equal? / false / nil / <=2) are genuine equivalent because the concurrent path with concurrency=1 still produces the same Array<EvalResult> via each_slice(1); spawn name: mutations are genuine equivalent (name is only used for logging)
31
32
  def run(dataset, callable, concurrency: 1)
32
33
  cases = dataset.to_a
33
34
  return cases.map { |eval_case| run_one(eval_case, callable) } if concurrency <= 1
34
35
 
35
- # Run cases in slices of +concurrency+ threads. Each slice is joined
36
- # before the next starts, bounding peak thread count to +concurrency+.
37
- # Writing to pre-allocated slots (one per thread) is safe because each
38
- # thread writes to a unique index and all threads in a slice are joined
36
+ # Run cases in slices of +concurrency+ tasks. Each slice is joined
37
+ # before the next starts, bounding peak task count to +concurrency+.
38
+ # Writing to pre-allocated slots (one per task) is safe because each
39
+ # task writes to a unique index and all tasks in a slice are joined
39
40
  # before the next slice begins.
40
- # Exceptions in worker threads are collected and re-raised after all
41
- # threads in the slice are joined, preventing orphaned threads.
41
+ # Exceptions in worker tasks are collected and re-raised after all
42
+ # tasks in the slice are joined, preventing orphaned tasks.
42
43
  results = Array.new(cases.length)
43
44
  cases.each_with_index.each_slice(concurrency) do |batch|
44
45
  errors = []
45
46
  errors_mu = Mutex.new
46
- threads = batch.map do |eval_case, i|
47
- Thread.new do
47
+ tasks = batch.map do |eval_case, i|
48
+ Phronomy::Runtime.instance.spawn(name: "eval-case-#{i}") do
48
49
  results[i] = run_one(eval_case, callable)
49
50
  rescue => e
50
51
  errors_mu.synchronize { errors << e }
51
52
  end
52
53
  end
53
- threads.each(&:join)
54
+ tasks.each(&:join)
54
55
  raise errors.first if errors.any?
55
56
  end
56
57
  results
@@ -59,6 +60,7 @@ module Phronomy
59
60
  private
60
61
 
61
62
  # Evaluate a single EvalCase with the given callable and return an EvalResult.
63
+ # mutant:disable - multiple genuine equivalent mutations: latency_ms=+t0 or =t0 are genuine because :millisecond makes all values Integer so be_a(Integer) passes; (actual,usage)=result is genuine because Ruby multi-assign of a String yields usage=nil identical to extract(); score_safely input: nil/eval_case/absent are genuine because ExactMatch and IncludesScorer ignore the :input kwarg; EvalResult error: nil/absent and usage: nil are genuine because on a successful score run score_error and usage are already nil
62
64
  def run_one(eval_case, callable)
63
65
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
64
66
  result = callable.call(eval_case.input)
@@ -71,6 +73,7 @@ module Phronomy
71
73
  end
72
74
 
73
75
  # Normalises the callable's return value into [actual_string, usage_or_nil].
76
+ # mutant:disable - multiple genuine equivalent mutations: is_a?(Hash) vs instance_of?(Hash) (no Hash subclass in practice); to_s vs to_str (String only); result[:output]/[:usage] vs .fetch(:output)/[:usage] (keys always present when is_a?(Hash)); [result.to_s, nil] vs [result.to_s] because actual,usage=[val] → usage=nil via Ruby multi-assign; result.to_s vs result.to_str for String-only values
74
77
  def extract(result)
75
78
  if result.is_a?(Hash)
76
79
  [result[:output].to_s, result[:usage]]
@@ -80,6 +83,7 @@ module Phronomy
80
83
  end
81
84
 
82
85
  # Calls the scorer and returns [score, error]. On failure, returns [0.0, exception].
86
+ # mutant:disable - [scorer.score(**kwargs), nil] vs [scorer.score(**kwargs)]: because score,error=[val] → error=nil via Ruby multi-assign; both produce the same destructuring in the caller
83
87
  def score_safely(scorer, **kwargs)
84
88
  [scorer.score(**kwargs), nil]
85
89
  rescue => e
@@ -45,9 +45,20 @@ module Phronomy
45
45
 
46
46
  # @return [Float] score in [0.0, 1.0]; 0.0 on error when raise_on_error is false
47
47
  # @api public
48
+ # mutant:disable - multiple genuine equivalent mutations:
49
+ # actual.to_str / actual: (shorthand) are genuine (callers pass String);
50
+ # expected.to_str / expected: are genuine (String);
51
+ # response.content.strip (no to_s) is genuine (content is String);
52
+ # lstrip/rstrip/no-strip are genuine (whitespace doesn't affect number scanning);
53
+ # scan(/-?\d\.?\d*/) is genuine (for [0,1] range responses, single-digit-before-decimal
54
+ # matches are the same after clamp);
55
+ # response.content.to_str.strip is genuine (String);
56
+ # all warn variations (warn no-arg, warn(nil), warn(e), warn(nil literal),
57
+ # nil-replacing-warn, warn-deletion) are genuine because the rescue block
58
+ # still returns 0.0 — warn is a side-effect not tested by value assertions
48
59
  def score(actual:, expected:, input: nil)
49
60
  prompt = format(@prompt_template, input: input.to_s, expected: expected.to_s, actual: actual.to_s)
50
- response = RubyLLM.chat(model: @model).ask(prompt)
61
+ response = Phronomy::Runtime.instance.blocking_io.submit { RubyLLM.chat(model: @model).ask(prompt) }.await
51
62
  response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
52
63
  rescue => e
53
64
  raise if @raise_on_error
@@ -3,12 +3,37 @@
3
3
  module Phronomy
4
4
  # Singleton event loop that manages all FSMSession instances.
5
5
  #
6
- # A single background thread reads from a global Thread::Queue and dispatches
7
- # events to their target FSMSession. IO work (LLM calls, tool calls) runs in
8
- # separate IO threads that post events back to the loop via EventLoop#post.
6
+ # A single background thread reads from a global {Phronomy::Concurrency::AsyncQueue} and
7
+ # dispatches events to their target FSMSession. IO work (LLM calls, tool
8
+ # calls) must be dispatched via +Runtime.instance.spawn+ or
9
+ # +BlockingAdapterPool+, then post results back to the loop via
10
+ # {EventLoop#post}.
9
11
  #
10
12
  # Activated with: +Phronomy.configure { |c| c.event_loop = true }+
11
13
  #
14
+ # == Threading exception (see ADR-010 Rule 2)
15
+ #
16
+ # +EventLoop+ is a **deliberate exception** to Phronomy's cooperative-first
17
+ # concurrency model. Its dispatch loop is an infinite +while @running+ loop
18
+ # that must never block the framework's own event processing.
19
+ # Running it on a shared scheduler task would consume the scheduler, preventing
20
+ # other tasks from running. Therefore {#start} creates a dedicated
21
+ # {Runtime::ThreadScheduler} — this is correct and intentional per ADR-010.
22
+ # No other framework component should do the same; see the ADR-010 checklist.
23
+ #
24
+ # == Handler constraints
25
+ #
26
+ # Handlers dispatched by the EventLoop run **on the EventLoop thread**.
27
+ # They must not:
28
+ #
29
+ # * Perform blocking operations directly (database queries, LLM calls, HTTP
30
+ # requests). Schedule blocking work via +Runtime.instance.spawn+ or
31
+ # +BlockingAdapterPool+, then post results back with {#post}.
32
+ # * Call +Workflow#invoke+ (or any synchronous +invoke+) from within a
33
+ # handler. That method would block waiting for the EventLoop to process
34
+ # events, causing a deadlock. Use the async pattern: post a follow-up
35
+ # event instead.
36
+ #
12
37
  # == Fork safety
13
38
  #
14
39
  # +EventLoop.instance+ is lazily initialized. The background thread is not
@@ -20,14 +45,25 @@ module Phronomy
20
45
  # Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
21
46
  # entry action. The entry action runs on the EventLoop thread; a nested
22
47
  # +invoke+ would block waiting for the same thread to process events →
23
- # deadlock. Use the async IO pattern instead (spawn a Thread, post events
24
- # back to the EventLoop).
48
+ # deadlock. Use the async pattern instead: schedule work via
49
+ # +Runtime.instance.spawn+ or +BlockingAdapterPool+, then post events back
50
+ # via +Phronomy::EventLoop.instance.post(...)+.
25
51
  class EventLoop
26
52
  # Returns the singleton instance, creating and starting it on first call.
27
53
  def self.instance
28
54
  @instance ||= new.tap(&:start)
29
55
  end
30
56
 
57
+ # Returns true when called from within the EventLoop dispatch task.
58
+ # Uses a task-local key set by the Runtime-spawned dispatch task so that
59
+ # the check works correctly for both thread-based and future fiber-based
60
+ # scheduler backends.
61
+ # @return [Boolean]
62
+ # @api private
63
+ def self.current?
64
+ Phronomy::Task.current&.name == "event-loop"
65
+ end
66
+
31
67
  # Stops and destroys the singleton. Primarily used in tests.
32
68
  # @api private
33
69
  def self.reset!
@@ -36,7 +72,7 @@ module Phronomy
36
72
  end
37
73
 
38
74
  def initialize
39
- @queue = Thread::Queue.new # global event queue (thread-safe; no Mutex needed)
75
+ @queue = Phronomy::Concurrency::AsyncQueue.new # global event queue (thread-safe; no Mutex needed)
40
76
  @fsms = {} # { id => FSMSession } — EventLoop thread only
41
77
  @waiting = {} # { id => completion_queue } — EventLoop thread only
42
78
  # Mutex-backed FSM count for drain-mode shutdown.
@@ -44,7 +80,43 @@ module Phronomy
44
80
  @fsm_count_cond = ConditionVariable.new
45
81
  @fsm_count = 0
46
82
  # Token cancelled when shutdown is requested; new child sessions receive it.
47
- @shutdown_token = Phronomy::CancellationToken.new
83
+ @shutdown_token = Phronomy::Concurrency::CancellationToken.new
84
+ # Fairness metrics (EventLoop thread only, except where noted)
85
+ @lag_mutex = Mutex.new
86
+ @last_lag_ns = 0
87
+ @max_lag_ns = 0
88
+ @dispatch_count = 0
89
+ @total_lag_ns = 0
90
+ end
91
+
92
+ # Returns the most recently measured event-loop lag in seconds.
93
+ # Lag is the wall-clock time between {#post} and the moment the event
94
+ # is dequeued for dispatch. Thread-safe.
95
+ # @return [Float]
96
+ # @api private
97
+ def last_lag_seconds
98
+ @lag_mutex.synchronize { @last_lag_ns } / 1_000_000_000.0
99
+ end
100
+
101
+ # Returns the maximum event-loop lag seen since the loop was started.
102
+ # Thread-safe.
103
+ # @return [Float]
104
+ # @api private
105
+ def max_lag_seconds
106
+ @lag_mutex.synchronize { @max_lag_ns } / 1_000_000_000.0
107
+ end
108
+
109
+ # Returns the mean event-loop lag across all dispatched events since the
110
+ # loop was started. Returns 0.0 when no events have been dispatched.
111
+ # Thread-safe.
112
+ # @return [Float]
113
+ # @api private
114
+ def average_lag_seconds
115
+ @lag_mutex.synchronize do
116
+ return 0.0 if @dispatch_count.zero?
117
+
118
+ @total_lag_ns.to_f / @dispatch_count / 1_000_000_000.0
119
+ end
48
120
  end
49
121
 
50
122
  # Registers an FSMSession for execution and returns a completion queue.
@@ -57,22 +129,24 @@ module Phronomy
57
129
  # (WorkflowContext) once the workflow finishes or halts. If an error occurred,
58
130
  # the popped value will be an Exception — callers are responsible for re-raising it.
59
131
  #
60
- # @param fsm_session [Phronomy::FSMSession]
61
- # @return [Thread::Queue] resolves to final/halted context, or an Exception
132
+ # @param fsm_session [Phronomy::Agent::Lifecycle::FSMSession]
133
+ # @return [Phronomy::Concurrency::AsyncQueue] resolves to final/halted context, or an Exception
62
134
  # @api private
63
135
  def register(fsm_session)
64
- if Thread.current[:phronomy_event_loop_thread]
136
+ if Phronomy::EventLoop.current?
65
137
  raise Phronomy::Error,
66
138
  "Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
67
- "entry action. Use the async IO pattern: spawn a Thread, post events " \
68
- "back via Phronomy::EventLoop.instance.post(...) instead."
139
+ "entry action. Schedule work via Runtime.instance.spawn or " \
140
+ "BlockingAdapterPool, then post events back via " \
141
+ "Phronomy::EventLoop.instance.post(...) instead."
69
142
  end
70
143
 
71
- completion_queue = Thread::Queue.new
144
+ completion_queue = Phronomy::Concurrency::AsyncQueue.new
72
145
  # Pass both session and completion_queue in the event payload so that the
73
146
  # EventLoop thread is the sole writer of @fsms and @waiting.
74
- @queue.push(Event.new(type: :start, target_id: fsm_session.id,
75
- payload: {session: fsm_session, completion: completion_queue}))
147
+ @queue.push([Event.new(type: :start, target_id: fsm_session.id,
148
+ payload: {session: fsm_session, completion: completion_queue}),
149
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
76
150
  completion_queue
77
151
  end
78
152
 
@@ -87,60 +161,77 @@ module Phronomy
87
161
  # @return [nil]
88
162
  # @api private
89
163
  def enqueue_child(agent_fsm)
90
- @queue.push(Event.new(type: :start, target_id: agent_fsm.id,
91
- payload: {session: agent_fsm, completion: nil}))
164
+ @queue.push([Event.new(type: :start, target_id: agent_fsm.id,
165
+ payload: {session: agent_fsm, completion: nil}),
166
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
92
167
  nil
93
168
  end
94
169
 
95
170
  # Posts an event to the loop. Safe to call from any thread (including IO threads).
171
+ # The current monotonic clock time is recorded so that the EventLoop can
172
+ # measure the dispatch lag when it dequeues the event.
96
173
  #
174
+ # @note **Handler constraint**: do not perform blocking operations or call
175
+ # +Workflow#invoke+ directly from within the handler that processes a
176
+ # posted event. Handlers run on the EventLoop thread; blocking there
177
+ # stalls all session processing. For blocking work, post a new event
178
+ # after the result is ready.
97
179
  # @param event [Phronomy::Event]
98
180
  # @api private
99
181
  def post(event)
100
- @queue.push(event)
182
+ @queue.push([event, Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
101
183
  end
102
184
 
103
- # Starts the background event loop thread.
185
+ # Starts the EventLoop dispatch task under {Runtime} ownership.
186
+ #
187
+ # The dispatch loop runs as a {Phronomy::Task} so that {Runtime#shutdown}
188
+ # can drain it together with all other in-flight tasks. The task is named
189
+ # +"event-loop"+ so that {.current?} can identify it via
190
+ # +Task.current&.name+.
104
191
  # @return [self]
105
192
  # @api private
106
193
  def start
107
- return self if @thread&.alive?
194
+ return self if @task&.alive?
108
195
 
109
196
  # Reset shutdown state so the loop can be restarted after a stop.
110
- @shutdown_token = Phronomy::CancellationToken.new
197
+ @shutdown_token = Phronomy::Concurrency::CancellationToken.new
111
198
  @fsm_count_mutex.synchronize { @fsm_count = 0 }
112
199
  @running = true
113
- @thread = Thread.new do
114
- Thread.current[:phronomy_event_loop_thread] = true
200
+ # The dispatch loop must always run in a real background thread.
201
+ # A cooperative scheduler (FakeScheduler/ImmediateBackend) executes tasks
202
+ # synchronously on the caller's thread, which would block forever inside
203
+ # the run_loop infinite loop. Create a dedicated Runtime with
204
+ # ThreadScheduler to guarantee async execution regardless of the global
205
+ # runtime_backend setting.
206
+ thread_runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::ThreadScheduler.new)
207
+ @task = thread_runtime.spawn(name: "event-loop") do
115
208
  run_loop
116
209
  end
117
- @thread.abort_on_exception = false
118
210
  self
119
211
  end
120
212
 
121
- # Stops the background thread. Used in tests only.
213
+ # Stops the EventLoop dispatch task.
122
214
  #
123
215
  # Sends a cooperative shutdown sentinel to the event queue so that the
124
- # worker thread can finish any in-flight handler before exiting. Waits up
125
- # to +timeout+ seconds for a clean shutdown; if the thread is still alive
126
- # afterwards it is force-killed as a last resort.
216
+ # dispatch task can finish any in-flight handler before exiting. Waits up
217
+ # to +timeout+ seconds for a clean shutdown; if the task is still alive
218
+ # afterwards it is cancelled (cooperative cancellation via {Task#cancel!}).
127
219
  #
128
220
  # @param timeout [Numeric] seconds to wait for cooperative shutdown. Defaults
129
221
  # to +Phronomy.configuration.event_loop_stop_grace_seconds+ (5 s).
130
222
  # @param drain [Boolean] when +true+, wait for all active FSMSessions to
131
223
  # complete before signalling the loop to stop. Bounded by +timeout+.
132
224
  # Defaults to +false+.
133
- # @param force_kill [Boolean] when +true+, the worker thread is killed with
134
- # +Thread#kill+ if it does not stop within +timeout+. When +false+
135
- # (default), the thread is never killed; the method returns +:timeout+
136
- # instead. +false+ is safer for production because +Thread#kill+ can
137
- # interrupt +ensure+ blocks.
225
+ # @param force_kill [Boolean] deprecated retained for backward compatibility.
226
+ # When +true+, the dispatch task is cancelled via {Task#cancel!} if it does
227
+ # not stop within +timeout+. +Thread#kill+ is no longer used; cooperative
228
+ # cancellation (raising {CancellationError}) replaces it.
138
229
  # @return [Symbol] shutdown status:
139
230
  # - +:clean+ — loop exited cooperatively with no active sessions discarded
140
231
  # - +:drained_with_discards+ — drain mode requested but sessions remained;
141
232
  # they were discarded and the loop was stopped
142
- # - +:timeout+ — the worker thread did not stop in time and +force_kill:+ is +false+
143
- # - +:force_killed+ — the worker thread did not stop in time and was killed
233
+ # - +:timeout+ — the task did not stop in time and +force_kill:+ is +false+
234
+ # - +:force_killed+ — the task was cancelled because it did not stop in time
144
235
  # @api private
145
236
  def stop(timeout: Phronomy.configuration.event_loop_stop_grace_seconds, drain: false, force_kill: false)
146
237
  @shutdown_token.cancel!
@@ -160,31 +251,31 @@ module Phronomy
160
251
  end
161
252
 
162
253
  @running = false
163
- @queue.push(:__stop__) # unblock queue.pop so the worker can see @running = false
254
+ @queue.push(:__stop__) # unblock queue.pop so the task can see @running = false
164
255
  begin
165
- @thread&.join(timeout)
256
+ @task&.join(timeout)
166
257
  rescue
167
- # Thread may have terminated with an exception (e.g. simulated crash in
168
- # tests). Suppress the re-raise so the cleanup below always runs.
258
+ # Task may have terminated with an error (e.g. simulated crash in tests).
259
+ # Suppress the re-raise so the cleanup below always runs.
169
260
  nil
170
261
  end
171
- if @thread&.alive?
262
+ if @task&.alive?
172
263
  if force_kill
173
264
  Phronomy.configuration.logger&.warn(
174
- "[Phronomy] EventLoop thread did not stop within #{timeout}s; force-killing. " \
265
+ "[Phronomy] EventLoop task did not stop within #{timeout}s; cancelling. " \
175
266
  "This is a last resort — check for blocking operations in event handlers."
176
267
  )
177
- @thread.kill
268
+ @task.cancel!
178
269
  status = :force_killed
179
270
  else
180
271
  Phronomy.configuration.logger&.warn(
181
- "[Phronomy] EventLoop thread did not stop within #{timeout}s; abandoning " \
272
+ "[Phronomy] EventLoop task did not stop within #{timeout}s; abandoning " \
182
273
  "(force_kill: false). Check for blocking operations in event handlers."
183
274
  )
184
275
  status = :timeout
185
276
  end
186
277
  end
187
- @thread = nil
278
+ @task = nil
188
279
  status
189
280
  end
190
281
 
@@ -192,14 +283,22 @@ module Phronomy
192
283
 
193
284
  def run_loop
194
285
  while @running
195
- event = @queue.pop
286
+ item = @queue.pop
196
287
  # :__stop__ is used purely as an unblock signal for @queue.pop; the
197
288
  # actual stop condition is @running == false (set before the push).
198
289
  # Treating it as `next` instead of `break` prevents a stale sentinel
199
290
  # (left by a previous stop call that raced with thread start) from
200
291
  # immediately terminating a freshly restarted EventLoop.
201
- next if event == :__stop__
292
+ next if item == :__stop__
202
293
 
294
+ # item is [event, posted_at_ns] — unwrap and measure lag
295
+ event, posted_at_ns = item
296
+ dequeued_at_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
297
+ lag_ns = dequeued_at_ns - posted_at_ns
298
+ update_lag_metrics(lag_ns)
299
+ check_starvation_lag(lag_ns, event)
300
+
301
+ dispatch_start_ns = dequeued_at_ns
203
302
  case event.type
204
303
  when :finished, :halted, :error
205
304
  # All three terminal events share the same cleanup path.
@@ -244,11 +343,50 @@ module Phronomy
244
343
  "no handler for target_id #{event.target_id.inspect}"
245
344
  end
246
345
  end
346
+
347
+ # Check how long this dispatch took; warn if it exceeds the threshold.
348
+ check_dispatch_time(dispatch_start_ns, event)
247
349
  end
248
350
  rescue => e
249
351
  # Unblock all waiting callers if the loop dies unexpectedly.
250
352
  @waiting.values.each { |cq| cq.push(e) }
251
353
  raise
252
354
  end
355
+
356
+ def update_lag_metrics(lag_ns)
357
+ @lag_mutex.synchronize do
358
+ @last_lag_ns = lag_ns
359
+ @max_lag_ns = lag_ns if lag_ns > @max_lag_ns
360
+ @total_lag_ns += lag_ns
361
+ @dispatch_count += 1
362
+ end
363
+ end
364
+
365
+ def check_starvation_lag(lag_ns, event)
366
+ threshold = Phronomy.configuration.event_loop_starvation_threshold_seconds
367
+ return unless threshold && lag_ns > (threshold * 1_000_000_000)
368
+
369
+ Phronomy.configuration.logger&.warn do
370
+ "[Phronomy::EventLoop] Starvation detected: event #{event.type.inspect} " \
371
+ "for target #{event.target_id.inspect} waited " \
372
+ "#{format("%.3f", lag_ns / 1_000_000_000.0)}s in queue " \
373
+ "(threshold: #{threshold}s)"
374
+ end
375
+ end
376
+
377
+ def check_dispatch_time(dispatch_start_ns, event)
378
+ threshold = Phronomy.configuration.event_loop_dispatch_threshold_seconds
379
+ return unless threshold
380
+
381
+ elapsed_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - dispatch_start_ns
382
+ return unless elapsed_ns > (threshold * 1_000_000_000)
383
+
384
+ Phronomy.configuration.logger&.warn do
385
+ "[Phronomy::EventLoop] Long dispatch: event #{event.type.inspect} " \
386
+ "for target #{event.target_id.inspect} took " \
387
+ "#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s on the EventLoop thread " \
388
+ "(threshold: #{threshold}s). Consider moving blocking work to BlockingAdapterPool."
389
+ end
390
+ end
253
391
  end
254
392
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Guardrail
5
+ # Detects potential prompt injection attempts in the agent input.
6
+ #
7
+ # Prompt injection is an attack where an adversary embeds LLM instructions
8
+ # inside data sources (e.g. RAG chunks, tool results, user input) to override
9
+ # the agent's intended behaviour.
10
+ #
11
+ # This guardrail scans the input string for common injection patterns and
12
+ # calls {#fail!} when a match is found. It is intended to be registered as
13
+ # an input guardrail on agents that consume untrusted external content.
14
+ #
15
+ # @example
16
+ # class MyAgent < Phronomy::Agent::Base
17
+ # model "gpt-4o"
18
+ # input_guardrails Phronomy::Guardrail::PromptInjectionGuardrail.new
19
+ # end
20
+ #
21
+ # @example Custom patterns
22
+ # guard = Phronomy::Guardrail::PromptInjectionGuardrail.new(
23
+ # extra_patterns: [/exfiltrate/i]
24
+ # )
25
+ class PromptInjectionGuardrail < InputGuardrail
26
+ # Common prompt injection / jailbreak patterns.
27
+ DEFAULT_PATTERNS = [
28
+ /ignore\s+(previous|prior|all)\s+instructions?/i,
29
+ /disregard\s+(previous|prior|all)\s+instructions?/i,
30
+ /forget\s+(previous|prior|all)\s+instructions?/i,
31
+ /override\s+(previous|prior|all)\s+instructions?/i,
32
+ /new\s+instructions?:\s/i,
33
+ /\byour\s+new\s+(role|instructions?|task)\b/i,
34
+ /you\s+are\s+now\s+(a|an)\b/i,
35
+ /\bact\s+as\s+(a|an)\b/i,
36
+ /\bpretend\s+(you\s+are|to\s+be)\b/i,
37
+ /\bdo\s+not\s+follow\s+(your|the)\s+instructions?\b/i
38
+ ].freeze
39
+
40
+ # @param extra_patterns [Array<Regexp>] additional patterns to scan for
41
+ # @api private
42
+ def initialize(extra_patterns: [])
43
+ super()
44
+ @patterns = DEFAULT_PATTERNS + extra_patterns
45
+ end
46
+
47
+ # Scans the input string for injection patterns.
48
+ # @param input [String, Hash]
49
+ # @api private
50
+ def check(input)
51
+ text = input.is_a?(Hash) ? input.values.join(" ") : input.to_s
52
+ @patterns.each do |pattern|
53
+ fail!("Potential prompt injection detected") if text.match?(pattern)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end