phronomy 0.6.0 → 0.7.1

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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. metadata +83 -2
@@ -11,7 +11,7 @@ module Phronomy
11
11
  # Field update policies:
12
12
  # :replace (default) -- overwrites with the new value
13
13
  # :append -- appends to an Array
14
- # :merge -- deep-merges into a Hash
14
+ # :merge -- shallow-merges into a Hash (top-level keys are merged; nested objects are replaced)
15
15
  #
16
16
  # @example
17
17
  # class ScanContext
@@ -31,9 +31,27 @@ module Phronomy
31
31
  # @param name [Symbol]
32
32
  # @param type [Symbol] :replace / :append / :merge
33
33
  # @param default [Object, Proc, nil]
34
+ # @raise [ArgumentError] if +default+ is a plain Array or Hash (use a Proc instead)
35
+ # @api public
34
36
  def field(name, type: :replace, default: nil)
37
+ if default.is_a?(Array) || default.is_a?(Hash)
38
+ raise ArgumentError,
39
+ "Mutable default for field #{name.inspect} must be wrapped in a Proc " \
40
+ "to avoid shared state across instances. " \
41
+ "Use `default: -> { #{default.inspect} }` instead."
42
+ end
43
+
35
44
  @fields[name] = {type: type, default: default}
36
- attr_accessor name
45
+
46
+ # Define getter.
47
+ attr_reader name
48
+
49
+ # Define write-guarded setter. Mutation from outside the EventLoop
50
+ # dispatch thread raises WorkflowContextOwnershipError in EventLoop mode.
51
+ define_method(:"#{name}=") do |value|
52
+ _assert_write_permitted!
53
+ instance_variable_set(:"@#{name}", value)
54
+ end
37
55
  end
38
56
 
39
57
  def fields
@@ -51,12 +69,14 @@ module Phronomy
51
69
  # :awaiting_<name> — halted at a wait_state(:awaiting_<name>) declaration
52
70
  # :<state> — resuming at <state> (workflow paused before its execution)
53
71
  # @return [Symbol]
72
+ # @api public
54
73
  def phase
55
74
  @phase || :__end__
56
75
  end
57
76
 
58
77
  # Returns true if the workflow is paused mid-execution (not yet completed).
59
78
  # @return [Boolean]
79
+ # @api public
60
80
  def halted?
61
81
  phase != :__end__
62
82
  end
@@ -64,6 +84,7 @@ module Phronomy
64
84
  # Sets internal workflow metadata. Returns self.
65
85
  # @param thread_id [String, nil]
66
86
  # @param phase [Symbol, nil]
87
+ # @api public
67
88
  def set_graph_metadata(thread_id: nil, phase: nil)
68
89
  @thread_id = thread_id unless thread_id.nil?
69
90
  @phase = phase unless phase.nil?
@@ -71,19 +92,32 @@ module Phronomy
71
92
  end
72
93
 
73
94
  def initialize(**attrs)
95
+ unknown = attrs.keys - self.class.fields.keys
96
+ raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
97
+
74
98
  self.class.fields.each do |name, config|
75
99
  default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
76
- send(:"#{name}=", attrs.fetch(name, default))
100
+ # Bypass the write guard in initialize — ownership enforcement begins
101
+ # after construction is complete.
102
+ instance_variable_set(:"@#{name}", attrs.fetch(name, default))
77
103
  end
78
104
  @thread_id = nil
79
105
  @phase = :__end__
80
106
  end
81
107
 
82
- # Immutably updates context fields. Returns a new instance with the applied changes.
83
- # Internal workflow metadata (thread_id, phase) is preserved.
108
+ # Returns a new context instance with the specified field updates applied.
109
+ # Updated fields follow the field's declared +:type+ semantics (:replace, :append,
110
+ # or :merge). Unchanged fields are deep-copied on a best-effort basis — objects
111
+ # that do not support +#dup+ (e.g. integers, frozen objects) are carried over
112
+ # by reference. Internal workflow metadata (thread_id, phase) is preserved.
84
113
  # @param updates [Hash] { field_name => new_value }
85
114
  # @return [self.class] new context instance
115
+ # @raise [ArgumentError] if updates contains keys that are not declared fields
116
+ # @api public
86
117
  def merge(updates)
118
+ unknown = updates.keys - self.class.fields.keys
119
+ raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
120
+
87
121
  new_attrs = {}
