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
@@ -5,7 +5,6 @@ require "net/http"
5
5
  require "open3"
6
6
  require "securerandom"
7
7
  require "shellwords"
8
- require "timeout"
9
8
  require "uri"
10
9
 
11
10
  module Phronomy
@@ -67,7 +66,7 @@ module Phronomy
67
66
 
68
67
  def build_tool_class(tool_name, server_uri, tool_def)
69
68
  klass = Class.new(McpTool)
70
- klass.instance_variable_set(:@mcp_tool_name, tool_name)
69
+ klass.tool_name(tool_name)
71
70
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
72
71
 
73
72
  # Register description and params from the MCP tool definition.
@@ -118,7 +117,9 @@ module Phronomy
118
117
  # {HttpTransport}. Defaults to 30 seconds.
119
118
  # @param env [Hash, nil] environment variable overrides for the subprocess.
120
119
  # When provided, only these variables are added/overridden; the parent environment
121
- # is still inherited unless explicitly cleared via an empty string value.
120
+ # is still inherited. Use +nil+ as a value to unset a variable in the child process
121
+ # (e.g. +{ "SECRET" => nil }+). An empty string value (+""+ ) sets the variable to
122
+ # an empty string — it does NOT unset it.
122
123
  # @param cwd [String, nil] working directory for the subprocess.
123
124
  # Defaults to the current process's working directory.
124
125
  # @param startup_timeout [Numeric, nil] seconds to wait for the server to
@@ -138,6 +139,7 @@ module Phronomy
138
139
  @stderr = nil
139
140
  @wait_thr = nil
140
141
  @stderr_thread = nil
142
+ @stderr_op = nil
141
143
  end
142
144
 
143
145
  # Shut down the child process and close its IO streams.
@@ -149,10 +151,17 @@ module Phronomy
149
151
  @stdout = nil
150
152
  @stderr = nil
151
153
  stderr_thread = @stderr_thread
154
+ stderr_op = @stderr_op
152
155
  wait_thr = @wait_thr
153
156
  @stderr_thread = nil
157
+ @stderr_op = nil
154
158
  @wait_thr = nil
155
159
  stderr_thread&.join(1)
160
+ begin
161
+ stderr_op&.await(timeout: 1.0)
162
+ rescue
163
+ nil
164
+ end
156
165
  wait_thr&.join(5)
157
166
  end
158
167
 
@@ -209,31 +218,53 @@ module Phronomy
209
218
  # Drain stderr asynchronously to prevent the pipe buffer from filling
210
219
  # and deadlocking the child process. Errors inside the drain thread are
211
220
  # silently ignored since stderr content is diagnostics-only.
212
- @stderr_thread = Thread.new do
213
- @stderr.read
214
- rescue
215
- nil
221
+ #
222
+ # Prefer BlockingAdapterPool when a Runtime is configured so that this
223
+ # file eventually needs no direct Thread.new (Issue #360). Fall back to
224
+ # Thread.new when no pool is available (no EventLoop / bare invocation).
225
+ pool = begin; Phronomy::Runtime.instance&.blocking_io; rescue; nil; end
226
+ if pool
227
+ @stderr_op = pool.submit {
228
+ begin
229
+ @stderr.read
230
+ rescue
231
+ nil
232
+ end
233
+ }
234
+ @stderr_thread = nil
235
+ else
236
+ @stderr_thread = Thread.new {
237
+ begin
238
+ @stderr.read
239
+ rescue
240
+ nil
241
+ end
242
+ }
243
+ @stderr_op = nil
216
244
  end
217
245
 
218
246
  if @startup_timeout
219
- Timeout.timeout(@startup_timeout) { @stdout.gets.tap { |line| @stdout.ungetbyte(line) if line } }
247
+ unless IO.select([@stdout], nil, nil, @startup_timeout)
248
+ close
249
+ raise Phronomy::ToolError,
250
+ "MCP stdio server did not start within #{@startup_timeout} seconds"
251
+ end
252
+ line = @stdout.gets
253
+ @stdout.ungetbyte(line) if line
220
254
  end
221
- rescue Timeout::Error
222
- close
223
- raise Phronomy::ToolError,
224
- "MCP stdio server did not start within #{@startup_timeout} seconds"
225
255
  end
226
256
 
227
257
  def rpc_call(method, params)
