phronomy 0.5.4 → 0.7.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +379 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +262 -48
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/SECURITY.md +80 -0
  8. data/benchmark/baseline.json +9 -0
  9. data/benchmark/bench_agent_invoke.rb +105 -0
  10. data/benchmark/bench_context_assembler.rb +46 -0
  11. data/benchmark/bench_regression.rb +171 -0
  12. data/benchmark/bench_token_estimator.rb +44 -0
  13. data/benchmark/bench_tool_schema.rb +69 -0
  14. data/benchmark/bench_vector_store.rb +39 -0
  15. data/benchmark/bench_workflow.rb +55 -0
  16. data/benchmark/run_all.rb +118 -0
  17. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  18. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  19. data/docs/decisions/003-event-loop-singleton.md +48 -0
  20. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
  21. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  22. data/docs/decisions/006-no-built-in-guardrails.md +48 -0
  23. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  24. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  25. data/docs/decisions/009-state-store-abstraction.md +141 -0
  26. data/lib/phronomy/agent/base.rb +281 -13
  27. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  28. data/lib/phronomy/agent/checkpoint.rb +1 -0
  29. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  30. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  31. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  32. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  33. data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
  34. data/lib/phronomy/agent/fsm.rb +180 -0
  35. data/lib/phronomy/agent/handoff.rb +3 -0
  36. data/lib/phronomy/agent/orchestrator.rb +123 -11
  37. data/lib/phronomy/agent/parallel_tool_chat.rb +92 -0
  38. data/lib/phronomy/agent/react_agent.rb +8 -6
  39. data/lib/phronomy/agent/runner.rb +2 -0
  40. data/lib/phronomy/agent/shared_state.rb +11 -0
  41. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  42. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  43. data/lib/phronomy/cancellation_token.rb +92 -0
  44. data/lib/phronomy/configuration.rb +32 -2
  45. data/lib/phronomy/context/assembler.rb +6 -0
  46. data/lib/phronomy/context/compaction_context.rb +2 -0
  47. data/lib/phronomy/context/context_version_cache.rb +2 -0
  48. data/lib/phronomy/context/token_budget.rb +3 -0
  49. data/lib/phronomy/context/token_estimator.rb +9 -2
  50. data/lib/phronomy/context/trigger_context.rb +1 -0
  51. data/lib/phronomy/context/trim_context.rb +4 -0
  52. data/lib/phronomy/context.rb +0 -1
  53. data/lib/phronomy/embeddings/base.rb +5 -2
  54. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  55. data/lib/phronomy/eval/comparison.rb +2 -0
  56. data/lib/phronomy/eval/dataset.rb +4 -0
  57. data/lib/phronomy/eval/metrics.rb +6 -0
  58. data/lib/phronomy/eval/runner.rb +2 -0
  59. data/lib/phronomy/eval/scorer/base.rb +1 -0
  60. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  61. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  62. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  63. data/lib/phronomy/event.rb +14 -0
  64. data/lib/phronomy/event_loop.rb +254 -0
  65. data/lib/phronomy/fsm_session.rb +201 -0
  66. data/lib/phronomy/generator_verifier.rb +24 -22
  67. data/lib/phronomy/guardrail/base.rb +3 -0
  68. data/lib/phronomy/guardrail.rb +0 -1
  69. data/lib/phronomy/knowledge_source/base.rb +6 -2
  70. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  71. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  72. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  73. data/lib/phronomy/loader/base.rb +1 -0
  74. data/lib/phronomy/loader/csv_loader.rb +2 -0
  75. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  76. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  77. data/lib/phronomy/output_parser/base.rb +1 -0
  78. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  79. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  80. data/lib/phronomy/prompt_template.rb +5 -0
  81. data/lib/phronomy/runnable.rb +20 -3
  82. data/lib/phronomy/splitter/base.rb +2 -0
  83. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  84. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  85. data/lib/phronomy/state_store/base.rb +48 -0
  86. data/lib/phronomy/state_store/in_memory.rb +62 -0
  87. data/lib/phronomy/tool/agent_tool.rb +1 -0
  88. data/lib/phronomy/tool/base.rb +189 -27
  89. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  90. data/lib/phronomy/tracing/base.rb +3 -0
  91. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  92. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  93. data/lib/phronomy/vector_store/base.rb +33 -7
  94. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  95. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  96. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  97. data/lib/phronomy/version.rb +1 -1
  98. data/lib/phronomy/workflow.rb +175 -74
  99. data/lib/phronomy/workflow_context.rb +55 -5
  100. data/lib/phronomy/workflow_runner.rb +197 -114
  101. data/lib/phronomy.rb +74 -1
  102. data/scripts/api_snapshot.rb +91 -0
  103. data/scripts/check_api_annotations.rb +68 -0
  104. data/scripts/check_private_enforcement.rb +93 -0
  105. data/scripts/check_readme_runnable.rb +98 -0
  106. data/scripts/run_mutation.sh +46 -0
  107. metadata +50 -6
  108. data/lib/phronomy/context/builder.rb +0 -92
  109. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
  110. data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
  111. data/lib/phronomy/guardrail/builtin.rb +0 -16
