phronomy 0.13.0 → 0.15.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -4,7 +4,11 @@ module Phronomy
4
4
  module LLMAdapter
5
5
  # Abstract base class for LLM adapters.
6
6
  #
7
- # Subclasses must implement {#complete} and {#stream}.
7
+ # Subclasses must implement {#complete} and {#stream}. The adapter or the
8
+ # underlying provider client owns transport timeout, retry, backoff, and
9
+ # rate-limit behavior. Phronomy only supplies cooperative cancellation and
10
+ # isolates blocking calls in {BlockingAdapterPool}.
11
+ #
8
12
  # The agent pipeline calls {#complete_async} / {#stream_async} which wrap
9
13
  # those methods in a {BlockingAdapterPool} submission.
10
14
  class Base
@@ -14,7 +18,7 @@ module Phronomy
14
18
  #
15
19
  # @param chat [Object] the configured chat session object
16
20
  # @param message [String] the user message
17
- # @param config [Hash] the invocation config (e.g. +:cancellation_token+)
21
+ # @param config [Hash] invocation config (e.g. +:cancellation_token+)
18
22
  # @return [Object] LLM response object
19
23
  # @raise [NotImplementedError]
20
24
  # @api private
@@ -28,7 +32,7 @@ module Phronomy
28
32
  #
29
33
  # @param chat [Object] the configured chat session object
30
34
  # @param message [String] the user message
31
- # @param config [Hash] the invocation config
35
+ # @param config [Hash] invocation config
32
36
  # @yield [chunk] streaming chunk from the LLM
33
37
  # @return [Object] LLM response object
34
38
  # @raise [NotImplementedError]
@@ -40,16 +44,18 @@ module Phronomy
40
44
  # Submits a non-streaming LLM call to {BlockingAdapterPool} and returns
41
45
  # a {BlockingAdapterPool::PendingOperation}.
42
46
  #
47
+ # Transport timeout and retry remain the responsibility of the adapter or
48
+ # provider client; Phronomy does not attach an additional operation timeout.
49
+ #
43
50
  # @param chat [Object] configured chat session
44
51
  # @param message [String] user message
45
- # @param config [Hash] invocation config
52
+ # @param config [Hash] invocation config
46
53
  # @param pool [BlockingAdapterPool] pool to submit to
47
54
  # @return [BlockingAdapterPool::PendingOperation]
48
55
  # @api private
49
56
  def complete_async(chat, message, config: {}, pool: default_pool)
50
57
  token = config[:cancellation_token]
51
- timeout = config[:llm_timeout]
52
- pool.submit(timeout: timeout, cancellation_token: token) do
58
+ pool.submit(cancellation_token: token) do
53
59
  complete(chat, message, config: config)
54
60
  end
55
61
  end
@@ -57,39 +63,24 @@ module Phronomy
57
63
  # Submits a streaming LLM call to {BlockingAdapterPool} and returns
58
64
  # a {BlockingAdapterPool::PendingOperation}.
59
65
  #
60
- # When +enqueue_to:+ is given, streaming chunks are pushed into that
61
- # {AsyncQueue} from the worker thread instead of being passed directly
62
- # to the caller's block. The queue is closed (via +ensure+) after the
63
- # LLM call finishes so the consumer's drain loop terminates naturally.
64
- # This keeps user-supplied blocks off the blocking-pool worker thread.
66
+ # The block is invoked on the blocking-pool worker thread. Agent code must
67
+ # pass only a lightweight internal sink that posts a value to EventLoop;
68
+ # Application callbacks must never be passed directly to this method.
65
69
  #
66
- # When +enqueue_to:+ is nil and a block is given, the block is invoked
67
- # directly from the worker thread (legacy behaviour, preserved for
68
- # backward compatibility).
70
+ # Transport timeout and retry remain the responsibility of the adapter or
71
+ # provider client; Phronomy does not attach an additional operation timeout.
69
72
  #
