phronomy 0.7.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +155 -32
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_regression.rb +1 -0
  8. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  9. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  10. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  11. data/lib/phronomy/agent/base.rb +250 -65
  12. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  13. data/lib/phronomy/agent/fsm.rb +41 -64
  14. data/lib/phronomy/agent/orchestrator.rb +146 -121
  15. data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
  16. data/lib/phronomy/agent/react_agent.rb +8 -0
  17. data/lib/phronomy/async_queue.rb +155 -0
  18. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  19. data/lib/phronomy/cancellation_scope.rb +123 -0
  20. data/lib/phronomy/cancellation_token.rb +43 -2
  21. data/lib/phronomy/concurrency_gate.rb +155 -0
  22. data/lib/phronomy/configuration.rb +142 -0
  23. data/lib/phronomy/deadline.rb +63 -0
  24. data/lib/phronomy/diagnostics.rb +62 -0
  25. data/lib/phronomy/embeddings/base.rb +17 -0
  26. data/lib/phronomy/eval/runner.rb +9 -9
  27. data/lib/phronomy/event_loop.rb +181 -43
  28. data/lib/phronomy/fsm_session.rb +50 -4
  29. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  30. data/lib/phronomy/invocation_context.rb +152 -0
  31. data/lib/phronomy/knowledge_source/base.rb +18 -0
  32. data/lib/phronomy/llm_adapter/base.rb +104 -0
  33. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  34. data/lib/phronomy/llm_adapter.rb +20 -0
  35. data/lib/phronomy/metrics.rb +38 -0
  36. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  37. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  38. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  39. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  40. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  41. data/lib/phronomy/runtime/scheduler.rb +98 -0
  42. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  43. data/lib/phronomy/runtime/task_registry.rb +48 -0
  44. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  45. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  46. data/lib/phronomy/runtime/timer_service.rb +42 -0
  47. data/lib/phronomy/runtime.rb +374 -0
  48. data/lib/phronomy/task/backend.rb +80 -0
  49. data/lib/phronomy/task/fiber_backend.rb +157 -0
  50. data/lib/phronomy/task/immediate_backend.rb +89 -0
  51. data/lib/phronomy/task/thread_backend.rb +84 -0
  52. data/lib/phronomy/task.rb +275 -0
  53. data/lib/phronomy/task_group.rb +265 -0
  54. data/lib/phronomy/testing/fake_clock.rb +109 -0
  55. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  56. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  57. data/lib/phronomy/testing.rb +12 -0
  58. data/lib/phronomy/tool/base.rb +110 -2
  59. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  60. data/lib/phronomy/tool/scope_policy.rb +50 -0
  61. data/lib/phronomy/tool_executor.rb +106 -0
  62. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  63. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  64. data/lib/phronomy/vector_store/base.rb +7 -0
  65. data/lib/phronomy/version.rb +1 -1
  66. data/lib/phronomy/workflow.rb +52 -5
  67. data/lib/phronomy/workflow_context.rb +29 -2
  68. data/lib/phronomy/workflow_runner.rb +74 -3
  69. data/lib/phronomy.rb +42 -0
  70. metadata +40 -2
@@ -5,21 +5,39 @@ module Phronomy
5
5
  # RubyLLM::Chat subclass that executes multiple tool calls concurrently.
6
6
  #
7
7
  # When the LLM returns more than one tool call in a single response, each
8
- # tool is dispatched in a dedicated IO thread and all results are collected
9
- # before being appended to the message history. This preserves a
10
- # deterministic message order while reducing wall-clock latency when tools
11
- # are IO-bound (HTTP calls, DB queries, etc.).
8
+ # tool is dispatched according to its +execution_mode+:
9
+ # - +:cooperative+ tools run via +Runtime.instance.spawn+, delegating
10
+ # scheduling to the configured runtime backend.
11
+ # - +:blocking_io+ tools are offloaded to a +BlockingAdapterPool+ worker
12
+ # thread so they do not occupy a scheduler task slot.
13
+ # All results are collected before being appended to the message history,
14
+ # preserving deterministic message order while reducing wall-clock latency
15
+ # when tools are IO-bound (HTTP calls, DB queries, etc.).
12
16
  #
13
17
  # Single-tool responses fall through to the standard sequential path via
14
18
  # +super+, preserving all existing edge-case behaviour (Tool::Halt,