228
258
  ensure_started!
229
259
  payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
230
260
  @stdin.puts(payload)
231
- raw = Timeout.timeout(@read_timeout) { @stdout.gets }
261
+ unless IO.select([@stdout], nil, nil, @read_timeout)
262
+ raise Phronomy::ToolError,
263
+ "MCP stdio server did not respond within #{@read_timeout} seconds"
264
+ end
265
+ raw = @stdout.gets
232
266
  raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
233
267
  JSON.parse(raw)
234
- rescue Timeout::Error
235
- raise Phronomy::ToolError,
236
- "MCP stdio server did not respond within #{@read_timeout} seconds"
237
268
  end
238
269
 
239
270
  def parse_schema_params(properties, required_names: [])
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Tool
5
+ # Evaluates whether a tool with a given scope may execute.
6
+ #
7
+ # A ScopePolicy is a callable that receives +(tool_class, scope, agent)+ and
8
+ # returns one of:
9
+ # +:allow+ — proceed immediately without an approval gate.
10
+ # +:reject+ — block execution; the tool returns a denial message.
11
+ # +:approve+ — delegate to the agent's approval handler (if registered);
12
+ # when no handler is registered the call is rejected.
13
+ #
14
+ # The {Default} instance is used automatically when no custom policy is
15
+ # configured on an agent.
16
+ #
17
+ # @example Custom policy that allows everything
18
+ # agent.scope_policy = ->(_tool_class, _scope, _agent) { :allow }
19
+ #
20
+ # @example Strict policy that rejects all write scopes
21
+ # agent.scope_policy = ->(_tc, scope, _agent) {
22
+ # scope == :write ? :reject : :allow
23
+ # }
24
+ class ScopePolicy
25
+ # Scopes that must go through an approval gate before execution.
26
+ APPROVAL_REQUIRED_SCOPES = %i[write admin external_network filesystem process external_process].freeze
27
+
28
+ # Scopes that are always permitted without approval.
29
+ ALWAYS_ALLOWED_SCOPES = %i[read_only].freeze
30
+
31
+ # Returns +:allow+ for always-allowed scopes, +:approve+ for high-risk
32
+ # scopes, and +:allow+ for anything else (including +nil+).
33
+ #
34
+ # @param _tool_class [Class]
35
+ # @param scope [Symbol, nil]
36
+ # @param _agent [Object]
37
+ # @return [:allow, :approve, :reject]
38
+ # @api private
39
+ def call(_tool_class, scope, _agent)
40
+ return :allow if scope.nil? || ALWAYS_ALLOWED_SCOPES.include?(scope)
41
+ return :approve if APPROVAL_REQUIRED_SCOPES.include?(scope)
42
+
43
+ :allow
44
+ end
45
+
46
+ # Shared singleton used when no custom policy is configured.
47
+ DEFAULT = new.freeze
48
+ end
49
+ end
50
+ end
@@ -16,7 +16,9 @@ module Phronomy
16
16
  # Returns a minimal span object with the given name.
17
17
  def start_span(name, **) = SpanStruct.new(name)
18
18
 
19
- # Does nothing.
19
+ # Does nothing. Explicit nil is equivalent to an empty method body; the
20
+ # mutation "remove nil" is accepted as it does not change observable behaviour.
21
+ # mutant:disable
20
22
  def finish_span(span, **) = nil
21
23
  end
22
24
  end
@@ -52,6 +52,40 @@ module Phronomy
52
52
  end
53
53
  span.finish
54
54
  end
55
+
56
+ # Overrides Base#trace to use OTel +in_span+, which pushes the span onto
57
+ # the OTel Context stack while the block runs. Nested +trace+ calls
58
+ # automatically inherit the current span as their parent, establishing the
59
+ # correct parent/child hierarchy in the trace tree.
60
+ #
61
+ # @param name [String] span name
62
+ # @param input [Object, nil] input to record as a span attribute
63
+ # @param meta [Hash] additional metadata (e.g. task_id, user_id)
64
+ # @yield [span] the active OTel span
65
+ # @return [Object] the block's return value
66
+ # @api private
67
+ def trace(name, input: nil, **meta)
68
+ attrs = {}
69
+ attrs["phronomy.input"] = input.to_s if input
70
+ meta.each { |k, v| attrs["phronomy.#{k}"] = v.to_s unless v.nil? }
71
+
72
+ result = nil
73
+ @otel_tracer.in_span(name, attributes: attrs) do |span|
74
+ result, usage = yield span
75
+ span.set_attribute("phronomy.output", result.to_s) if result
76
+ if usage
77
+ span.set_attribute("llm.usage.input_tokens", usage.input)
78
+ span.set_attribute("llm.usage.output_tokens", usage.output)
79
+ total = (usage.input || 0) + (usage.output || 0)
80
+ span.set_attribute("llm.usage.total_tokens", total)
81
+ end
82
+ rescue => e
83
+ span.record_exception(e)
84
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
85
+ raise
86
+ end
87
+ result
88
+ end
55
89
  end