88
122
  self.class.fields.each_key do |name|
89
123
  field_config = self.class.fields[name]
@@ -97,7 +131,7 @@ module Phronomy
97
131
  updates[name]
98
132
  end
99
133
  else
100
- send(name)
134
+ deep_dup_value(send(name))
101
135
  end
102
136
  end
103
137
  new_context = self.class.new(**new_attrs)
@@ -110,10 +144,53 @@ module Phronomy
110
144
 
111
145
  # Converts user-defined fields to a Hash (excludes internal workflow metadata).
112
146
  # @return [Hash]
147
+ # @api public
113
148
  def to_h
114
149
  self.class.fields.keys.each_with_object({}) do |name, h|
115
150
  h[name] = send(name)
116
151
  end
117
152
  end
153
+
154
+ private
155
+
156
+ # Asserts that the calling thread is allowed to mutate this context.
157
+ # No-op when EventLoop mode is disabled.
158
+ # @raise [Phronomy::WorkflowContextOwnershipError] when called from a
159
+ # non-EventLoop thread in EventLoop mode.
160
+ # @api private
161
+ def _assert_write_permitted!
162
+ return unless defined?(Phronomy::EventLoop) &&
163
+ Phronomy.configuration.event_loop
164
+ return if Phronomy::EventLoop.current?
165
+
166
+ raise Phronomy::WorkflowContextOwnershipError,
167
+ "WorkflowContext fields may only be mutated from the EventLoop dispatch " \
168
+ "thread. Use context.merge(...) to produce a new context, or deliver " \
169
+ "updates as event payloads."
170
+ end
171
+
172
+ # Performs a deep copy of a value for immutable context propagation.
173
+ # Arrays and Hashes are deep-duplicated recursively.
174
+ # Immutable values (nil, Symbol, Integer, Float, true/false, frozen String) are returned as-is.
175
+ # Other objects are dup'd (best-effort shallow copy for custom types).
176
+ # Objects that cannot be dup'd (e.g. Proc, Method) are returned as-is.
177
+ def deep_dup_value(val)
178
+ case val
179
+ when Array
180
+ val.map { |v| deep_dup_value(v) }
181
+ when Hash
182
+ val.each_with_object({}) { |(k, v), h| h[k] = deep_dup_value(v) }
183
+ when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
184
+ val
185
+ else
186
+ return val if val.frozen?
187
+
188
+ begin
189
+ val.dup
190
+ rescue TypeError
191
+ val
192
+ end
193
+ end
194
+ end
118
195
  end
119
196
  end
@@ -17,8 +17,11 @@ module Phronomy
17
17
  # determined by the declared state machine topology, never by Phronomy internals.
18
18
  #
19
19
  # Entry and exit actions are registered as state_machines +after_transition to:+
20
- # and +before_transition from:+ callbacks respectively. The WorkflowContext is
21
- # mutable; actions receive it and modify fields in place.
20
+ # and +before_transition from:+ callbacks respectively. Entry actions may either
21
+ # mutate the context in place or return a new context (e.g. via +s.merge(...)+).
22
+ # When an entry action returns a Phronomy::WorkflowContext, that value replaces
23
+ # the current context; otherwise the return value is ignored.
24
+ # Exit actions are always mutation-in-place; their return value is ignored.
22
25
  #
23
26
  # The sole exception is the initial state: state_machines does not fire transition
24
27
  # callbacks on initialization, so the entry action for the entry point is invoked
@@ -35,13 +38,14 @@ module Phronomy
35
38
  # 2. <event_name> — external events triggered by human input, originating
36
39
  # from wait states
37
40
  # (declared with +transition from: :awaiting, on: :approve, to: :run+)
41
+ # @api private
38
42
  class WorkflowRunner
39
43
  include Phronomy::Runnable
40
44
 
41
45
  # Sentinel value for the terminal state of a workflow.
42
46
  FINISH = :__end__
43
47
 
44
- def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [])
48
+ def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [], state_store: nil, action_timeouts: {})
45
49
  @state_class = state_class
46
50
  @entry_actions = entry_actions # { state_name => [callable, ...] }
47
51
  @declared_states = declared_states
@@ -50,13 +54,16 @@ module Phronomy
50
54
  @external_events = external_events # { name => [{from:, to:, guard:}, ...] }
51
55
  @entry_point = entry_point
52
56
  @wait_state_names = wait_state_names