15
19
  # forced_tool_choice, streaming, SuspendSignal, etc.).
16
20
  #
17
- # This class is used automatically when the agent is running inside an
18
- # {AgentFSM} IO thread (i.e. when the +:phronomy_agent_parallel_tools+
19
- # thread-local flag is +true+). It is not used for direct synchronous
20
- # +invoke+ calls so that the streaming callback state remains single-threaded.
21
+ # This class is used automatically when EventLoop mode is enabled
22
+ # ({Phronomy.configuration.event_loop}). It is not used for direct
23
+ # synchronous +invoke+ calls so that the streaming callback state remains
24
+ # single-threaded.
21
25
  # @api private
22
26
  class ParallelToolChat < RubyLLM::Chat
27
+ # @param max_parallel_tools [Integer] maximum simultaneous tool executions
28
+ # @param cancellation_token [Phronomy::CancellationToken, nil] token observed before each batch
29
+ # @param opts [Hash] remaining kwargs forwarded to RubyLLM::Chat
30
+ # @api private
31
+ def initialize(max_parallel_tools: 10, cancellation_token: nil, **opts)
32
+ super(**opts)
33
+ @max_parallel_tools = max_parallel_tools
34
+ @cancellation_token = cancellation_token
35
+ end
36
+
37
+ # Allows the owning agent to update the token between retries.
38
+ # @api private
39
+ attr_writer :cancellation_token
40
+
23
41
  private
24
42
 
25
43
  # Overrides RubyLLM::Chat#handle_tool_calls to parallelise execution
@@ -29,7 +47,8 @@ module Phronomy
29
47
  # 1. Pre-execution callbacks (+on_new_message+, +on_tool_call+) —
30
48
  # sequential so that the Suspendable concern's approval hook can
31
49
  # raise +SuspendSignal+ before any tool is executed.
32
- # 2. Parallel tool execution — one IO thread per tool call.
50
+ # 2. Parallel tool execution — cooperative tools via Runtime.instance.spawn
51
+ # (respects the configured runtime backend), blocking_io tools via BlockingAdapterPool.
33
52
  # 3. Post-execution callbacks and message recording — sequential,
34
53
  # in the original tool-call order.
35
54
  #
@@ -52,29 +71,48 @@ module Phronomy
52
71
  end
53
72
 
54
73
  # Phase 2 — parallel tool execution.
55
- # Honour the per-agent concurrency cap (max_parallel_tools DSL).
56
- # Tool calls are processed in batches of at most `max` threads;
57
- # batches run sequentially so the total in-flight thread count never
58
- # exceeds the limit.
74
+ # :cooperative tools run inside a Task (no pool).
75
+ # :blocking_io/:cpu_bound/:external_process tools are submitted directly
76
+ # to BlockingAdapterPool when available eliminating the extra Task
77
+ # Thread that previously wrapped each pool operation.
59
78
  #
60
- # Check for cancellation before dispatching each batch so that
61
- # already-cancelled tokens do not start new LLM/tool-round-trips.
62
- ct = Thread.current[:phronomy_cancellation_token]
63
- max = Thread.current[:phronomy_max_parallel_tools] || 10
64
- thread_results = tool_calls.each_slice(max).flat_map do |batch|
79
+ # Both Phronomy::Task and BlockingAdapterPool::PendingOperation support
80
+ # #await, so results are collected uniformly below.
81
+ ct = @cancellation_token
82
+ max = @max_parallel_tools
83
+ tool_results = tool_calls.each_slice(max).flat_map do |batch|
65
84
  if ct&.cancelled?
66
85
  raise Phronomy::CancellationError, "invocation cancelled before tool execution"
67
86
  end
68
87
 
69
- threads = batch.map do |tool_call|
70
- Thread.new { {tool_call: tool_call, result: execute_tool(tool_call)} }
88
+ # Dispatch all tools in this batch via ToolExecutor (centralised routing).
89
+ dispatched = batch.map do |tc|
90
+ tool = tools[tc.name.to_sym]
91
+ unless tool
92
+ next {tool_call: tc, awaitable: nil, result: {
93
+ error: "Model tried to call unavailable tool `#{tc.name}`. " \
94
+ "Available tools: #{tools.keys.to_json}."
95
+ }}
96
+ end
97
+
98
+ awaitable = Phronomy::ToolExecutor.call_async(
99
+ tool: tool,
100
+ args: tc.arguments,
101
+ cancellation_token: ct
102
+ )
103
+ {tool_call: tc, awaitable: awaitable, result: nil}
104
+ end
105
+
106
+ # Await all dispatched operations in original order.
107
+ dispatched.map do |item|
108
+ result = item[:awaitable] ? item[:awaitable].await : item[:result]
109
+ {tool_call: item[:tool_call], result: result}
71
110
  end