56
90
  end
57
91
  end
@@ -4,8 +4,8 @@ module Phronomy
4
4
  # Vector store implementations for embedding-based semantic search.
5
5
  #
6
6
  # Sub-classes are auto-loaded by Zeitwerk:
7
- # Phronomy::VectorStore::Base
8
- # Phronomy::VectorStore::InMemory
7
+ # Phronomy::Agent::Context::Knowledge::VectorStore::Base
8
+ # Phronomy::Agent::Context::Knowledge::VectorStore::InMemory
9
9
  module VectorStore
10
10
  end
11
11
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -81,12 +81,35 @@ module Phronomy
81
81
  # Executes the workflow from the initial state.
82
82
  # @param input [Hash] initial context field values
83
83
  # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
84
+ # @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
85
+ # object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
86
+ # derived from it (existing +config:+ keys take precedence). The object is also
87
+ # stored in +config[:invocation_context]+ for downstream tracing.
84
88
  # @return [Object] final context
85
89
  # @api public
86
- def invoke(input, config: {})
90
+ def invoke(input, config: {}, invocation_context: nil)
91
+ if invocation_context
92
+ config = _apply_invocation_context(config, invocation_context)
93
+ end
87
94
  @runner.invoke(input, config: config)
88
95
  end
89
96
 
97
+ # Invokes this workflow asynchronously and returns a {Phronomy::Task}.
98
+ #
99
+ # @param input [Hash]
100
+ # @param config [Hash]
101
+ # @param invocation_context [Phronomy::InvocationContext, nil]
102
+ # @return [Phronomy::Task]
103
+ # @api public
104
+ def invoke_async(input, config: {}, invocation_context: nil)
105
+ if invocation_context
106
+ config = _apply_invocation_context(config, invocation_context)
107
+ end
108
+ Phronomy::Runtime.instance.spawn(name: "workflow-invoke-async") do
109
+ invoke(input, config: config)
110
+ end
111
+ end
112
+
90
113
  # Resumes a halted workflow. Generic resume that works for all halt types.
91
114
  # @param state [Object] halted context
92
115
  # @param input [Hash, nil] optional field updates to merge before resuming
@@ -116,6 +139,23 @@ module Phronomy
116
139
  @runner.stream(input, config: config, &block)
117
140
  end
118
141
 
142
+ private
143
+
144
+ # Merges an {InvocationContext} into the config hash.
145
+ # Existing +config+ keys take precedence (backward-compat).
146
+ def _apply_invocation_context(config, ic)
147
+ effective = config.merge(invocation_context: ic)
148
+ effective = effective.merge(thread_id: ic.thread_id) if effective[:thread_id].nil? && ic.thread_id
149
+ if effective[:cancellation_token].nil?
150
+ if (tok = ic.effective_timeout_token)
151
+ effective = effective.merge(cancellation_token: tok)
152
+ end
153
+ end
154
+ effective
155
+ end
156
+
157
+ public
158
+
119
159
  # ---------------------------------------------------------------------------
120
160
  # Internal DSL builder
121
161
  # ---------------------------------------------------------------------------
@@ -139,6 +179,8 @@ module Phronomy
139
179
  @transitions = []
140
180
  # Set of wait state names
141
181
  @wait_state_names = []
182
+ # { state_name => Numeric } — per-state action timeout in seconds
183
+ @action_timeouts = {}
142
184
  end
143
185
 
144
186
  # Declares the initial (entry) state.
@@ -151,13 +193,17 @@ module Phronomy
151
193
  # rubocop:enable Style/TrivialAccessors
152
194
 
153
195
  # Declares an action state.