57
+ @state_store = state_store
58
+ @action_timeouts = action_timeouts # { state_name => seconds }
53
59
  @phase_machine_class = build_phase_machine_class(auto_transitions, exit_actions)
54
60
  end
55
61
 
56
62
  # Executes the workflow from the initial state.
57
63
  # @param input [Hash] initial context field values
58
- # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
64
+ # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id:, state_store: }
59
65
  # @return [Object] final context (includes Phronomy::WorkflowContext)
66
+ # @api private
60
67
  def invoke(input, config: {})
61
68
  caller_meta = {}
62
69
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
@@ -65,13 +72,23 @@ module Phronomy
65
72
  trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
66
73
  thread_id = config[:thread_id] || SecureRandom.uuid
67
74
  recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
68
- state = @state_class.new(**input)
75
+
76
+ store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
77
+ snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
78
+ initial_fields = if snapshot && snapshot[:fields]
79
+ snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
80
+ else
81
+ input
82
+ end
83
+
84
+ state = @state_class.new(**initial_fields)
69
85
  state.set_graph_metadata(thread_id: thread_id)
70
86
  result = if Phronomy.configuration.event_loop
71
87
  run_via_event_loop(state, recursion_limit: recursion_limit)
72
88
  else
73
89
  run_workflow(state, recursion_limit: recursion_limit)
74
90
  end
91
+ store&.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) if config[:thread_id]
75
92
  [result, nil]
76
93
  end
77
94
  end
@@ -80,6 +97,7 @@ module Phronomy
80
97
  # @param state [Object] halted context
81
98
  # @param input [Hash, nil] optional field updates to merge before resuming
82
99
  # @return [Object] final context
100
+ # @api private
83
101
  def resume(state:, input: nil)
84
102
  send_event(state: state, event: :resume, input: input)
85
103
  end
@@ -93,6 +111,7 @@ module Phronomy
93
111
  # @param event [Symbol] named event or +:resume+ for generic resumption
94
112
  # @param input [Hash, nil] optional field updates to merge before resuming
95
113
  # @return [Object] final context
114
+ # @api private
96
115
  def send_event(state:, event:, input: nil)
97
116
  state = state.merge(input) if input
98
117
  event = event.to_sym
@@ -128,6 +147,7 @@ module Phronomy
128
147
  # @param config [Hash]
129
148
  # @yield [Hash]
130
149
  # @return [Object] final context
150
+ # @api private
131
151
  def stream(input, config: {}, &block)
132
152
  thread_id = config[:thread_id] || SecureRandom.uuid
133
153
  recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
@@ -151,6 +171,7 @@ module Phronomy
151
171
  external_events: @external_events,
152
172
  phase_machine_class: @phase_machine_class,
153
173
  recursion_limit: recursion_limit,
174
+ action_timeouts: @action_timeouts,
154
175
  resume_event: resume_event,
155
176
  resume_phase: resume_phase
156
177
  )
@@ -180,6 +201,7 @@ module Phronomy
180
201
  tracker = new_phase_machine(current_state)
181
202
  tracker.context = ctx
182
203
  fire_event!(tracker, resume_event, current_state)
204
+ ctx = tracker.context
183
205
  next_phase = tracker.phase.to_sym
184
206
  current_state = (next_phase == current_state) ? FINISH : next_phase
185
207
  else
@@ -189,7 +211,24 @@ module Phronomy
189
211
  tracker.context = ctx
190
212
  # state_machines only fires after_transition callbacks on transitions.
191
213
  # The entry point has no prior transition, so we invoke its entry actions directly.
192
- @entry_actions[current_state]&.each { |c| c.call(ctx) }
214
+ @entry_actions[current_state]&.each do |c|
215
+ result = c.call(ctx)
216
+ if result.is_a?(Phronomy::Task)
217
+ timeout_secs = @action_timeouts[current_state]
218
+ if timeout_secs
219
+ if result.join(timeout_secs).nil?
220
+ result.cancel!
221
+ raise Phronomy::ActionTimeoutError,
222
+ "Action in state #{current_state.inspect} timed out after #{timeout_secs}s"
223
+ end
224
+ end
225
+ task_result = result.await
226
+ ctx = task_result if task_result.is_a?(Phronomy::WorkflowContext)
227
+ elsif result.is_a?(Phronomy::WorkflowContext)
228
+ ctx = result
229
+ end
230
+ end
231
+ tracker.context = ctx
193
232
  end