70
- # @param chat [Object] configured chat session
71
- # @param message [String] user message
72
- # @param config [Hash] invocation config
73
- # @param pool [BlockingAdapterPool] pool to submit to
74
- # @param enqueue_to [AsyncQueue, nil] when set, push chunks here instead of
75
- # calling the block on the worker thread
76
- # @yield [chunk] streaming chunk — only used when +enqueue_to:+ is nil
73
+ # @yield [chunk] streaming chunk on the worker thread
77
74
  # @return [BlockingAdapterPool::PendingOperation]
78
75
  # @api private
79
- def stream_async(chat, message, config: {}, pool: default_pool, enqueue_to: nil, &block)
76
+ def stream_async(chat, message, config: {}, pool: default_pool, &block)
77
+ raise ArgumentError, "stream_async requires a block" unless block
78
+
80
79
  token = config[:cancellation_token]
81
- timeout = config[:llm_timeout]
82
- if enqueue_to
83
- pool.submit(timeout: timeout, cancellation_token: token) do
84
- stream(chat, message, config: config) do |chunk|
85
- enqueue_to.push(chunk)
86
- end
87
- ensure
88
- enqueue_to.close
89
- end
90
- else
91
- pool.submit(timeout: timeout, cancellation_token: token) do
92
- stream(chat, message, config: config, &block)
80
+ pool.submit(cancellation_token: token) do
81
+ stream(chat, message, config: config) do |chunk|
82
+ token&.raise_if_cancelled!("invocation cancelled during streaming")
83
+ block.call(chunk)
93
84
  end
94
85
  end
95
86
  end
@@ -20,15 +20,18 @@ module Phronomy
20
20
  # @return [Hash{Symbol => Numeric}]
21
21
  # @api public
22
22
  def self.snapshot
23
- pool = Runtime.instance.blocking_io
24
- el = EventLoop.instance
25
- task_snap = Runtime.instance.task_snapshot
23
+ runtime = Runtime.instance
24
+ pool = runtime.blocking_io
25
+ el = runtime.event_loop
26
+ task_snap = runtime.task_snapshot
26
27
 
27
28
  {
28
29
  blocking_pool_active: pool.active_count,
29
30
  blocking_pool_queue_length: pool.queue_depth,
30
31
  blocking_pool_abandoned_total: pool.abandoned_count,
31
32
  blocking_pool_size: pool.pool_size,
33
+ event_loop_queue_depth: el.queue_depth,
34
+ event_loop_queue_max_depth: el.max_queue_depth,
32
35
  event_loop_lag_last_ms: (el.last_lag_seconds * 1000).round(3),
33
36
  event_loop_lag_max_ms: (el.max_lag_seconds * 1000).round(3),
34
37
  event_loop_lag_average_ms: (el.average_lag_seconds * 1000).round(3)
@@ -2,122 +2,90 @@
2
2
 
3
3
  module Phronomy
4
4
  module MultiAgent
5
- # RubyLLM::Chat subclass that executes multiple tool calls concurrently.
5
+ # RubyLLM::Chat subclass supporting concurrent direct Tool execution.
6
6
  #
7
- # When the LLM returns more than one tool call in a single response, each
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.).
16
- #
17
- # Single-tool responses fall through to the standard sequential path via
18
- # +super+, preserving all existing edge-case behaviour (Tool::Halt,
19
- # forced_tool_choice, streaming, SuspendSignal, etc.).
20
- #
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.
7
+ # AgentInvocation installs +on_tool_call_batch+ and intercepts the whole batch
8
+ # before this class dispatches anything. The direct-chat fallback dispatches
9
+ # the complete batch concurrently; process protection remains the responsibility
10
+ # of Runtime's bounded executors and queues.
25
11
  # @api private
26
12
  class ParallelToolChat < RubyLLM::Chat
27
- # @param max_parallel_tools [Integer] maximum simultaneous tool executions
28
- # @param cancellation_token [Phronomy::Concurrency::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)
13
+ def initialize(cancellation_token: nil, **opts)
32
14
  super(**opts)