@@ -12,11 +12,13 @@ module Phronomy
12
12
  # ExactMatch.new.score(actual: "paris", expected: "Paris") # => 0.0
13
13
  class ExactMatch < Base
14
14
  # @param case_sensitive [Boolean] default true
15
+ # @api public
15
16
  def initialize(case_sensitive: true)
16
17
  @case_sensitive = case_sensitive
17
18
  end
18
19
 
19
20
  # @return [Float] 1.0 on match, 0.0 otherwise
21
+ # @api public
20
22
  def score(actual:, expected:, input: nil)
21
23
  a = actual.to_s.strip
22
24
  e = expected.to_s.strip
@@ -13,11 +13,13 @@ module Phronomy
13
13
  # IncludesScorer.new.score(actual: "The answer is 42.", expected: "42") # => 1.0
14
14
  class IncludesScorer < Base
15
15
  # @param case_sensitive [Boolean] default false
16
+ # @api public
16
17
  def initialize(case_sensitive: false)
17
18
  @case_sensitive = case_sensitive
18
19
  end
19
20
 
20
21
  # @return [Float] 1.0 if actual contains expected, 0.0 otherwise
22
+ # @api public
21
23
  def score(actual:, expected:, input: nil)
22
24
  a = actual.to_s
23
25
  e = expected.to_s
@@ -36,6 +36,7 @@ module Phronomy
36
36
  # @param prompt_template [String] format string with %<input>s, %<expected>s, %<actual>s
37
37
  # @param raise_on_error [Boolean] when true, re-raises scoring exceptions instead of
38
38
  # returning 0.0. Use this in batch eval pipelines where silent failures are unacceptable.
39
+ # @api public
39
40
  def initialize(model:, prompt_template: DEFAULT_PROMPT, raise_on_error: false)
40
41
  @model = model
41
42
  @prompt_template = prompt_template
@@ -43,6 +44,7 @@ module Phronomy
43
44
  end
44
45
 
45
46
  # @return [Float] score in [0.0, 1.0]; 0.0 on error when raise_on_error is false
47
+ # @api public
46
48
  def score(actual:, expected:, input: nil)
47
49
  prompt = format(@prompt_template, input: input.to_s, expected: expected.to_s, actual: actual.to_s)