194
233
 
195
234
  # Event queue: decouple action execution from transition firing.
@@ -211,6 +250,7 @@ module Phronomy
211
250
  end
212
251
 
213
252
  fire_event!(tracker, event, current_state)
253
+ ctx = tracker.context
214
254
  next_phase = tracker.phase.to_sym
215
255
  # When next_phase == current_state no transition matched → terminal state.
216
256
  current_state = (next_phase == current_state) ? FINISH : next_phase
@@ -277,11 +317,17 @@ module Phronomy
277
317
  ext_events = @external_events
278
318
  entry_acts = @entry_actions
279
319
  exit_acts = exit_actions
320
+ act_timeouts = @action_timeouts # { state_name => seconds }
280
321
 
281
322
  Class.new do
282
323
  # Holds the current WorkflowContext so guards and callbacks can read it.
283
324
  attr_accessor :context
284
325
 
326
+ # Set to true by an entry action that returned an awaitable Task.
327
+ # When true, FSMSession skips the automatic advance_or_halt step and
328
+ # waits for the async worker thread to post a state_completed event back.
329
+ attr_accessor :async_pending
330
+
285
331
  state_machine :phase, initial: entry do
286
332
  all_states.each { |s| state s }
287
333
 
@@ -316,10 +362,63 @@ module Phronomy
316
362
  # Entry callbacks: fire after_transition into each state.
317
363
  # Each callable is registered as a separate callback; state_machines
318
364
  # accumulates them and fires in declaration order.
365
+ # If the callable returns a WorkflowContext (e.g. via s.merge(...)),
366
+ # the returned context replaces the current one on the tracker.
319
367
  entry_acts.each do |state_name, callables|
320
368
  callables.each do |callable|
369
+ timeout_secs = act_timeouts[state_name]
321
370
  after_transition to: state_name do |machine|
322
- callable.call(machine.context)
371
+ result = callable.call(machine.context)
372
+ if result.is_a?(Phronomy::Task)
373
+ if Phronomy.configuration.event_loop
374
+ # EventLoop mode: await in a background task so the EventLoop
375
+ # thread is not blocked. Signal async_pending so FSMSession
376
+ # skips the automatic advance_or_halt step.
377
+ machine.async_pending = true
378
+ ctx_ref = machine.context
379
+ thread_id = ctx_ref.thread_id
380
+ Phronomy::Runtime.instance.spawn(name: "wf-await-#{thread_id}") do
381
+ if timeout_secs
382
+ if result.join(timeout_secs).nil?
383
+ result.cancel!
384
+ raise Phronomy::ActionTimeoutError,
385
+ "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
386
+ end
387
+ end
388
+ task_result = result.await
389
+ if task_result.is_a?(Phronomy::WorkflowContext)
390
+ Phronomy::EventLoop.instance.post(
391
+ Phronomy::Event.new(
392
+ type: :action_completed,
393
+ target_id: thread_id,
394
+ payload: task_result
395
+ )
396
+ )
397
+ else
398
+ Phronomy::EventLoop.instance.post(
399
+ Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
400
+ )
401
+ end
402
+ rescue => e
403
+ Phronomy::EventLoop.instance.post(
404
+ Phronomy::Event.new(type: :error, target_id: thread_id, payload: e)
405
+ )
406
+ end
407
+ else
408
+ # Non-EventLoop mode: block synchronously on the task result.
409
+ if timeout_secs
410
+ if result.join(timeout_secs).nil?
411
+ result.cancel!
412
+ raise Phronomy::ActionTimeoutError,
413
+ "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
414
+ end
415
+ end
416
+ task_result = result.await
417
+ machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
418
+ end
419
+ elsif result.is_a?(Phronomy::WorkflowContext)
420
+ machine.context = result
421
+ end
323
422
  end
324
423
  end
325
424
  end
data/lib/phronomy.rb CHANGED
@@ -12,6 +12,10 @@ loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
12
12
  loader.inflector.inflect("fsm_session" => "FSMSession")
13
13
  # AgentFSM: Zeitwerk would infer "Fsm" — override to "FSM".
14
14
  loader.inflector.inflect("fsm" => "FSM")
15
+ # LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
16
+ loader.inflector.inflect("llm_adapter" => "LLMAdapter")
17
+ # LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
18
+ loader.inflector.inflect("ruby_llm" => "RubyLLM")
15
19
  loader.setup