154
- # @param name [Symbol] state name
155
- # @param action [#call, nil] optional entry action shorthand.
196
+ # @param name [Symbol] state name
197
+ # @param action [#call, nil] optional entry action shorthand.
156
198
  # +state :generate, action: MY_PROC+ is equivalent to
157
199
  # +state :generate; entry :generate, MY_PROC+.
200
+ # @param action_timeout [Numeric, nil] seconds before an async (Task-returning)
201
+ # entry action is cancelled and {Phronomy::ActionTimeoutError} is raised.
202
+ # Only applies when the action returns a {Task} or {PendingOperation}.
158
203
  # @api public
159
- def state(name, action: nil)
204
+ def state(name, action: nil, action_timeout: nil)
160
205
  @declared_states << name
206
+ @action_timeouts[name] = action_timeout if action_timeout
161
207
  entry(name, action) if action
162
208
  end
163
209
 
@@ -309,7 +355,8 @@ module Phronomy
309
355
  external_events: external_events,
310
356
  entry_point: @initial || @declared_states.first,
311
357
  wait_state_names: @wait_state_names,
312
- state_store: @state_store
358
+ state_store: @state_store,
359
+ action_timeouts: @action_timeouts.dup
313
360
  )
314
361
 
315
362
  Workflow.new(runner)
@@ -42,7 +42,16 @@ module Phronomy
42
42
  end
43
43
 
44
44
  @fields[name] = {type: type, default: default}
45
- 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
46
55
  end
47
56
 
48
57
  def fields
@@ -61,6 +70,7 @@ module Phronomy
61
70
  # :<state> — resuming at <state> (workflow paused before its execution)
62
71
  # @return [Symbol]
63
72
  # @api public
73
+ # mutant:disable - @phase is always non-nil (set to :__end__ in initialize, only changed by set_graph_metadata which never sets nil), so the || :__end__ fallback branch is never reached — all mutations of the right-hand side are genuine equivalents
64
74
  def phase
65
75
  @phase || :__end__
66
76
  end
@@ -68,6 +78,7 @@ module Phronomy
68
78
  # Returns true if the workflow is paused mid-execution (not yet completed).
69
79
  # @return [Boolean]
70
80
  # @api public
81
+ # mutant:disable - phase != :__end__ vs !phase.eql?(:__end__) vs !phase.equal?(:__end__) are genuine equivalents for Symbol (Symbols are interned so == / eql? / equal? all behave identically)
71
82
  def halted?
72
83
  phase != :__end__
73
84
  end
@@ -76,19 +87,23 @@ module Phronomy
76
87
  # @param thread_id [String, nil]
77
88
  # @param phase [Symbol, nil]
78
89
  # @api public
90
+ # mutant:disable - mutations replacing return value `self` with nil or removing the last line are genuine equivalents: callers chain on the return value only in merge which immediately discards it
79
91
  def set_graph_metadata(thread_id: nil, phase: nil)
80
92
  @thread_id = thread_id unless thread_id.nil?
81
93
  @phase = phase unless phase.nil?
82
94
  self
83
95
  end
84
96
 
97
+ # mutant:disable - multiple genuine equivalent mutations: is_a?(Proc) vs instance_of?(Proc) (Proc has no subclasses in practice), config[]/fetch() for always-present :default key, @thread_id=nil removal (unset ivar is already nil), @phase=:__end__ → nil or removal (phase method returns :__end__ via @phase||:__end__ fallback), raise message #{.inspect} vs #{} (spec checks exception class not message text)
85
98
  def initialize(**attrs)
86
99
  unknown = attrs.keys - self.class.fields.keys
87
100
  raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
88
101
 
89
102
  self.class.fields.each do |name, config|
90
103
  default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
91
- send(:"#{name}=", attrs.fetch(name, default))
104
+ # Bypass the write guard in initialize — ownership enforcement begins
105
+ # after construction is complete.
106
+ instance_variable_set(:"@#{name}", attrs.fetch(name, default))
92
107
  end
93
108
  @thread_id = nil
94
109
  @phase = :__end__
@@ -103,6 +118,7 @@ module Phronomy
103
118
  # @return [self.class] new context instance
104
119
  # @raise [ArgumentError] if updates contains keys that are not declared fields
105
120
  # @api public