33
- @max_parallel_tools = max_parallel_tools
34
15
  @cancellation_token = cancellation_token
35
16
  end
36
17
 
37
- # Allows the owning agent to update the token between retries.
38
- # @api private
39
18
  attr_writer :cancellation_token
40
19
 
20
+ # Registers an Agent-owned batch interceptor. The callback must return
21
+ # quickly or raise ToolCallIntercepted; it must not execute Tool bodies.
22
+ def on_tool_call_batch(&block)
23
+ @on[:tool_call_batch] = block
24
+ self
25
+ end
26
+
41
27
  private
42
28
 
43
- # Overrides RubyLLM::Chat#handle_tool_calls to parallelise execution
44
- # when multiple tool calls are present in a single LLM response.
45
- #
46
- # The method preserves the three-phase protocol of the original:
47
- # 1. Pre-execution callbacks (+on_new_message+, +on_tool_call+) —
48
- # sequential so that the Suspendable concern's approval hook can
49
- # raise +SuspendSignal+ before any tool is executed.
50
- # 2. Parallel tool execution — cooperative tools via Runtime.instance.spawn
51
- # (respects the configured runtime backend), blocking_io tools via BlockingAdapterPool.
52
- # 3. Post-execution callbacks and message recording — sequential,
53
- # in the original tool-call order.
54
- #
55
- # @param response [RubyLLM::Message] the LLM response containing tool calls
56
- # @yield streaming block forwarded to +complete+
57
- # @api private
58
29
  def handle_tool_calls(response, &block)
59
30
  tool_calls = response.tool_calls.values
60
-
61
- # Single tool: delegate to the parent implementation to preserve every
62
- # edge case (forced_tool_choice, streaming, Halt, SuspendSignal…).
63
31
  return super if tool_calls.size <= 1
64
32
 
65
- # Phase 1 — pre-execution callbacks (sequential, original order).
66
- # The SuspendSignal approval hook is registered via on_tool_call, so it
67
- # MUST fire before execution begins.
33
+ if @on[:tool_call_batch]
34
+ tool_calls.each { @on[:new_message]&.call }
35
+ @on[:tool_call_batch].call(tool_calls)
36
+ return
37
+ end
38
+
39
+ # Direct ParallelToolChat fallback. Agent execution never reaches this
40
+ # branch because AgentInvocation installs the batch interceptor first.
68
41
  tool_calls.each do |tool_call|
69
42
  @on[:new_message]&.call
70
43
  @on[:tool_call]&.call(tool_call)
71
44
  end
72
45
 
73
- # Phase 2 — parallel tool execution.
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.
78
- #
79
- # Both Phronomy::Task and BlockingAdapterPool::PendingOperation support
80
- # #wait_result, 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|
84
- if ct&.cancelled?
85
- raise Phronomy::CancellationError, "invocation cancelled before tool execution"
86
- end
87
-
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
46
+ cancellation_token = @cancellation_token
47
+ if cancellation_token&.cancelled?
48
+ raise Phronomy::CancellationError,
49
+ "invocation cancelled before tool execution"
50
+ end
97
51
 
98
- awaitable = Phronomy::Agent::ToolExecutor.call_async(
99
- tool: tool,
100
- args: tc.arguments,
101
- cancellation_token: ct
102
- )
103
- {tool_call: tc, awaitable: awaitable, result: nil}
52
+ dispatched = tool_calls.map do |tool_call|
53
+ tool = tools[tool_call.name.to_sym]
54
+ unless tool
55
+ next {
56
+ tool_call: tool_call,
57
+ awaitable: nil,
58
+ result: {
59
+ error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
60
+ "Available tools: #{tools.keys.to_json}."
61
+ }
62
+ }
104
63
  end
105
64
 
