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
@@ -36,6 +36,7 @@ module Phronomy
36
36
  # - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
37
37
  # @param tool_name [String] the tool name as registered in the MCP server
38
38
  # @return [McpTool] a configured subclass instance ready for use with an Agent
39
+ # @api public
39
40
  def from_server(server_uri, tool_name:)
40
41
  # Use a short-lived transport only to query the tool definition,
41
42
  # then close it. Each McpTool instance creates its own transport
@@ -65,13 +66,16 @@ module Phronomy
65
66
 
66
67
  def build_tool_class(tool_name, server_uri, tool_def)
67
68
  klass = Class.new(McpTool)
68
- klass.instance_variable_set(:@mcp_tool_name, tool_name)
69
+ klass.tool_name(tool_name)
69
70
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
70
71
 
71
72
  # Register description and params from the MCP tool definition.
72
73
  klass.description(tool_def[:description] || tool_name)
73
74
  (tool_def[:parameters] || []).each do |p|
74
- klass.param(p[:name].to_sym, type: p[:type]&.to_sym || :string, desc: p[:description].to_s)
75
+ opts = {type: p[:type]&.to_sym || :string, desc: p[:description].to_s}
76
+ opts[:required] = p[:required] if p.key?(:required)
77
+ opts[:enum] = p[:enum] if p.key?(:enum)
78
+ klass.param(p[:name].to_sym, **opts)
75
79
  end
76
80
 
77
81
  # Each instance creates its own transport so concurrent agent threads
@@ -107,15 +111,35 @@ module Phronomy
107
111
  # so that session state (registered resources, tool context, etc.) is preserved
108
112
  # across multiple calls.
109
113
  class StdioTransport
110
- def initialize(command)
114
+ # @param command [String] shell command to spawn the MCP server process
115
+ # @param read_timeout [Integer] seconds to wait for the server's JSON-RPC response
116
+ # before raising {Phronomy::ToolError}. Mirrors the +read_timeout+ option on
117
+ # {HttpTransport}. Defaults to 30 seconds.
118
+ # @param env [Hash, nil] environment variable overrides for the subprocess.
119
+ # When provided, only these variables are added/overridden; the parent environment
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.
123
+ # @param cwd [String, nil] working directory for the subprocess.
124
+ # Defaults to the current process's working directory.
125
+ # @param startup_timeout [Numeric, nil] seconds to wait for the server to
126
+ # emit its first line on stdout before raising {Phronomy::ToolError}.
127
+ # When nil (default), no startup check is performed.
128
+ # @api public
129
+ def initialize(command, read_timeout: 30, env: nil, cwd: nil, startup_timeout: nil)
111
130
  # Split the command string into an argv array so that Open3 executes
112
131
  # it directly without going through the shell, preventing injection.
113
132
  @command = Shellwords.split(command)
133
+ @read_timeout = read_timeout
134
+ @env = env
135
+ @cwd = cwd
136
+ @startup_timeout = startup_timeout
114
137
  @stdin = nil
115
138
  @stdout = nil
116
139
  @stderr = nil
117
140
  @wait_thr = nil
118
141
  @stderr_thread = nil
142
+ @stderr_op = nil
119
143
  end
120
144
 
121
145
  # Shut down the child process and close its IO streams.
@@ -127,25 +151,34 @@ module Phronomy
127
151
  @stdout = nil
128
152
  @stderr = nil
129
153
  stderr_thread = @stderr_thread
154
+ stderr_op = @stderr_op
130
155
  wait_thr = @wait_thr
131
156
  @stderr_thread = nil
157
+ @stderr_op = nil
132
158
  @wait_thr = nil
133
159
  stderr_thread&.join(1)
160
+ begin
161
+ stderr_op&.await(timeout: 1.0)
162
+ rescue
163
+ nil
164
+ end
134
165
  wait_thr&.join(5)
135
166
  end
136
167
 
137
168
  # Retrieve the tool definition from the server using the MCP `tools/list` method.
138
169
  # @param tool_name [String]
139
170
  # @return [Hash] { description:, parameters: }
171
+ # @api public
140
172
  def fetch_tool(tool_name)
141
173
  response = rpc_call("tools/list", {})
142
174
  tools = response.dig("result", "tools") || []
