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
@@ -35,6 +35,7 @@ module Phronomy
35
35
  # @param messages [Array]
36
36
  # @param config [Hash]
37
37
  # @param params [Hash] initial params (model, temperature already set on chat)
38
+ # @api public
38
39
  def initialize(agent:, messages:, config:, params: {})
39
40
  @agent = agent
40
41
  @messages = messages.dup.freeze
@@ -47,6 +47,7 @@ module Phronomy
47
47
  # @param pending_tool_name [String]
48
48
  # @param pending_tool_args [Hash]
49
49
  # @param pending_tool_call_id [String]
50
+ # @api public
50
51
  def initialize(thread_id:, original_input:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:)
51
52
  @thread_id = thread_id
52
53
  @original_input = original_input
@@ -8,6 +8,7 @@ module Phronomy
8
8
  # Included in {Phronomy::Agent::Base}. Hooks are executed just before every
9
9
  # LLM call (global → class → instance order) and may inject or override
10
10
  # LLM parameters such as temperature or model.
11
+ # @api private
11
12
  module BeforeCompletion
12
13
  def self.included(base)
13
14
  base.extend(ClassMethods)
@@ -26,6 +27,7 @@ module Phronomy
26
27
  # class MyAgent < Phronomy::Agent::Base
27
28
  # before_completion ->(ctx) { { temperature: 0.2 } }
28
29
  # end
30
+ # @api private
29
31
  def before_completion(callable = nil)
30
32
  if callable.nil? && !block_given?
31
33
  @before_completion
@@ -35,6 +37,7 @@ module Phronomy
35
37
  end
36
38
 
37
39
  # @return [#call, nil]
40
+ # @api private
38
41
  def _before_completion
39
42
  @before_completion
40
43
  end
@@ -53,6 +56,7 @@ module Phronomy
53
56
  # @param chat [RubyLLM::Chat] the assembled chat object
54
57
  # @param config [Hash] the invocation config hash
55
58
  # @return [Hash] the merged params applied to the chat
59
+ # @api private
56
60
  def run_before_completion_hooks!(chat, config)