16
20
 
17
21
  require_relative "phronomy/version"
@@ -23,11 +27,49 @@ module Phronomy
23
27
  class ParseError < Error; end
24
28
  class RecursionLimitError < Error; end
25
29
  class ToolError < Error; end
30
+ # Raised when an agent invocation exceeds the timeout set via +invoke_timeout+.
31
+ class TimeoutError < Error; end
26
32
 
27
33
  class ConfigurationError < Error; end
28
34
 
29
35
  class HandoffError < Error; end
30
36
 
37
+ # Raised when a network or transport layer call fails (e.g. LLM API unreachable,
38
+ # MCP server connection refused). Distinguishable from application-level errors
39
+ # so callers can apply network-specific retry logic.
40
+ class TransportError < Error; end
41
+
42
+ # Raised when the LLM API returns a rate-limit response (HTTP 429 or equivalent).
43
+ # Callers should back off and retry after the indicated delay.
44
+ class RateLimitError < TransportError; end
45
+
46
+ # Raised when the LLM API rejects the request due to an invalid or revoked API key.
47
+ # Callers should not retry without fixing the credentials.
48
+ class AuthenticationError < TransportError; end
49
+
50
+ # Raised when the prompt exceeds the model's context window limit.
51
+ class ContextLengthError < Error; end
52
+
53
+ # Raised when a workflow or agent execution is explicitly cancelled.
54
+ # Separate from TimeoutError (deadline exceeded) — this is an intentional stop.
55
+ class CancellationError < Error; end
56
+
57
+ # Raised when {Agent#invoke} (a synchronous, blocking call) is attempted from
58
+ # inside an active scheduler task and +strict_runtime_guards+ is enabled.
59
+ #
60
+ # Calling a blocking invocation from within a scheduler task stalls the
61
+ # scheduler until the inner invocation completes, preventing other tasks from
62
+ # making progress (hidden deadlock risk). Use {Agent#invoke_async} followed by
63
+ # +#await+ inside scheduler tasks instead.
64
+ #
65
+ # This error is only raised when:
66
+ # Phronomy.configure { |c| c.strict_runtime_guards = true }
67
+ #
68
+ # By default a warning is logged and execution continues.
69
+ #
70
+ # @see Phronomy::Runtime.in_scheduler_context?
71
+ class SchedulerReentrancyError < Error; end
72
+
31
73
  # Raised by {Phronomy::GeneratorVerifier#invoke} when +raise_if_untrusted: true+
32
74
  # and the pipeline's combined confidence score falls below the configured threshold.
33
75
  #
@@ -54,6 +96,28 @@ module Phronomy
54
96
  end
55
97
  end
56
98
 
99
+ # Raised when an operation is submitted to a {BlockingAdapterPool} that has
100
+ # already been shut down via {BlockingAdapterPool#shutdown}.
101
+ class PoolShutdownError < Error; end
102
+
103
+ # Raised when a concurrency limit is exceeded and the configured backpressure
104
+ # strategy is +:raise+. The caller should back off and retry.
105
+ class BackpressureError < Error; end
106
+
107
+ # Raised by {CancellationScope#pop_queue} when the deadline expires before a
108
+ # result is available. Extends {TimeoutError} for backwards compatibility.
109
+ class ScopeTimeoutError < TimeoutError; end
110
+
111
+ # Raised when a Workflow entry/exit action task exceeds the +action_timeout:+
112
+ # configured for its state. Extends {TimeoutError}.
113
+ class ActionTimeoutError < TimeoutError; end
114
+
115
+ # Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
116
+ # that does not own the context (i.e. not the EventLoop dispatch thread).
117
+ # Only raised in EventLoop mode. Use +context.merge(...)+ to produce a new
118
+ # context, or deliver updates as +:child_completed+ event payloads.
119
+ class WorkflowContextOwnershipError < Error; end
120
+
57
121
  class << self
58
122
  def configuration
59
123
  @configuration ||= Configuration.new
@@ -63,9 +127,56 @@ module Phronomy
63
127
  yield configuration
64
128
  end
65
129
 