72
- threads.map(&:value)
73
111
  end
74
112
 
75
113
  # Phase 3 — post-execution callbacks and message recording (sequential).
76
114
  halt_result = nil
77
- thread_results.each do |item|
115
+ tool_results.each do |item|
78
116
  result = item[:result]
79
117
  @on[:tool_result]&.call(result)
80
118
  tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
@@ -87,6 +125,25 @@ module Phronomy
87
125
  reset_tool_choice if forced_tool_choice?
88
126
  halt_result || complete(&block)
89
127
  end
128
+
129
+ # Overrides RubyLLM::Chat#execute_tool to forward the cancellation token
130
+ # explicitly and to route the call through {ToolExecutor} so that the
131
+ # execution_mode decision is made in a single place.
132
+ def execute_tool(tool_call)
133
+ tool = tools[tool_call.name.to_sym]
134
+ unless tool
135
+ return {
136
+ error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
137
+ "Available tools: #{tools.keys.to_json}."
138
+ }
139
+ end
140
+
141
+ Phronomy::ToolExecutor.call_async(
142
+ tool: tool,
143
+ args: tool_call.arguments,
144
+ cancellation_token: @cancellation_token
145
+ ).await
146
+ end
90
147
  end
91
148
  end
92
149
  end
@@ -13,6 +13,10 @@ module Phronomy
13
13
  caller_meta = {}
14
14
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
15
15
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
16
+ if (ic = config[:invocation_context])
17
+ caller_meta[:task_id] = ic.task_id if ic.task_id
18
+ caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
19
+ end
16
20
 
17
21
  trace("agent.invoke", input: input, **caller_meta) do |_span|
18
22
  # Run input guardrails before any LLM interaction.
@@ -68,6 +72,10 @@ module Phronomy
68
72
  caller_meta = {}
69
73
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
70
74
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
75
+ if (ic = config[:invocation_context])
76
+ caller_meta[:task_id] = ic.task_id if ic.task_id
77
+ caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
78
+ end
71
79
 
72
80
  trace("agent.invoke", input: input, **caller_meta) do |_span|