48
50
  response = RubyLLM.chat(model: @model).ask(prompt)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Immutable event struct used for inter-FSM communication via EventLoop.
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
13
+ Event = Data.define(:type, :target_id, :payload)
14
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Singleton event loop that manages all FSMSession instances.
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.
9
+ #
10
+ # Activated with: +Phronomy.configure { |c| c.event_loop = true }+
11
+ #
12
+ # == Fork safety
13
+ #
14
+ # +EventLoop.instance+ is lazily initialized. The background thread is not
15
+ # created until the first call, so Puma worker forking does not duplicate the
16
+ # thread. No +after_fork+ hook is required.
17
+ #
18
+ # == Deadlock warning
19
+ #
20
+ # Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
21
+ # entry action. The entry action runs on the EventLoop thread; a nested
22
+ # +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).
25
+ class EventLoop
26
+ # Returns the singleton instance, creating and starting it on first call.
27
+ def self.instance
28
+ @instance ||= new.tap(&:start)
29
+ end
30
+
31
+ # Stops and destroys the singleton. Primarily used in tests.
32
+ # @api private
33
+ def self.reset!
34
+ @instance&.stop
35
+ @instance = nil
36
+ end
37
+
38
+ def initialize
39
+ @queue = Thread::Queue.new # global event queue (thread-safe; no Mutex needed)
40
+ @fsms = {} # { id => FSMSession } — EventLoop thread only
41
+ @waiting = {} # { id => completion_queue } — EventLoop thread only
42
+ # Mutex-backed FSM count for drain-mode shutdown.
43
+ @fsm_count_mutex = Mutex.new
44
+ @fsm_count_cond = ConditionVariable.new
45
+ @fsm_count = 0
46
+ # Token cancelled when shutdown is requested; new child sessions receive it.
47
+ @shutdown_token = Phronomy::CancellationToken.new
48
+ end
49
+
50
+ # Registers an FSMSession for execution and returns a completion queue.
51
+ #
52
+ # The session and its completion queue are handed off to the EventLoop thread
53
+ # via the queue payload, so +@fsms+ and +@waiting+ are exclusively written
54
+ # and read by the EventLoop thread. No Mutex is required.
55
+ #
56
+ # The caller blocks on +completion_queue.pop+ to receive the final context
57
+ # (WorkflowContext) once the workflow finishes or halts. If an error occurred,
58
+ # the popped value will be an Exception — callers are responsible for re-raising it.
59
+ #
60
+ # @param fsm_session [Phronomy::FSMSession]
61
+ # @return [Thread::Queue] resolves to final/halted context, or an Exception
62
+ # @api private
63
+ def register(fsm_session)
64
+ if Thread.current[:phronomy_event_loop_thread]
65
+ raise Phronomy::Error,
66
+ "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."
69
+ end
70
+
71
+ completion_queue = Thread::Queue.new
72
+ # Pass both session and completion_queue in the event payload so that the
73
+ # 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}))
76
+ completion_queue
77
+ end
78
+
79
+ # Enqueues an {AgentFSM} as a fire-and-forget child session.
80
+ #
81
+ # Unlike {#register}, this method:
82
+ # - Is safe to call from the EventLoop thread (entry actions).
83
+ # - Does NOT block — no completion queue is created.
84
+ # - Delegates `:finished`/`:error` cleanup to the EventLoop via posted events.
85
+ #
86
+ # @param agent_fsm [Phronomy::Agent::FSM]
87
+ # @return [nil]
88
+ # @api private
89
+ def enqueue_child(agent_fsm)
90
+ @queue.push(Event.new(type: :start, target_id: agent_fsm.id,
91
+ payload: {session: agent_fsm, completion: nil}))
92
+ nil
93
+ end
94
+
95
+ # Posts an event to the loop. Safe to call from any thread (including IO threads).
96
+ #
97
+ # @param event [Phronomy::Event]
98
+ # @api private
99
+ def post(event)
100
+ @queue.push(event)
101
+ end
102
+
103
+ # Starts the background event loop thread.
104
+ # @return [self]
105
+ # @api private
106
+ def start
107
+ return self if @thread&.alive?
108
+
109
+ # Reset shutdown state so the loop can be restarted after a stop.
110
+ @shutdown_token = Phronomy::CancellationToken.new
111
+ @fsm_count_mutex.synchronize { @fsm_count = 0 }
112
+ @running = true
113
+ @thread = Thread.new do
114
+ Thread.current[:phronomy_event_loop_thread] = true
115
+ run_loop
116
+ end
117
+ @thread.abort_on_exception = false
118
+ self
119
+ end
120
+
121
+ # Stops the background thread. Used in tests only.
122
+ #
123
+ # 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.
127
+ #
128
+ # @param timeout [Numeric] seconds to wait for cooperative shutdown. Defaults
129
+ # to +Phronomy.configuration.event_loop_stop_grace_seconds+ (5 s).
130
+ # @param drain [Boolean] when +true+, wait for all active FSMSessions to
131
+ # complete before signalling the loop to stop. Bounded by +timeout+.
132
+ # 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.
138
+ # @return [Symbol] shutdown status:
139
+ # - +:clean+ — loop exited cooperatively with no active sessions discarded
140
+ # - +:drained_with_discards+ — drain mode requested but sessions remained;
141
+ # 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
144
+ # @api private
145
+ def stop(timeout: Phronomy.configuration.event_loop_stop_grace_seconds, drain: false, force_kill: false)
146
+ @shutdown_token.cancel!
147
+ status = :clean
148
+
149
+ if drain
150
+ # Wait for active sessions to finish, bounded by timeout.
151
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
152
+ @fsm_count_mutex.synchronize do
153
+ while @fsm_count > 0
154
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+ break if remaining <= 0
156
+ @fsm_count_cond.wait(@fsm_count_mutex, remaining)
157
+ end
158
+ status = :drained_with_discards if @fsm_count > 0
159
+ end
160
+ end
161
+
162
+ @running = false
163
+ @queue.push(:__stop__) # unblock queue.pop so the worker can see @running = false
164
+ begin
165
+ @thread&.join(timeout)
166
+ 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.
169
+ nil
170
+ end
171
+ if @thread&.alive?
172
+ if force_kill
173
+ Phronomy.configuration.logger&.warn(
174
+ "[Phronomy] EventLoop thread did not stop within #{timeout}s; force-killing. " \
175
+ "This is a last resort — check for blocking operations in event handlers."
176
+ )
177
+ @thread.kill
178
+ status = :force_killed
179
+ else
180
+ Phronomy.configuration.logger&.warn(
181
+ "[Phronomy] EventLoop thread did not stop within #{timeout}s; abandoning " \
182
+ "(force_kill: false). Check for blocking operations in event handlers."
183
+ )
184
+ status = :timeout
185
+ end
186
+ end
187
+ @thread = nil
188
+ status
189
+ end
190
+
191
+ private
192
+
193
+ def run_loop
194
+ while @running
195
+ event = @queue.pop
196
+ # :__stop__ is used purely as an unblock signal for @queue.pop; the
197
+ # actual stop condition is @running == false (set before the push).
198
+ # Treating it as `next` instead of `break` prevents a stale sentinel
199
+ # (left by a previous stop call that raced with thread start) from
200
+ # immediately terminating a freshly restarted EventLoop.
201
+ next if event == :__stop__
202
+
203
+ case event.type
204
+ when :finished, :halted, :error
205
+ # All three terminal events share the same cleanup path.
206
+ # Both @fsms and @waiting are exclusively owned by this thread.
207
+ @fsms.delete(event.target_id)
208
+ cq = @waiting.delete(event.target_id)
209
+ cq&.push(event.payload)
210
+ # Decrement active FSM count and signal drain waiters.
211
+ @fsm_count_mutex.synchronize do
212
+ @fsm_count -= 1
213
+ @fsm_count_cond.signal if @fsm_count <= 0
214
+ end
215
+
216
+ when :start
217
+ # session and completion_queue arrive together in the payload so that
218
+ # this thread is the sole writer of @fsms and @waiting.
219
+ # completion may be nil for fire-and-forget child sessions (AgentFSM).
220
+ session = event.payload[:session]
221
+ cq = event.payload[:completion]
222
+
223
+ # When shutdown has been requested, reject new sessions with a
224
+ # CancellationError rather than starting new LLM calls that would
225
+ # be interrupted by force-kill.
226
+ if @shutdown_token.cancelled? && cq
227
+ cq.push(Phronomy::CancellationError.new("EventLoop is shutting down"))
228
+ next
229
+ end
230
+
231
+ @fsms[event.target_id] = session
232
+ @waiting[event.target_id] = cq if cq
233
+ @fsm_count_mutex.synchronize { @fsm_count += 1 }
234
+ session.start
235
+
236
+ else
237
+ fsm = @fsms[event.target_id]
238
+ if fsm
239
+ fsm.handle(event)
240
+ else
241
+ # Warn when an event is dropped due to an unknown target_id so that
242
+ # mis-typed IDs and handler-deregistration races are visible.
243
+ warn "[Phronomy::EventLoop] Dropped event #{event.type.inspect} — " \
244
+ "no handler for target_id #{event.target_id.inspect}"
245
+ end
246
+ end
247
+ end
248
+ rescue => e
249
+ # Unblock all waiting callers if the loop dies unexpectedly.
250
+ @waiting.values.each { |cq| cq.push(e) }
251
+ raise
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Event-driven execution wrapper for a single workflow run.
5
+ #
6
+ # Created by WorkflowRunner and registered with EventLoop. All public methods
7
+ # are called from the EventLoop thread — FSMSession is NOT thread-safe and must
8
+ # not be accessed concurrently from multiple threads.
9
+ #
10
+ # == Lifecycle
11
+ #
12
+ # register(session) → EventLoop posts :start → session.start
13
+ # ↓ (auto-transition present)
14
+ # EventLoop posts :state_completed → session.handle
15
+ # ↓ (repeat)
16
+ # session posts :finished or :halted
17
+ # ↓
18
+ # EventLoop pushes ctx to completion_queue → caller unblocks
19
+ #
20
+ # == Async IO pattern (EventLoop mode only)
21
+ #
22
+ # When a state has no auto-transition and is not a wait_state, but has an
23
+ # external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
24
+ # the FSMSession stays registered in the EventLoop and waits for that event.
25
+ # The entry action is expected to spawn an IO thread that posts the event back:
26
+ #
27
+ # entry :fetching, ->(ctx) {
28
+ # Thread.new {
29
+ # ctx.result = http.get(ctx.url)
30
+ # Phronomy::EventLoop.instance.post(
31
+ # Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
32
+ # )
33
+ # }
34
+ # }
35
+ # transition from: :fetching, on: :fetch_done, to: :process
36
+ class FSMSession
37
+ FINISH = WorkflowRunner::FINISH
38
+
39
+ # @return [String] workflow thread_id (matches WorkflowContext#thread_id)
40
+ attr_reader :id
41
+
42
+ # @param id [String]
43
+ # @param context [Object] includes Phronomy::WorkflowContext
44
+ # @param entry_point [Symbol] initial state name
45
+ # @param entry_actions [Hash] { state_name => [callable, ...] }
46
+ # @param auto_state_set [Hash] { state_name => true }
47
+ # @param declared_states [Array<Symbol>] all action state names
48
+ # @param wait_state_names [Array<Symbol>]
49
+ # @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
50
+ # @param phase_machine_class [Class] state_machines-backed phase tracker class
51
+ # @param recursion_limit [Integer]
52
+ # @param resume_event [Symbol, nil] external event to fire when resuming
53
+ # @param resume_phase [Symbol, nil] wait state name to resume from
54
+ # @api private
55
+ def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
56
+ declared_states:, wait_state_names:, external_events:, phase_machine_class:,
57
+ recursion_limit:, resume_event: nil, resume_phase: nil)
58
+ @id = id
59
+ @ctx = context
60
+ @entry_point = entry_point
61
+ @entry_actions = entry_actions
62
+ @auto_state_set = auto_state_set
63
+ @declared_states = declared_states
64
+ @wait_state_names = wait_state_names
65
+ @external_events = external_events
66
+ @phase_machine_class = phase_machine_class
67
+ @recursion_limit = recursion_limit
68
+ @resume_event = resume_event
69
+ @resume_phase = resume_phase
70
+ @step = 0
71
+ @done = false
72
+ @current_state = nil
73
+ @tracker = nil
74
+ end
75
+
76
+ # Begins workflow execution. Called by EventLoop on :start event.
77
+ def start
78
+ if @resume_event
79
+ # Resume from wait state: position tracker at the wait state, then fire the
80
+ # external event. state_machines fires before_transition (exit) and
81
+ # after_transition (entry) callbacks, so both actions execute here.
82
+ @current_state = @resume_phase
83
+ @tracker = build_tracker(@current_state)
84
+ @tracker.context = @ctx
85
+ fire_and_advance!(@resume_event)
86
+ else
87
+ # Fresh start: state_machines does not fire callbacks on initialization,
88
+ # so we invoke the entry action for the initial state manually.
89
+ @current_state = @entry_point
90
+ @tracker = build_tracker(@current_state)
91
+ @tracker.context = @ctx
92
+ (@entry_actions[@current_state] || []).each do |c|
93
+ result = c.call(@ctx)
94
+ @ctx = result if result.is_a?(Phronomy::WorkflowContext)
95
+ end
96
+ @tracker.context = @ctx
97
+ advance_or_halt
98
+ end
99
+ rescue => e
100
+ finish_with_error(e)
101
+ end
102
+
103
+ # Processes an event dispatched from EventLoop.
104
+ # Called for :state_completed and all user-defined external events.
105
+ #
106
+ # @param event [Phronomy::Event]
107
+ # @api private
108
+ def handle(event)
109
+ return if @done
110
+
111
+ fire_and_advance!(event.type)
112
+ rescue => e
113
+ finish_with_error(e)
114
+ end
115
+
116
+ private
117
+
118
+ # Fires event_name on the phase tracker, updates @current_state, then
119
+ # calls advance_or_halt to decide what to do next.
120
+ def fire_and_advance!(event_name)
121
+ if @step >= @recursion_limit
122
+ raise Phronomy::RecursionLimitError,
123
+ "Recursion limit (#{@recursion_limit}) exceeded"
124
+ end
125
+
126
+ fire_event!(@tracker, event_name, @current_state)
127
+ @ctx = @tracker.context
128
+ next_phase = @tracker.phase.to_sym
129
+ # When next_phase == @current_state, no transition matched → treat as terminal.
130
+ @current_state = (next_phase == @current_state) ? FINISH : next_phase
131
+ @step += 1
132
+ advance_or_halt
133
+ end
134
+
135
+ # Determines the next action after the FSM has entered @current_state.
136
+ def advance_or_halt
137
+ return finish! if @current_state == FINISH
138
+
139
+ if @wait_state_names.include?(@current_state)
140
+ return halt!
141
+ end
142
+
143
+ if @auto_state_set.key?(@current_state)
144
+ event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
145
+ return
146
+ end
147
+
148
+ if has_external_event_from?(@current_state)
149
+ # Async IO pattern: the entry action spawned an IO thread that will post
150
+ # an external event back. Stay registered; do nothing here.
151
+ return
152
+ end
153
+
154
+ # No transition declared — validate the state is known, then treat as terminal.
155
+ unless @declared_states.include?(@current_state)
156
+ raise ArgumentError, "State #{@current_state.inspect} is not defined"
157
+ end
158
+
159
+ finish!
160
+ end
161
+
162
+ def finish!
163
+ @done = true
164
+ @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
165
+ event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
166
+ end
167
+
168
+ def halt!
169
+ @done = true
170
+ @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
171
+ event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
172
+ end
173
+
174
+ def finish_with_error(err)
175
+ @done = true
176
+ event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
177
+ end
178
+
179
+ def fire_event!(tracker, event_name, from_state)
180
+ return if tracker.send(event_name)
181
+
182
+ raise ArgumentError,
183
+ "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
184
+ "Ensure at least one guard matches or add a fallback (no-guard) transition."
185
+ end
186
+
187
+ def has_external_event_from?(state)
188
+ @external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
189
+ end
190
+
191
+ def build_tracker(from_state)
192
+ machine = @phase_machine_class.new
193
+ machine.instance_variable_set(:@phase, from_state.to_s)
194
+ machine
195
+ end
196
+
197
+ def event_loop
198
+ Phronomy::EventLoop.instance
199
+ end
200
+ end
201
+ end
@@ -113,6 +113,7 @@ module Phronomy
113
113
  # @param raise_if_untrusted [Boolean] when +true+, raises