143
175
  defn = tools.find { |t| t["name"] == tool_name }
144
176
  raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@command.inspect}" unless defn
145
177
 
178
+ required_names = defn.dig("inputSchema", "required") || []
146
179
  {
147
180
  description: defn["description"],
148
- parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {})
181
+ parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
149
182
  }
150
183
  end
151
184
 
@@ -153,6 +186,7 @@ module Phronomy
153
186
  # @param tool_name [String]
154
187
  # @param args [Hash]
155
188
  # @return [Object] the tool result
189
+ # @api public
156
190
  def call_tool(tool_name, args)
157
191
  response = rpc_call("tools/call", {name: tool_name, arguments: args})
158
192
  if response["error"]
@@ -176,14 +210,47 @@ module Phronomy
176
210
  def ensure_started!
177
211
  return if @stdin && !@stdin.closed?
178
212
 
179
- @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*@command)
213
+ popen3_opts = {}
214
+ popen3_opts[:chdir] = @cwd if @cwd
215
+
216
+ argv = @env ? [@env, *@command] : @command
217
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*argv, **popen3_opts)
180
218
  # Drain stderr asynchronously to prevent the pipe buffer from filling
181
219
  # and deadlocking the child process. Errors inside the drain thread are
182
220
  # silently ignored since stderr content is diagnostics-only.
183
- @stderr_thread = Thread.new do
184
- @stderr.read
185
- rescue
186
- 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
244
+ end
245
+
246
+ if @startup_timeout
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
187
254
  end
188
255
  end
189
256
 
@@ -191,18 +258,25 @@ module Phronomy
191
258
  ensure_started!
192
259
  payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
193
260
  @stdin.puts(payload)
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
194
265
  raw = @stdout.gets
195
266
  raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
196
267
  JSON.parse(raw)
197
268
  end
198
269
 
199
- def parse_schema_params(properties)
270
+ def parse_schema_params(properties, required_names: [])
200
271
  properties.map do |name, schema|
201
- {
272
+ param = {
202
273
  name: name.to_s,
203
274
  type: schema["type"] || "string",
204
- description: schema["description"].to_s
275
+ description: schema["description"].to_s,
276
+ required: required_names.include?(name.to_s)
205
277
  }
278
+ param[:enum] = schema["enum"] if schema["enum"]
279
+ param
206
280
  end
207
281
  end
208
282
  end
@@ -223,10 +297,15 @@ module Phronomy
223
297
  # @param base_url [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
224
298
  # @param open_timeout [Integer] TCP connection timeout in seconds (default: 5)
225
299
  # @param read_timeout [Integer] HTTP read timeout in seconds (default: 30)
226
- def initialize(base_url, open_timeout: 5, read_timeout: 30)
300
+ # @param headers [Hash] additional HTTP request headers (e.g. Authorization).
301
+ # Merged on top of the default Content-Type and Accept headers; caller-supplied
302
+ # values override defaults when keys collide.
303
+ # @api public
304
+ def initialize(base_url, open_timeout: 5, read_timeout: 30, headers: {})
227
305
  @uri = URI.parse(base_url)
228
306
  @open_timeout = open_timeout
229
307
  @read_timeout = read_timeout
308
+ @extra_headers = headers
230
309
  end
231
310
 
232
311
  # HTTP connections are stateless; close is a no-op, defined so that
@@ -237,15 +316,17 @@ module Phronomy
237
316
  # Retrieve the tool definition from the server using MCP `tools/list`.
238
317
  # @param tool_name [String]
239
318
  # @return [Hash] { description:, parameters: }
319
+ # @api public
240
320
  def fetch_tool(tool_name)
241
321
  response = rpc_call("tools/list", {})
242
322
  tools = response.dig("result", "tools") || []
243
323
  defn = tools.find { |t| t["name"] == tool_name }
244
324
  raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@uri}" unless defn
245
325
 
326
+ required_names = defn.dig("inputSchema", "required") || []
246
327
  {
247
328
  description: defn["description"],
248
- parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {})
329
+ parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
249
330
  }
250
331
  end
251
332
 
@@ -253,6 +334,7 @@ module Phronomy
253
334
  # @param tool_name [String]
254
335
  # @param args [Hash]