57
61
  hooks = [
58
62
  Phronomy.configuration.before_completion,
@@ -72,6 +76,7 @@ module Phronomy
72
76
  merged = {}
73
77
  hooks.each do |hook|
74
78
  result = hook.call(ctx)
79
+ check_cancellation!(config, "invocation cancelled during before_completion hook")
75
80
  merged.merge!(result) if result.is_a?(Hash)
76
81
  end
77
82
 
@@ -86,6 +91,7 @@ module Phronomy
86
91
  #
87
92
  # @param chat [RubyLLM::Chat]
88
93
  # @param params [Hash]
94
+ # @api private
89
95
  def apply_before_completion_params!(chat, params)
90
96
  params.each do |key, value|
91
97
  case key
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Concerns
6
+ # Translates RubyLLM transport errors into the corresponding Phronomy error
7
+ # classes so that callers can rescue Phronomy-namespaced exceptions rather
8
+ # than coupling themselves to the underlying provider library.
9
+ #
10
+ # Included in {Phronomy::Agent::Base}.
11
+ module ErrorTranslation
12
+ private
13
+
14
+ # Re-raises +error+ as the most specific Phronomy error class that
15
+ # corresponds to it. Non-RubyLLM errors are re-raised unchanged.
16
+ # The original exception is available as +#cause+ on the translated error.
17
+ #
18
+ # Must be called from within an active +rescue+ block so that Ruby
19
+ # automatically sets +#cause+ on the new exception.
20
+ #
21
+ # @param error [Exception]
22
+ # @raise [Phronomy::RateLimitError] for provider HTTP 429
23
+ # @raise [Phronomy::AuthenticationError] for provider HTTP 401 / 403
24
+ # @raise [Phronomy::ContextLengthError] for context window overflow
25
+ # @raise [Phronomy::TransportError] for all other +RubyLLM::Error+ subclasses
26
+ # @raise re-raises +error+ unchanged for non-RubyLLM exceptions
27
+ # @api private
28
+ def translate_and_reraise!(error)
29
+ case error
30
+ when RubyLLM::RateLimitError
31
+ raise Phronomy::RateLimitError, error.message
32
+ when RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError
33
+ raise Phronomy::AuthenticationError, error.message
34
+ when RubyLLM::ContextLengthExceededError
35
+ raise Phronomy::ContextLengthError, error.message
36
+ when RubyLLM::Error
37
+ raise Phronomy::TransportError, error.message
38
+ else
39
+ raise # bare re-raise preserves $! and its backtrace unchanged
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -8,10 +8,12 @@ module Phronomy
8
8
  # Included in {Phronomy::Agent::Base}. Guardrails are run on the raw
9
9
  # input string before the LLM is called, and on the raw output string
10
10
  # before the result is returned to the caller.
11
+ # @api private
11
12
  module Guardrailable
12
13
  # Attach a guardrail that validates input before every #invoke call.
13
14
  # @param guardrail [Phronomy::Guardrail::InputGuardrail]
14
15
  # @return [self]
16
+ # @api private
15
17
  def add_input_guardrail(guardrail)
16
18
  @input_guardrails ||= []
17
19
  @input_guardrails << guardrail
@@ -21,6 +23,7 @@ module Phronomy
21
23
  # Attach a guardrail that validates output before it is returned.
22
24
  # @param guardrail [Phronomy::Guardrail::OutputGuardrail]
23
25
  # @return [self]
26
+ # @api private
24
27
  def add_output_guardrail(guardrail)
25
28
  @output_guardrails ||= []
26
29
  @output_guardrails << guardrail
@@ -7,6 +7,7 @@ module Phronomy
7
7
  #
8
8
  # Included in {Phronomy::Agent::Base}. The retry loop wraps the full
9
9
  # #invoke_once call; {Phronomy::GuardrailError} is never retried.
10
+ # @api private
10
11
  module Retryable
11
12
  def self.included(base)
12
13
  base.extend(ClassMethods)
@@ -25,6 +26,7 @@ module Phronomy
25
26
  # class MyAgent < Phronomy::Agent::Base
26
27
  # retry_policy times: 2, wait: :exponential, base: 1.0
27
28
  # end
29
+ # @api private
28
30
  def retry_policy(times: 0, wait: 0, base: 1.0)
29
31
  @_retry_policy = {times: times, wait: wait, base: base}
30
32
  end
@@ -35,6 +37,7 @@ module Phronomy
35
37
 
36
38
  # Injectable sleep callable for testing (shared with Tool::Base pattern).
37
39
  # @return [#call]
40
+ # @api private
38
41
  def _sleep_proc
39
42
  @_sleep_proc || method(:sleep)
40
43
  end
@@ -48,12 +51,19 @@ module Phronomy
48
51
 
49
52
  # Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
50
53
  def _invoke_impl(input, messages: [], thread_id: nil, config: {})
54
+ # Fail fast when the token is already cancelled before any LLM call.
55
+ if (token = config[:cancellation_token]) && token.cancelled?
56
+ raise Phronomy::CancellationError, "invocation cancelled"
57
+ end
58
+
51
59
  policy = self.class._retry_policy
52
60
  attempt = 0
53
61
  begin
54
62
  invoke_once(input, messages: messages, thread_id: thread_id, config: config)
55
63
  rescue Phronomy::GuardrailError
56
64
  raise
65
+ rescue Phronomy::CancellationError
66
+ raise # Never retry after cancellation.
57
67
  rescue
58
68
  if policy && attempt < policy[:times]
59
69
  wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
@@ -61,7 +71,7 @@ module Phronomy
61
71
  attempt += 1
62
72
  retry
63
73
  end
64
- raise
74
+ translate_and_reraise!($!)
65
75
  end
66
76
  end
67
77
 
@@ -70,6 +80,7 @@ module Phronomy
70
80
  # @param base [Float]
71
81
  # @param attempt [Integer]
72
82
  # @return [Float]
83
+ # @api private
73
84
  def compute_agent_retry_wait(strategy, base, attempt)
74
85
  case strategy
75
86
  when :exponential
@@ -8,6 +8,7 @@ module Phronomy
8
8
  # Included in {Phronomy::Agent::Base}. When a tool decorated with
9
9
  # +requires_approval true+ is called and no synchronous approval handler
10
10
  # has been registered, the invocation is suspended and a
11
+ # @api private
11
12
  # {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
12
13
  module Suspendable
13
14
  # Registers a callback that is invoked before executing any tool that has
@@ -25,11 +26,27 @@ module Phronomy
25
26
  # agent = MyAgent.new
26
27
  # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
27
28
  # @return [self]
29
+ # @api private
28
30
  def on_approval_required(&block)
29
31
  @approval_handler = block
30
32
  self
31
33
  end
32
34
 
35
+ # Registers a scope policy callable for this agent instance.
36
+ #
37
+ # The callable receives +(tool_class, scope, agent)+ and must return
38
+ # +:allow+, +:reject+, or +:approve+.
39
+ #
40
+ # @example Reject all write-scoped tools
41
+ # agent.scope_policy = ->(_tc, scope, _agent) { scope == :write ? :reject : :allow }
42
+ #
43
+ # @param policy [#call]
44
+ # @return [void]
45
+ # @api public
46
+ def scope_policy=(policy)
47
+ @scope_policy = policy
48
+ end
49
+
33
50
  # Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
34
51
  #
35
52
  # This method reconstructs the conversation state captured at suspension
@@ -43,6 +60,7 @@ module Phronomy
43
60
  # @param config [Hash] same runtime options as #invoke
44
61
  # @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
45
62
  # @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
63
+ # @api private
46
64
  def resume(checkpoint, approved:, config: {})
47
65
  # Build a fresh chat with all tools registered.
48
66
  chat = build_chat
@@ -95,6 +113,7 @@ module Phronomy
95
113
  # - none of the agent's tools have requires_approval set.
96
114
  #
97
115
  # @param chat [RubyLLM::Chat]
116
+ # @api private
98
117
  def _register_suspension_hook!(chat)
99
118
  return if @approval_handler
100
119
  return if self.class.tools.none? { |tc| tc.requires_approval }
@@ -15,14 +15,14 @@ module Phronomy
15
15
  # == Execution model
16
16
  #
17
17
  # {#start} is called by the EventLoop on the +:start+ event. It immediately
18
- # returns after spawning a background IO thread that runs the agent's full
18
+ # returns after spawning a {Phronomy::Task} that runs the agent's full
19
19
  # invocation pipeline (via +_invoke_impl+). The EventLoop thread is never
20
20
  # blocked by agent execution.
21
21
  #
22
- # Inside the IO thread, the +:phronomy_agent_parallel_tools+ thread-local
23
- # flag is set to +true+ so that {Agent::Base#build_chat} returns a
24
- # {ParallelToolChat} instance, enabling concurrent tool dispatch when the LLM
25
- # returns multiple tool calls in one response.
22
+ # Inside the task, {Agent::Base#build_chat} returns a
23
+ # {ParallelToolChat} instance when EventLoop mode is enabled, allowing
24
+ # concurrent tool dispatch when the LLM returns multiple tool calls in one
25
+ # response.
26
26
  #
27
27
  # == Completion events
28
28
  #
@@ -57,6 +57,7 @@ module Phronomy
57
57
  # {Agent::Base#run_as_child} creates an +AgentFSM+ with +parent_id+ set to
58
58
  # +ctx.thread_id+, registers it with the EventLoop, and returns immediately.
59
59
  # The parent {FSMSession} waits for the +:child_completed+ event.
60
+ # @api private
60
61
  class FSM
61
62
  # @return [String] unique identifier used as the EventLoop target_id
62
63
  attr_reader :id
@@ -71,39 +72,30 @@ module Phronomy
71
72
  # auto-generated when nil
72
73
  # @param config [Hash] invocation config forwarded to
73
74
  # +_invoke_impl+
74
- # @param parent_id [String, nil] EventLoop id of the parent
75
- # FSMSession; when set, a
76
- # +:child_completed+ event is posted
77
- # on completion
78
- # @param result_writer [Proc, nil] optional callable invoked with the
79
- # result hash <b>before</b>
80
- # +:child_completed+ is posted.
81
- # Use this to write the agent output
82
- # back into the parent WorkflowContext.
83
- # Thread::Queue provides the
84
- # happens-before guarantee.
75
+ # @param parent_id [String, nil] EventLoop id of the parent FSMSession;
76
+ # when set, a +:child_completed+ event
77
+ # is posted on completion. The result
78
+ # is delivered exclusively as the event
79
+ # payload no cross-thread writes to the
80
+ # parent WorkflowContext are performed.
85
81
  #
86
- # @example Writing result into context
87
- # entry :run_agent, ->(ctx) {
88
- # MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
89
- # }
90
- def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil, result_writer: nil)
82
+ # @api private
83
+ def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil)
91
84
  @agent = agent