114
114
  # {Phronomy::LowConfidenceError} if the final result does not meet the
115
115
  # confidence threshold (default: false)
116
+ # @api private
116
117
  def initialize(
117
118
  draft_agent:,
118
119
  review_agent:,
@@ -133,7 +134,7 @@ module Phronomy
133
134
  @threshold = confidence_threshold.to_f
134
135
  @max_iterations = max_iterations.to_i
135
136
  @raise_if_untrusted = raise_if_untrusted
136
- @compiled_graph = nil
137
+ @compiled_workflow = nil
137
138
  end
138
139
 
139
140
  # Run the generator-verifier pipeline.
@@ -143,8 +144,9 @@ module Phronomy
143
144
  # @return [Result]
144
145
  # @raise [Phronomy::LowConfidenceError] when +raise_if_untrusted:+ is +true+
145
146
  # and the result does not meet the confidence threshold
147
+ # @api private
146
148
  def invoke(input, config: {})
147
- app = compiled_graph
149
+ app = compiled_workflow
148
150
  state = app.invoke({input: input}, config: config)
149
151
  confidence = combined_confidence(state)
150
152
  trusted = confidence >= @threshold
@@ -166,8 +168,8 @@ module Phronomy
166
168
  [(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
167
169
  end
168
170
 
169
- def compiled_graph
170
- @compiled_graph ||= build_workflow
171
+ def compiled_workflow
172
+ @compiled_workflow ||= build_workflow
171
173
  end
172
174
 
173
175
  def build_workflow
@@ -184,42 +186,42 @@ module Phronomy
184
186
  Phronomy::Workflow.define(PipelineState) do
185
187
  initial :draft
186
188
 
187
- state :draft, action: ->(state) {
189
+ state :draft
190
+ state :review
191
+ state :finalize
192
+
193
+ entry :draft, ->(state) {
188
194
  feedback = state.review_notes.last
189
195
  prompt = dpb.call(state.input, feedback)
190
196
  result = draft_agent.invoke(prompt)
191
197
  parsed = drp.call(result[:output])
192
- state.merge(
193
- draft: parsed[:answer].to_s,
194
- self_score: pipeline.__send__(:clamp, parsed[:confidence]),
195
- citations: pipeline.__send__(:normalize_citations, parsed[:citations]),
196
- iteration: state.iteration + 1
197
- )
198
+ state.draft = parsed[:answer].to_s
199
+ state.self_score = pipeline.__send__(:clamp, parsed[:confidence])
200
+ state.citations = pipeline.__send__(:normalize_citations, parsed[:citations])
201
+ state.iteration = state.iteration + 1
198
202
  }
199
203
 
200
- state :review, action: ->(state) {
204
+ entry :review, ->(state) {
201
205
  prompt = rpb.call(state.input, state.draft, state.citations)
202
206
  result = review_agent.invoke(prompt)
203
207
  parsed = rrp.call(result[:output])
204
- state.merge(
205
- review_score: pipeline.__send__(:clamp, parsed[:score]),
206
- approved: parsed[:approved] == true,
207
- review_notes: parsed[:feedback].to_s
208
- )
208
+ state.review_score = pipeline.__send__(:clamp, parsed[:score])
209
+ state.approved = parsed[:approved] == true
210
+ state.review_notes << parsed[:feedback].to_s
209
211
  }
210
212
 
211
- state :finalize, action: ->(state) { state.merge(output: state.draft) }
213
+ entry :finalize, ->(state) { state.output = state.draft }
212
214
 
213
- after :draft, to: :review
214
- after :finalize, to: :__finish__
215
+ transition from: :draft, to: :review
216
+ transition from: :finalize, to: :__finish__
215
217
 
216
- event :route_review, from: :review,
218
+ transition from: :review,
217
219
  guard: ->(state) {
218
220
  confidence = [state.self_score || 0.0, state.review_score || 0.0].min
219
221
  (confidence >= threshold && state.approved) || state.iteration >= max_iter
220
222
  },
221
223
  to: :finalize
222
- event :route_review, from: :review, to: :draft
224
+ transition from: :review, to: :draft
223
225
  end
224
226
  end
225
227
 
@@ -17,6 +17,7 @@ module Phronomy
17
17
  # Validate the value. Subclasses must implement this method.
18
18
  # @param value [Object] the input or output being checked
19
19
  # @raise [Phronomy::GuardrailError] if the guardrail rejects the value
20
+ # @api public
20
21
  def check(value)
21
22
  raise NotImplementedError, "#{self.class}#check is not implemented"
22
23
  end
@@ -24,6 +25,7 @@ module Phronomy
24
25
  # Run the check, raising GuardrailError on failure.
25
26
  # @param value [Object]
26
27
  # @return [Object] the original value (unchanged) when the check passes
28
+ # @api public
27
29
  def run!(value)
28
30
  check(value)
29
31
  value
@@ -34,6 +36,7 @@ module Phronomy
34
36
  # Call inside #check to reject the value.
35
37
  # @param reason [String] human-readable rejection reason
36
38
  # @raise [Phronomy::GuardrailError]
39
+ # @api public
37
40
  def fail!(reason)
38
41
  raise Phronomy::GuardrailError.new(reason, guardrail: self)
39
42
  end
@@ -5,4 +5,3 @@
5
5
  require_relative "guardrail/base"
6
6
  require_relative "guardrail/input_guardrail"
7
7
  require_relative "guardrail/output_guardrail"
8
- require_relative "guardrail/builtin"
@@ -11,9 +11,12 @@ module Phronomy
11
11
  class Base
12
12
  # Retrieve knowledge chunks relevant to the given query.
13
13
  #
14
- # @param query [String, nil] the current user input used to select relevant chunks
14
+ # @param query [String, nil] the current user input used to select relevant chunks
15
+ # @param cancellation_token [Phronomy::CancellationToken, nil] optional token; raises CancellationError when cancelled
15
16
  # @return [Array<Hash>] array of { content: String, type: Symbol }
16
- def fetch(query: nil)
17
+ # @api public
18
+ def fetch(query: nil, cancellation_token: nil)
19
+ cancellation_token&.raise_if_cancelled!
17
20
  raise NotImplementedError, "#{self.class}#fetch is not implemented"
18
21
  end
19
22
 
@@ -24,6 +27,7 @@ module Phronomy
24
27
  # Override in subclasses that return fixed content.
25
28
  #
26
29
  # @return [Boolean]
30
+ # @api public
27
31
  def static?
28
32
  false
29
33
  end