121
+ # mutant:disable - multiple genuine equivalent mutations: send/public_send/__send__ are identical (all field accessors are public), fields[]/fetch() and field_config[]/fetch() for always-present keys, updates[]/fetch() when updates.key?(name) is already true, Array() wrapping for append fields that always hold Arrays, (send||{})/send equivalence for merge fields that always hold Hashes, deep_dup_value(send) vs send are equivalent under killfork (coverage selection does not trace the deep_dup_value call site across the fork boundary), raise message inspect vs to_s (spec checks exception class only)
106
122
  def merge(updates)
107
123
  unknown = updates.keys - self.class.fields.keys
108
124
  raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
@@ -134,6 +150,7 @@ module Phronomy
134
150
  # Converts user-defined fields to a Hash (excludes internal workflow metadata).
135
151
  # @return [Hash]
136
152
  # @api public
153
+ # mutant:disable - send/public_send/__send__ are genuine equivalents (all field accessors are public methods)
137
154
  def to_h
138
155
  self.class.fields.keys.each_with_object({}) do |name, h|
139
156
  h[name] = send(name)
@@ -142,11 +159,29 @@ module Phronomy
142
159
 
143
160
  private
144
161
 
162
+ # Asserts that the calling thread is allowed to mutate this context.
163
+ # No-op when EventLoop mode is disabled.
164
+ # @raise [Phronomy::WorkflowContextOwnershipError] when called from a
165
+ # non-EventLoop thread in EventLoop mode.
166
+ # @api private
167
+ # mutant:disable - multiple genuine equivalent mutations: defined?(Phronomy::EventLoop)&& removal is genuine because EventLoop is always loaded in the killfork environment; true&& is genuine (truthy guard); EventLoop.current? resolves to Phronomy::EventLoop.current? within the Phronomy module; WorkflowContextOwnershipError resolves to Phronomy::WorkflowContextOwnershipError within the module; raise without message or with nil message is genuine (spec checks exception class, not message text)
168
+ def _assert_write_permitted!
169
+ return unless defined?(Phronomy::EventLoop) &&
170
+ Phronomy.configuration.event_loop
171
+ return if Phronomy::EventLoop.current?
172
+
173
+ raise Phronomy::WorkflowContextOwnershipError,
174
+ "WorkflowContext fields may only be mutated from the EventLoop dispatch " \
175
+ "thread. Use context.merge(...) to produce a new context, or deliver " \
176
+ "updates as event payloads."
177
+ end
178
+
145
179
  # Performs a deep copy of a value for immutable context propagation.
146
180
  # Arrays and Hashes are deep-duplicated recursively.
147
181
  # Immutable values (nil, Symbol, Integer, Float, true/false, frozen String) are returned as-is.
148
182
  # Other objects are dup'd (best-effort shallow copy for custom types).
149
183
  # Objects that cannot be dup'd (e.g. Proc, Method) are returned as-is.
184
+ # mutant:disable - multiple genuine equivalent mutations: each class in the when clause (NilClass/Symbol/Integer/Float/TrueClass/FalseClass) can be removed or replaced with nil because all those types are frozen so the else-branch val.frozen? guard returns the same result; return val vs val is also equivalent; if val.frozen? vs if self.frozen? is equivalent since self is never frozen in this context
150
185
  def deep_dup_value(val)
151
186
  case val
152
187
  when Array
@@ -45,7 +45,7 @@ module Phronomy
45
45
  # Sentinel value for the terminal state of a workflow.
46
46
  FINISH = :__end__
47
47
 
48
- def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [], state_store: nil)
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: {})
49
49
  @state_class = state_class
50
50
  @entry_actions = entry_actions # { state_name => [callable, ...] }
51
51
  @declared_states = declared_states
@@ -55,7 +55,17 @@ module Phronomy
55
55
  @entry_point = entry_point
56
56
  @wait_state_names = wait_state_names
57
57
  @state_store = state_store
58
- @phase_machine_class = build_phase_machine_class(auto_transitions, exit_actions)
58
+ @action_timeouts = action_timeouts # { state_name => seconds }
59
+ @phase_machine_class = Agent::Lifecycle::PhaseMachineBuilder.new(
60
+ entry_point: @entry_point,
61
+ declared_states: @declared_states,
62
+ wait_state_names: @wait_state_names,
63
+ external_events: @external_events,
64
+ entry_actions: @entry_actions,
65
+ action_timeouts: @action_timeouts,
66
+ auto_transitions: auto_transitions,
67
+ exit_actions: exit_actions
68
+ ).build
59
69
  end