66
- # Resets configuration; primarily used in tests.
130
+ # Resets the global Phronomy configuration to defaults.
131
+ #
132
+ # **Intended for test suites only.** Calling this in a production process
133
+ # will drop all runtime configuration (tracer, model, tokenizer, etc.)
134
+ # globally and immediately affect all subsequent agent and workflow calls.
135
+ #
136
+ # **Parallel test suites warning:** When tests run in parallel (e.g.
137
+ # `parallel_tests` or `parallel_rspec`), +reset_configuration!+ in one
138
+ # worker will clear configuration shared with other workers in the same
139
+ # process. Prefer process-isolation strategies (forked workers) over
140
+ # thread-based parallelism when using this method.
141
+ #
142
+ # Typical usage in a sequential test suite:
143
+ # after { Phronomy.reset_configuration! }
67
144
  def reset_configuration!
68
145
  @configuration = Configuration.new
69
146
  end
147
+
148
+ # Yields the current {Configuration} object, then restores the original
149
+ # configuration on exit (even if the block raises).
150
+ #
151
+ # Intended for test helpers that need to temporarily override settings
152
+ # without permanently mutating the global configuration.
153
+ #
154
+ # @yield [config] the current {Configuration} instance (mutable)
155
+ # @example
156
+ # Phronomy.with_configuration do |c|
157
+ # c.logger = Logger.new($stdout)
158
+ # end
159
+ # @api public
160
+ def with_configuration
161
+ original = @configuration&.dup
162
+ yield configuration
163
+ ensure
164
+ @configuration = original
165
+ end
166
+
167
+ # Resets all Phronomy runtime state: configuration and the EventLoop
168
+ # singleton (if running).
169
+ #
170
+ # **Intended for test suites only.** Stops any running EventLoop thread,
171
+ # clears the EventLoop singleton, and resets configuration to defaults.
172
+ # Call once before/after each example to ensure test isolation.
173
+ #
174
+ # @example
175
+ # config.around { |ex| Phronomy.reset_runtime! ; ex.run ; Phronomy.reset_runtime! }
176
+ # @api public
177
+ def reset_runtime!
178
+ Phronomy::EventLoop.reset!
179
+ @configuration = Configuration.new
180
+ end
70
181
  end