73
81
  run_input_guardrails!(input)
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # A thread-safe FIFO queue for passing values between concurrent tasks.
5
+ #
6
+ # Wraps +Thread::Queue+ so that callers do not need to reference the Ruby
7
+ # standard-library type directly. A future implementation may replace the
8
+ # backing primitive without changing call sites.
9
+ #
10
+ # @example Producer / consumer
11
+ # queue = Phronomy::AsyncQueue.new
12
+ # Runtime.instance.spawn { queue.push(expensive_io()) }
13
+ # value = queue.pop # blocks until the producer pushes
14
+ # @api private
15
+ class AsyncQueue
16
+ # @param max_size [Integer, nil] optional upper bound on queue depth.
17
+ # When set, {#push} blocks the caller until a slot is available.
18
+ # @api private
19
+ def initialize(max_size: nil)
20
+ @queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
21
+ @max_size = max_size
22
+ end
23
+
24
+ # Enqueues +item+.
25
+ # In a cooperative scheduler context with a bounded queue (max_size:), suspends
26
+ # the current Fiber via a scheduler signal when the queue is full rather than
27
+ # blocking the OS thread. Without a scheduler, falls back to the standard
28
+ # SizedQueue blocking behaviour.
29
+ # @param item [Object] value to enqueue
30
+ # @return [self]
31
+ # @api private
32
+ def push(item)
33
+ scheduler = Phronomy::Runtime::Scheduler.current
34
+ if scheduler && @max_size
35
+ _push_cooperative(scheduler, item)
36
+ else
37
+ @queue.push(item)
38
+ scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
39
+ end
40
+ self
41
+ end
42
+
43
+ # Dequeues and returns the next item.
44
+ # In a cooperative scheduler context, suspends the current Fiber (yielding
45
+ # control back to the scheduler) rather than blocking the OS thread.
46
+ #
47
+ # When +timeout+ is given the semantics depend on the active backend:
48
+ #
49
+ # * **Thread backend** (`:thread`) — uses real wall-clock time via
50
+ # +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
51
+ # Returns +nil+ if no item arrives within the specified number of real seconds.
52
+ # * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
53
+ # *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
54
+ # the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
55
+ # In tests this means the timeout is fully deterministic and does not depend on
56
+ # actual elapsed wall time. However, in production `:fiber` mode the timeout
57
+ # may never expire unless the scheduler explicitly advances virtual time.
58
+ #
59
+ # @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
60
+ # in production workloads is not guaranteed and may differ from wall-clock
61
+ # expectations.
62
+ # @note **Cooperative timeout limitation**: on the cooperative path, the
63
+ # deadline is re-checked *after* a wake-up signal arrives. If virtual time
64
+ # has already passed the deadline when the consumer is woken by a producer
65
+ # push, the consumer returns +nil+ rather than the pushed item. Without any
66
+ # wake-up signal the waiting Fiber remains suspended even after
67
+ # +scheduler.advance+ — the timeout does not self-fire.
68
+ # @param timeout [Numeric, nil] seconds to wait before returning +nil+.
69
+ # Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
70
+ # @return [Object, nil] the next item, or +nil+ when timeout expires
71
+ # @api private
72
+ def pop(timeout: nil)
73
+ scheduler = Phronomy::Runtime::Scheduler.current
74
+ if scheduler
75
+ _pop_cooperative(scheduler, timeout: timeout)
76
+ elsif timeout
77
+ @queue.pop(timeout: timeout)
78
+ else
79
+ @queue.pop
80
+ end
81
+ end
82
+
83
+ # Returns the current number of items in the queue.
84
+ # @return [Integer]
85
+ # @api private
86
+ def size
87
+ @queue.size
88
+ end
89
+
90
+ # Returns +true+ when the queue contains no items.
91
+ # @return [Boolean]
92
+ # @api private
93
+ def empty?
94
+ @queue.empty?
95
+ end
96
+
97
+ # Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
98
+ # @return [self]
99
+ # @api private
100
+ def close
101
+ @queue.close
102
+ self
103
+ end
104
+
105
+ private
106
+
107
+ # Cooperative pop for DeterministicScheduler context.
108
+ # Suspends the current Fiber via the scheduler's signal mechanism rather than
109
+ # blocking the OS thread. Because cooperative mode is single-threaded, the
110
+ # empty?/pop pair is race-free (no other Fiber can run between the two calls).
111
+ # After dequeuing, notifies any push-waiter so that a backpressure-suspended
112
+ # producer can be unblocked.
113
+ # @api private
114
+ # @param scheduler [Runtime::Scheduler]
115
+ # @param timeout [Numeric, nil]
116
+ # @return [Object, nil]
117
+ def _pop_cooperative(scheduler, timeout:)
118
+ @coop_signal ||= scheduler.new_signal
119
+ deadline = timeout ? (scheduler.virtual_time + timeout) : nil
120
+
121
+ loop do
122
+ unless @queue.empty?
123
+ item = @queue.pop(timeout: 0)
124
+ # Notify a push-waiter (bounded queue) that a slot opened up.
125
+ scheduler.raise_signal(@push_signal) if @push_signal
126
+ return item
127
+ end
128
+ return nil if deadline && scheduler.virtual_time >= deadline
129
+ scheduler.wait_for_signal(@coop_signal)
130
+ return nil if deadline && scheduler.virtual_time >= deadline
131
+ end
132
+ end
133
+
134
+ # Cooperative push for DeterministicScheduler context with a bounded queue.
135
+ # Suspends the current Fiber via a scheduler signal when the queue is full,
136
+ # rather than blocking the OS thread.
137
+ # @api private
138
+ # @param scheduler [Runtime::Scheduler]
139
+ # @param item [Object]
140
+ # @return [void]
141
+ def _push_cooperative(scheduler, item)
142
+ @push_signal ||= scheduler.new_signal
143
+
144
+ loop do
145
+ unless @queue.size >= @max_size
146
+ @queue.push(item)
147
+ # Notify any pop-waiter that an item is now available.
148
+ scheduler.raise_signal(@coop_signal) if @coop_signal
149
+ return
150
+ end
151
+ scheduler.wait_for_signal(@push_signal)
152
+ end
153
+ end
154
+ end
155
+ end