106
- # Await all dispatched operations in original order.
107
- dispatched.map do |item|
108
- result = item[:awaitable] ? item[:awaitable].wait_result : item[:result]
109
- {tool_call: item[:tool_call], result: result}
110
- end
65
+ awaitable = Phronomy::Agent::ToolExecutor.call_async(
66
+ tool: tool,
67
+ args: tool_call.arguments,
68
+ cancellation_token: cancellation_token
69
+ )
70
+ {tool_call: tool_call, awaitable: awaitable, result: nil}
71
+ end
72
+
73
+ tool_results = dispatched.map do |item|
74
+ result = item[:awaitable] ? item[:awaitable].wait_result : item[:result]
75
+ {tool_call: item[:tool_call], result: result}
111
76
  end
112
77
 
113
- # Phase 3 — post-execution callbacks and message recording (sequential).
114
78
  halt_result = nil
115
79
  tool_results.each do |item|
116
80
  result = item[:result]
117
81
  @on[:tool_result]&.call(result)
118
82
  tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
119
83
  content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
120
- message = add_message(role: :tool, content: content, tool_call_id: item[:tool_call].id)
84
+ message = add_message(
85
+ role: :tool,
86
+ content: content,
87
+ tool_call_id: item[:tool_call].id
88
+ )
121
89
  @on[:end_message]&.call(message)
122
90
  halt_result = result if result.is_a?(RubyLLM::Tool::Halt)
123
91
  end
@@ -126,15 +94,12 @@ module Phronomy
126
94
  halt_result || complete(&block)
127
95
  end
128
96
 
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
97
  def execute_tool(tool_call)
133
98
  tool = tools[tool_call.name.to_sym]
134
99
  unless tool
135
100
  return {
136
101
  error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
137
- "Available tools: #{tools.keys.to_json}."
102
+ "Available tools: #{tools.keys.to_json}."
138
103
  }
139
104
  end
140
105
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Raised when an Application stream callback fails and
5
+ # +stream_callback_error_policy+ is +:fail_task+.
6
+ #
7
+ # This error represents delivery failure, not Agent, LLM, Tool, or FSM
8
+ # execution failure. The Agent result is retained in {#result}, and the
9
+ # original Application exception is available through both
10
+ # {#original_error} and Ruby's +#cause+ chain.
11
+ #
12
+ # @api public
13
+ class StreamCallbackError < Error
14
+ # @return [Symbol] stream event type handled by the failing callback
15
+ attr_reader :event_type
16
+
17
+ # @return [Hash, nil] successful or suspended Agent result
18
+ attr_reader :result
19
+
20
+ # @return [Exception] original Application callback exception
21
+ attr_reader :original_error
22
+
23
+ def initialize(event_type:, original_error:, result: nil)
24
+ @event_type = event_type
25
+ @original_error = original_error
26
+ @result = result
27
+
28
+ super(
29
+ "Stream callback failed while handling #{event_type.inspect}: " \
30
+ "#{original_error.class}: #{original_error.message}"
31
+ )
32
+ set_backtrace(original_error.backtrace)
33
+ end
34
+ end
35
+ end
@@ -46,12 +46,21 @@ module Phronomy
46
46
  scheduler = Phronomy::Runtime::FakeScheduler.new
47
47
  scheduler.clock = clock if clock
48
48
  runtime = Phronomy::Runtime.new(scheduler: scheduler)
49
- original = Phronomy::Runtime.instance
50
- Phronomy::Runtime.instance = runtime
49
+ original = Phronomy::Runtime.default_if_initialized_for_test
50
+ Phronomy::Runtime.replace_default_for_test(runtime)
51
51
  begin
52
52
  yield scheduler, clock
53
53
  ensure
54
- Phronomy::Runtime.instance = original
54
+ result = nil
55
+ begin
56
+ result = runtime.shutdown
57
+ ensure
58
+ Phronomy::Runtime.restore_default_for_test(original)
59
+ end
60
+ unless result&.cleanup_complete?
61
+ raise Phronomy::RuntimeShutdownError,
62
+ "Temporary test Runtime did not shut down completely"
63
+ end
55
64
  end
56
65
  end
57
66
  end