255
336
  # @return [Object] the tool result
337
+ # @api public
256
338
  def call_tool(tool_name, args)
257
339
  response = rpc_call("tools/call", {name: tool_name, arguments: args})
258
340
  if response["error"]
@@ -285,6 +367,7 @@ module Phronomy
285
367
  request = Net::HTTP::Post.new(path)
286
368
  request["Content-Type"] = "application/json"
287
369
  request["Accept"] = "application/json, text/event-stream"
370
+ @extra_headers.each { |k, v| request[k.to_s] = v.to_s }
288
371
  request.body = payload
289
372
 
290
373
  http_response = http.request(request)
@@ -323,13 +406,16 @@ module Phronomy
323
406
  result || raise(Phronomy::ToolError, "No valid JSON-RPC response found in SSE stream")
324
407
  end
325
408
 
326
- def parse_schema_params(properties)
409
+ def parse_schema_params(properties, required_names: [])
327
410
  properties.map do |name, schema|
328
- {
411
+ param = {
329
412
  name: name.to_s,
330
413
  type: schema["type"] || "string",
331
- description: schema["description"].to_s
414
+ description: schema["description"].to_s,
415
+ required: required_names.include?(name.to_s)
332
416
  }
417
+ param[:enum] = schema["enum"] if schema["enum"]
418
+ param
333
419
  end
334
420
  end
335
421
  end
@@ -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
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Centralises tool execution routing based on {Tool::Base.execution_mode}.
5
+ #
6
+ # This is the single place in the framework that decides *how* a tool call is
7
+ # dispatched:
8
+ #
9
+ # - +:cooperative+ — dispatched via +Runtime#spawn+ through the configured
10
+ # scheduler. Under the +:fiber+ backend this avoids an
11
+ # extra OS thread; under the +:thread+ backend it is
12
+ # backed by +ThreadScheduler+ (one thread per task).
13
+ # - +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime
14
+ # provides a pool; falls back to +Runtime#spawn+ otherwise.
15
+ # - +:cpu_bound+ — emits a deprecation-style warning then falls back to
16
+ # +:blocking_io+ routing (no process pool available yet).
17
+ # - +:external_process+ — falls back to +:blocking_io+ routing (no process
18
+ # manager available yet).
19
+ #
20
+ # All paths return an object that responds to +#await+ (+Phronomy::Task+ or
21
+ # +BlockingAdapterPool::PendingOperation+), so callers can collect results
22
+ # uniformly.
23
+ #
24
+ # @note Non-goals
25
+ # ToolExecutor deliberately does NOT provide:
26
+ # - A CPU-bound process pool. CPU-intensive tool work must be handled at the
27
+ # application layer (e.g., fork, Sidekiq, separate OS processes). The
28
+ # framework will not add a +ProcessPoolExecutor+ equivalent.
29
+ # - An external process manager. Spawning or supervising subprocesses is
30
+ # out of scope for this module.
31
+ # - Additional core execution routes beyond scheduler-backed cooperative
32
+ # execution and BlockingAdapterPool-backed blocking I/O isolation.
33
+ # The +:cpu_bound+ and +:external_process+ modes are accepted for
34
+ # compatibility but both fall back to +:blocking_io+ routing with a
35
+ # one-time warning. If a genuinely new core execution route is needed,
36
+ # a new ADR is required.
37
+ # These non-goals follow from the cooperative-first, non-preemptive
38
+ # concurrency model (ADR-010): framework components must not assume the
39
+ # caller's concurrency model, and CPU/process management belongs to the
40
+ # application layer.
41
+ #
42
+ # @api private
43
+ module ToolExecutor
44
+ # Tracks tool classes that have already emitted an execution_mode warning so
45
+ # that the same warning is only logged once per process lifetime.
46
+ WARNED_MODES = Set.new
47
+ WARNED_MODES_MUTEX = Mutex.new
48
+ private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
49
+
50
+ # Dispatches a single tool call asynchronously according to its
51
+ # +execution_mode+ and returns an awaitable.
52
+ #
53
+ # @param tool [Phronomy::Tool::Base] the tool instance to invoke
54
+ # @param args [Hash] argument hash to pass to {Tool::Base#call}
55
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
56
+ # @param runtime [Phronomy::Runtime] runtime to use for spawning
57
+ # (defaults to {Runtime.instance}; injectable for tests)
58
+ # @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
59
+ # @api private
60
+ def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
61
+ ct = cancellation_token
62
+ mode = tool.class.execution_mode
63
+
64
+ # Warn and normalise unsupported modes to :blocking_io.
65
+ # Each (tool class, mode) pair emits the warning at most once per process
66
+ # lifetime to avoid log flooding in high-throughput scenarios.
67
+ if mode == :cpu_bound || mode == :external_process
68
+ warn_key = [tool.class.name, mode]
69
+ newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
70
+ if newly_warned
71
+ msg = if mode == :cpu_bound
72
+ "[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
73
+ "which has no dedicated executor. " \
74
+ "Falling back to blocking_io (BlockingAdapterPool). " \
75
+ "Use :blocking_io explicitly to suppress this warning."
76
+ else
77
+ "[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
78
+ "which has no dedicated process manager. " \
79
+ "Falling back to blocking_io (BlockingAdapterPool)."
80
+ end
81
+ if Phronomy.configuration.logger
82
+ Phronomy.configuration.logger.warn(msg)
83
+ else
84
+ warn msg
85
+ end
86
+ end
87
+ mode = :blocking_io
88
+ end
89
+
90
+ pool = begin
91
+ runtime&.blocking_io
92
+ rescue
93
+ nil
94
+ end
95
+
96
+ if mode == :cooperative || pool.nil?
97
+ runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
98
+ tool.call(args, cancellation_token: ct)
99
+ end
100
+ else
101
+ # Submit directly to pool — no wrapping Task thread required.
102
+ pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -23,6 +23,7 @@ module Phronomy
23
23
  # @param meta [Hash] additional metadata attached to the span
24
24
  # @yield [span] the active span object
25
25
  # @return [Object] the block's return value
26
+ # @api public
26
27
  def trace(name, input: nil, **meta)
27
28
  span = start_span(name, input: input, **meta)
28
29
  result, usage = yield span
@@ -38,6 +39,7 @@ module Phronomy
38
39
  # @param name [String]
39
40
  # @param attributes [Hash]
40
41
  # @return [Object] an opaque span handle
42
+ # @api public
41
43
  def start_span(name, **attributes)
42
44
  raise NotImplementedError, "#{self.class}#start_span is not implemented"
43
45
  end
@@ -48,6 +50,7 @@ module Phronomy
48
50
  # @param output [Object, nil] successful output value
49
51
  # @param usage [Phronomy::TokenUsage, nil] token usage for this span
50
52
  # @param error [Exception, nil] exception if the block raised
53
+ # @api public
51
54
  def finish_span(span, output: nil, usage: nil, error: nil)
52
55
  raise NotImplementedError, "#{self.class}#finish_span is not implemented"
53
56
  end
@@ -31,6 +31,7 @@ module Phronomy
31
31
  # @param public_key [String] Langfuse project public key
32
32
  # @param secret_key [String] Langfuse project secret key
33
33
  # @param host [String] Langfuse host URL (override for self-hosted instances)
34
+ # @api public
34
35
  def initialize(public_key:, secret_key:, host: DEFAULT_HOST)
35
36
  @public_key = public_key
36
37
  @secret_key = secret_key
@@ -41,6 +42,7 @@ module Phronomy
41
42
  # Returns a plain Hash that records the span start state.
42
43
  #
43
44
  # @return [Hash] an opaque span handle used by {#finish_span}
45
+ # @api public
44
46
  def start_span(name, input: nil, **meta)
45
47
  {
46
48
  id: SecureRandom.uuid,
@@ -17,6 +17,7 @@ module Phronomy
17
17
  # end
18
18
  class OpenTelemetryTracer < Base
19
19
  # @param tracer_name [String] name passed to the OTel TracerProvider
20
+ # @api public
20
21
  def initialize(tracer_name: "phronomy")
21
22
  require "opentelemetry"
22
23
  @otel_tracer = OpenTelemetry.tracer_provider.tracer(tracer_name, Phronomy::VERSION)
@@ -27,6 +28,7 @@ module Phronomy
27
28
  # +phronomy.+.
28
29
  #
29
30
  # @return [OpenTelemetry::Trace::Span]
31
+ # @api public
30
32
  def start_span(name, input: nil, **attributes)
31
33
  attrs = {}
32
34
  attrs["phronomy.input"] = input.to_s if input
@@ -50,6 +52,40 @@ module Phronomy
50
52
  end
51
53
  span.finish
52
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
53
89
  end
54
90
  end
55
91
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module VectorStore
5
+ # Mixin that defines the async interface for VectorStore backends.
6
+ #
7
+ # Mixing this module into a VectorStore class provides three choices:
8
+ #
9
+ # 1. **Do nothing** — inherits default implementations from {VectorStore::Base}
10
+ # that route through {BlockingAdapterPool} (the previous behaviour).
11
+ #
12
+ # 2. **Override selectively** — override only the async methods where the
13
+ # backend has a native async driver, while the remaining methods fall back
14
+ # to the pool.
15
+ #
16
+ # 3. **Implement all natively** — override all async methods to avoid pool
17
+ # allocation entirely.
18
+ #
19
+ # @example Native async search (no pool worker thread allocated)
20
+ # class MyFastStore < Phronomy::VectorStore::Base
21
+ # include Phronomy::VectorStore::AsyncBackend
22
+ #
23
+ # def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
24
+ # # Returns a PendingOperation backed by a native async driver.
25
+ # native_async_search(query_embedding, k)
26
+ # end
27
+ # end
28
+ #
29
+ # @api public
30
+ module AsyncBackend
31
+ # Async variant of {VectorStore::Base#add}.
32
+ #
33
+ # Submits the add call to {BlockingAdapterPool} by default.
34
+ # Override to use a native async driver.
35
+ #
36
+ # @param id [String]
37
+ # @param embedding [Array<Float>]
38
+ # @param metadata [Hash]
39
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
40
+ # @param timeout [Numeric, nil]
41
+ # @return [BlockingAdapterPool::PendingOperation]
42
+ # @api public
43
+ def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
44
+ Phronomy::Runtime.instance.blocking_io.submit(
45
+ timeout: timeout,
46
+ cancellation_token: cancellation_token
47
+ ) do
48
+ add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
49
+ end
50
+ end
51
+
52
+ # Async variant of {VectorStore::Base#search}.
53
+ #
54
+ # Submits the search call to {BlockingAdapterPool} by default.
55
+ # Override to use a native async driver.
56
+ #
57
+ # @param query_embedding [Array<Float>]
58
+ # @param k [Integer]
59
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
60
+ # @param timeout [Numeric, nil]
61
+ # @return [BlockingAdapterPool::PendingOperation]
62
+ # @api public
63
+ def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
64
+ Phronomy::Runtime.instance.blocking_io.submit(
65
+ timeout: timeout,
66
+ cancellation_token: cancellation_token
67
+ ) do
68
+ search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
69
+ end
70
+ end
71
+
72
+ # Async variant of {VectorStore::Base#remove}.
73
+ #
74
+ # Submits the remove call to {BlockingAdapterPool} by default.
75
+ # Override to use a native async driver.
76
+ #
77
+ # @param id [String]
78
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
79
+ # @param timeout [Numeric, nil]
80
+ # @return [BlockingAdapterPool::PendingOperation]
81
+ # @api public
82
+ def remove_async(id:, cancellation_token: nil, timeout: nil)
83
+ Phronomy::Runtime.instance.blocking_io.submit(
84
+ timeout: timeout,
85
+ cancellation_token: cancellation_token
86
+ ) do
87
+ remove(id: id)
88
+ end
89
+ end
90
+
91
+ # Async variant of {VectorStore::Base#clear}.
92
+ #
93
+ # Submits the clear call to {BlockingAdapterPool} by default.
94
+ # Override to use a native async driver.
95
+ #
96
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
97
+ # @param timeout [Numeric, nil]
98
+ # @return [BlockingAdapterPool::PendingOperation]
99
+ # @api public
100
+ def clear_async(cancellation_token: nil, timeout: nil)
101
+ Phronomy::Runtime.instance.blocking_io.submit(
102
+ timeout: timeout,
103
+ cancellation_token: cancellation_token
104
+ ) do
105
+ clear
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end