60
70
 
61
71
  # Executes the workflow from the initial state.
@@ -159,7 +169,7 @@ module Phronomy
159
169
 
160
170
  # Builds an FSMSession for the given context. Used in EventLoop mode.
161
171
  def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
162
- Phronomy::FSMSession.new(
172
+ Phronomy::Agent::Lifecycle::FSMSession.new(
163
173
  id: context.thread_id,
164
174
  context: context,
165
175
  entry_point: @entry_point,
@@ -170,6 +180,7 @@ module Phronomy
170
180
  external_events: @external_events,
171
181
  phase_machine_class: @phase_machine_class,
172
182
  recursion_limit: recursion_limit,
183
+ action_timeouts: @action_timeouts,
173
184
  resume_event: resume_event,
174
185
  resume_phase: resume_phase
175
186
  )
@@ -211,7 +222,20 @@ module Phronomy
211
222
  # The entry point has no prior transition, so we invoke its entry actions directly.
212
223
  @entry_actions[current_state]&.each do |c|
213
224
  result = c.call(ctx)
214
- ctx = result if result.is_a?(Phronomy::WorkflowContext)
225
+ if result.is_a?(Phronomy::Task)
226
+ timeout_secs = @action_timeouts[current_state]
227
+ if timeout_secs
228
+ if result.join(timeout_secs).nil?
229
+ result.cancel!
230
+ raise Phronomy::ActionTimeoutError,
231
+ "Action in state #{current_state.inspect} timed out after #{timeout_secs}s"
232
+ end
233
+ end
234
+ task_result = result.await
235
+ ctx = task_result if task_result.is_a?(Phronomy::WorkflowContext)
236
+ elsif result.is_a?(Phronomy::WorkflowContext)
237
+ ctx = result
238
+ end
215
239
  end
216
240
  tracker.context = ctx
217
241
  end
@@ -295,79 +319,6 @@ module Phronomy
295
319
  # before_transition from — exit callbacks (invoked when leaving a state)
296
320
  #
297
321
  # Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
298
- def build_phase_machine_class(auto_transitions, exit_actions)
299
- entry = @entry_point
300
- all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
301
- auto_trans = auto_transitions # Array of { from:, to:, guard: }
302
- ext_events = @external_events
303
- entry_acts = @entry_actions
304
- exit_acts = exit_actions
305
-
306
- Class.new do
307
- # Holds the current WorkflowContext so guards and callbacks can read it.
308
- attr_accessor :context
309
-
310
- state_machine :phase, initial: entry do
311
- all_states.each { |s| state s }
312
-
313
- # Auto-fire transitions: all auto transitions unified under :state_completed.
314
- # Includes unguarded (unconditional) and guarded (conditional) transitions.
315
- # Declaration order is preserved; guards are evaluated before unguarded fallbacks.
316
- event :state_completed do
317
- auto_trans.each do |t|
318
- if t[:guard]
319
- guard_proc = t[:guard]
320
- transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
321
- else
322
- transition t[:from] => t[:to]
323
- end
324
- end
325
- end
326
-
327
- # External events: human-in-the-loop triggers from wait states.
328
- ext_events.each do |ev_name, transitions|
329
- event ev_name do
330
- transitions.each do |t|
331
- if t[:guard]
332
- guard_proc = t[:guard]
333
- transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
334
- else
335
- transition t[:from] => t[:to]
336
- end
337
- end
338
- end
339
- end
340
-
341
- # Entry callbacks: fire after_transition into each state.
342
- # Each callable is registered as a separate callback; state_machines
343
- # accumulates them and fires in declaration order.
344
- # If the callable returns a WorkflowContext (e.g. via s.merge(...)),
345
- # the returned context replaces the current one on the tracker.
346
- entry_acts.each do |state_name, callables|
347
- callables.each do |callable|
348
- after_transition to: state_name do |machine|
349
- result = callable.call(machine.context)
350
- machine.context = result if result.is_a?(Phronomy::WorkflowContext)
351
- end
352
- end
353
- end
354
-
355
- # Exit callbacks: fire before_transition out of each state.
356
- # Each callable is registered as a separate callback; state_machines
357
- # accumulates them and fires in declaration order.
358
- exit_acts.each do |state_name, callables|
359
- callables.each do |callable|
360
- before_transition from: state_name do |machine|
361
- callable.call(machine.context)
362
- end
363
- end
364
- end
365
- end
366
- end
367
- rescue => e
368
- raise ArgumentError, "Failed to build phase machine: #{e.message}"
369
- end
370
-
371
322
  # Creates a PhaseTracker instance initialized to +from_state+.
372
323
  def new_phase_machine(from_state)
373
324
  machine = @phase_machine_class.new
data/lib/phronomy.rb CHANGED
@@ -8,10 +8,15 @@ loader = Zeitwerk::Loader.for_gem
8
8
  # Teach Zeitwerk that "llm" maps to "LLM" so that file names such as
9
9
  # ruby_llm_embeddings.rb resolve to RubyLLMEmbeddings (not RubyLlmEmbeddings).
10
10
  loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
11
+ loader.inflector.inflect("rag_knowledge" => "RAGKnowledge")
11
12
  # FSMSession: Zeitwerk would infer "FsmSession" — override to "FSMSession".
12
13
  loader.inflector.inflect("fsm_session" => "FSMSession")
13
14
  # AgentFSM: Zeitwerk would infer "Fsm" — override to "FSM".
14
15
  loader.inflector.inflect("fsm" => "FSM")
16
+ # LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
17
+ loader.inflector.inflect("llm_adapter" => "LLMAdapter")
18
+ # LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
19
+ loader.inflector.inflect("ruby_llm" => "RubyLLM")
15
20
  loader.setup
16
21
 
17
22
  require_relative "phronomy/version"
@@ -50,6 +55,22 @@ module Phronomy
50
55
  # Separate from TimeoutError (deadline exceeded) — this is an intentional stop.
51
56
  class CancellationError < Error; end
52
57
 
58
+ # Raised when {Agent#invoke} (a synchronous, blocking call) is attempted from
59
+ # inside an active scheduler task and +strict_runtime_guards+ is enabled.
60
+ #
61
+ # Calling a blocking invocation from within a scheduler task stalls the
62
+ # scheduler until the inner invocation completes, preventing other tasks from
63
+ # making progress (hidden deadlock risk). Use {Agent#invoke_async} followed by
64
+ # +#await+ inside scheduler tasks instead.
65
+ #
66
+ # This error is only raised when:
67
+ # Phronomy.configure { |c| c.strict_runtime_guards = true }
68
+ #
69
+ # By default a warning is logged and execution continues.
70
+ #
71
+ # @see Phronomy::Runtime.in_scheduler_context?
72
+ class SchedulerReentrancyError < Error; end
73
+
53
74
  # Raised by {Phronomy::GeneratorVerifier#invoke} when +raise_if_untrusted: true+
54
75
  # and the pipeline's combined confidence score falls below the configured threshold.
55
76
  #
@@ -76,6 +97,28 @@ module Phronomy
76
97
  end
77
98
  end
78
99
 
100
+ # Raised when an operation is submitted to a {BlockingAdapterPool} that has
101
+ # already been shut down via {BlockingAdapterPool#shutdown}.
102
+ class PoolShutdownError < Error; end
103
+
104
+ # Raised when a concurrency limit is exceeded and the configured backpressure
105
+ # strategy is +:raise+. The caller should back off and retry.
106
+ class BackpressureError < Error; end
107
+
108
+ # Raised by {CancellationScope#pop_queue} when the deadline expires before a
109
+ # result is available. Extends {TimeoutError} for backwards compatibility.
110
+ class ScopeTimeoutError < TimeoutError; end
111
+
112
+ # Raised when a Workflow entry/exit action task exceeds the +action_timeout:+
113
+ # configured for its state. Extends {TimeoutError}.
114
+ class ActionTimeoutError < TimeoutError; end
115
+
116
+ # Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
117
+ # that does not own the context (i.e. not the EventLoop dispatch thread).
118
+ # Only raised in EventLoop mode. Use +context.merge(...)+ to produce a new
119
+ # context, or deliver updates as +:child_completed+ event payloads.
120
+ class WorkflowContextOwnershipError < Error; end
121
+
79
122
  class << self
80
123
  def configuration
81
124
  @configuration ||= Configuration.new