71
182
  end
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # scripts/api_snapshot.rb
5
+ #
6
+ # Dumps the public instance methods of all Stable/Beta public API classes to
7
+ # JSON. The snapshot is stored in spec/fixtures/api_snapshot.json and is used
8
+ # by spec/phronomy/api_compatibility_spec.rb to detect unintended API removals.
9
+ #
10
+ # Usage:
11
+ # # Regenerate spec/fixtures/api_snapshot.json (run when intentionally adding
12
+ # # or removing public API methods after updating the stability table):
13
+ # ruby scripts/api_snapshot.rb --write
14
+ #
15
+ # # Print snapshot to stdout (useful for manual inspection):
16
+ # ruby scripts/api_snapshot.rb
17
+
18
+ require "json"
19
+ require "fileutils"
20
+ require_relative "../lib/phronomy"
21
+
22
+ # Classes and modules whose public API is tracked.
23
+ # Add an entry whenever a new class/module is promoted to Stable or Beta in README.md.
24
+ PUBLIC_API_ENTRIES = [
25
+ # Stable
26
+ Phronomy::Agent::Base,
27
+ Phronomy::Tool::Base,
28
+ Phronomy::Workflow,
29
+ Phronomy::WorkflowContext,
30
+ Phronomy::Runnable,
31
+ Phronomy::PromptTemplate,
32
+ # Beta
33
+ Phronomy::Agent::ReactAgent,
34
+ Phronomy::Agent::Orchestrator,
35
+ Phronomy::Agent::TeamCoordinator,
36
+ Phronomy::Guardrail::InputGuardrail,
37
+ Phronomy::Guardrail::OutputGuardrail,
38
+ Phronomy::VectorStore::Base,
39
+ Phronomy::VectorStore::InMemory,
40
+ Phronomy::Embeddings::Base,
41
+ Phronomy::KnowledgeSource::Base,
42
+ Phronomy::KnowledgeSource::StaticKnowledge,
43
+ Phronomy::KnowledgeSource::RAGKnowledge,
44
+ Phronomy::Tracing::Base,
45
+ Phronomy::Tracing::NullTracer,
46
+ Phronomy::Eval::Runner
47
+ ].freeze
48
+
49
+ # Baseline methods common to all Ruby objects — excluded from the snapshot.
50
+ BASELINE_INSTANCE_METHODS = (
51
+ Object.public_instance_methods |
52
+ Kernel.public_instance_methods
53
+ ).uniq.freeze
54
+
55
+ BASELINE_CLASS_METHODS = (
56
+ Class.public_methods |
57
+ Module.public_methods
58
+ ).uniq.freeze
59
+
60
+ def snapshot_entry(klass)
61
+ if klass.instance_of?(Module)
62
+ # Module — capture instance methods defined in this module only
63
+ own_methods = klass.public_instance_methods(false).sort
64
+ {
65
+ "name" => klass.name,
66
+ "type" => "module",
67
+ "public_instance_methods" => own_methods
68
+ }
69
+ else
70
+ # Class — capture public instance methods minus universal baseline
71
+ instance_methods = (klass.public_instance_methods - BASELINE_INSTANCE_METHODS).sort
72
+ class_methods = (klass.public_methods(false) - BASELINE_CLASS_METHODS).sort
73
+ {
74
+ "name" => klass.name,
75
+ "type" => "class",
76
+ "public_instance_methods" => instance_methods,
77
+ "public_class_methods" => class_methods
78
+ }
79
+ end
80
+ end
81
+
82
+ snapshot = PUBLIC_API_ENTRIES.map { |entry| snapshot_entry(entry) }
83
+
84
+ if ARGV.include?("--write")
85
+ path = File.expand_path("../spec/fixtures/api_snapshot.json", __dir__)
86
+ FileUtils.mkdir_p(File.dirname(path))
87
+ File.write(path, JSON.pretty_generate(snapshot) + "\n")
88
+ puts "Wrote #{path}"
89
+ else
90
+ puts JSON.pretty_generate(snapshot)
91
+ end
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # check_api_annotations.rb
5
+ #
6
+ # Verifies that every YARD-documented public method in lib/ carries either
7
+ # "@api public" or "@api private".
8
+ #
9
+ # A method is considered "YARD-documented" when its preceding comment block
10
+ # contains at least one @param, @return, @raise, @yield, @example, or
11
+ # @overload tag. Methods with only a plain prose description (no @ tags)
12
+ # are exempt.
13
+ #
14
+ # Usage (run from the phronomy/ repository root):
15
+ # ruby scripts/check_api_annotations.rb
16
+ #
17
+ # Exit codes:
18
+ # 0 — all documented methods carry @api annotations
19
+ # 1 — one or more documented methods are missing @api annotations
20
+
21
+ lib_dir = File.expand_path("../lib", __dir__)
22
+
23
+ unless File.directory?(lib_dir)
24
+ warn "ERROR: lib directory not found at #{lib_dir}"
25
+ exit 1
26
+ end
27
+
28
+ errors = []
29
+
30
+ Dir.glob(File.join(lib_dir, "**", "*.rb")).sort.each do |file|
31
+ lines = File.readlines(file)
32
+
33
+ lines.each_with_index do |line, i|
34
+ next unless line.match?(/^\s*def\s+\w/)
35
+
36
+ # Collect the contiguous comment block immediately above this def.
37
+ comment_lines = []
38
+ j = i - 1
39
+ while j >= 0 && lines[j].match?(/^\s*#/)
40
+ comment_lines.unshift(lines[j])
41
+ j -= 1
42
+ end
43
+
44
+ next if comment_lines.empty?
45
+
46
+ comment = comment_lines.join
47
+
48
+ # Only lint methods that carry at least one YARD type tag.
49
+ next unless comment.match?(/#[ \t]+@(param|return|raise|yield|example|overload)/)
50
+
51
+ # Pass if an @api tag is already present.
52
+ next if comment.match?(/#[ \t]+@api[ \t]+(public|private)/)
53
+
54
+ rel_path = file.sub("#{lib_dir}/../", "")
55
+ m = line.match(/def\s+(\w+[!?=]?)/)
56
+ method_name = m ? m[1] : "unknown"
57
+ errors << "#{rel_path}:#{i + 1} def #{method_name} (missing @api public or @api private)"
58
+ end
59
+ end
60
+
61
+ if errors.empty?
62
+ puts "OK: all YARD-documented methods carry @api annotations"
63
+ exit 0
64
+ else
65
+ puts "FAIL: #{errors.size} method(s) missing @api annotation:"
66
+ errors.each { |e| puts " #{e}" }
67
+ exit 1
68
+ end