92
85
  @input = input
93
86
  @messages = Array(messages).dup
94
87
  @thread_id = thread_id || SecureRandom.uuid
95
88
  @config = config
96
89
  @parent_id = parent_id
97
- @result_writer = result_writer
98
90
  @id = @thread_id
99
91
  @current_phase = :idle
100
92
  end
101
93
 
102
94
  # Called by {EventLoop} on the +:start+ event.
103
- # Transitions to +:running+ and spawns the agent IO thread.
95
+ # Transitions to +:running+ and spawns the agent task.
104
96
  def start
105
97
  @current_phase = :running
106
- spawn_agent_thread
98
+ spawn_agent_task
107
99
  end
108
100
 
109
101
  # Called by {EventLoop} for external events dispatched to this id.
@@ -115,10 +107,10 @@ module Phronomy
115
107
 
116
108
  private
117
109
 
118
- # Spawns the background IO thread that runs the agent invocation.
119
- # Captures all instance variables by value so the thread closure is
110
+ # Spawns a {Phronomy::Task} that runs the agent invocation pipeline.
111
+ # Captures all instance variables by value so the task closure is
120
112
  # safe even if the FSM object is modified (though it is not in practice).
121
- def spawn_agent_thread
113
+ def spawn_agent_task
122
114
  agent = @agent
123
115
  input = @input
124
116
  messages = @messages
@@ -126,38 +118,38 @@ module Phronomy
126
118
  config = @config
127
119
  fsm_id = @id
128
120
  parent_id = @parent_id
129
- result_writer = @result_writer
130
121
 
131
- Thread.new do
132
- # Enable parallel tool dispatch inside this IO thread.
133
- Thread.current[:phronomy_agent_parallel_tools] = true
134
-
135
- begin
136
- result = agent.send(:_invoke_impl,
137
- input,
138
- messages: messages,
139
- thread_id: thread_id,
140
- config: config)
141
-
142
- if parent_id
143
- # Let the caller write the result into the context BEFORE the
144
- # parent FSMSession advances. Thread::Queue provides the
145
- # happens-before guarantee — no Mutex needed.
146
- result_writer&.call(result)
147
-
148
- Phronomy::EventLoop.instance.post(
149
- Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
150
- )
151
- end
122
+ Phronomy::Runtime.instance.spawn(name: "agent-fsm:#{fsm_id}") do
123
+ result = agent.send(:_invoke_impl,
124
+ input,
125
+ messages: messages,
126
+ thread_id: thread_id,
127
+ config: config)
152
128
 
129
+ if parent_id
130
+ # Result is delivered exclusively as the :child_completed payload.
131
+ # The parent Workflow task is the sole owner of WorkflowContext
132
+ # and applies the result after receiving the event.
153
133
  Phronomy::EventLoop.instance.post(
154
- Phronomy::Event.new(type: :finished, target_id: fsm_id, payload: result)
134
+ Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
155
135
  )
156
- rescue => e
136
+ end
137
+
138
+ Phronomy::EventLoop.instance.post(
139
+ Phronomy::Event.new(type: :finished, target_id: fsm_id, payload: result)
140
+ )
141
+ rescue => e
142
+ if parent_id
157
143
  Phronomy::EventLoop.instance.post(
158
- Phronomy::Event.new(type: :error, target_id: fsm_id, payload: e)
144
+ Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
159
145
  )
160
146
  end
147
+
148
+ Phronomy::EventLoop.instance.post(
149
+ Phronomy::Event.new(type: :error, target_id: fsm_id, payload: e)
150
+ )
151
+
152
+ # Context caches are instance variables; no thread-local cleanup needed.
161
153
  end
162
154
  end
163
155
  end
@@ -22,6 +22,7 @@ module Phronomy
22
22
 
23
23
  # @param target_agent [Phronomy::Agent::Base] the agent to hand off to
24
24
  # @param description [String, nil] overrides the auto-generated tool description
25
+ # @api public
25
26
  def initialize(target_agent:, description: nil)
26
27
  @target_agent = target_agent
27
28
  klass_name = target_agent.class.name&.split("::")&.last || "Agent"
@@ -33,6 +34,7 @@ module Phronomy
33
34
 
34
35
  # Builds an anonymous Phronomy::Tool::Base subclass for this handoff.
35
36
  # @return [Class<Phronomy::Tool::Base>]
37
+ # @api public
36
38
  def to_tool_class
37
39
  sentinel_value = sentinel
38
40
  tn = tool_name
@@ -46,6 +48,7 @@ module Phronomy
46
48
 
47
49
  # The sentinel string embedded in the tool result.
48
50
  # @return [String]
51
+ # @api public
49
52
  def sentinel
50
53
  "#{SENTINEL_PREFIX}:#{target_agent.class.name}:#{@uuid}"
51
54
  end