phronomy 0.16.0 → 0.18.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -3,335 +3,331 @@
3
3
  module Phronomy
4
4
  module MultiAgent
5
5
  # Base class for orchestrator agents that coordinate multiple subagents.
6
- # Implements the Orchestrator-Subagent multi-agent coordination pattern
7
- # (Anthropic blog, Pattern 2).
8
- #
9
- # @see https://claude.com/blog/multi-agent-coordination-patterns
10
- #
11
- # Extends {Phronomy::Agent::Base} with:
12
- # - A +subagent+ class-level DSL for declarative subagent registration. Each
13
- # declared subagent is automatically exposed as an LLM-callable tool.
14
- # - +dispatch_parallel+ for programmatic parallel invocation of heterogeneous
15
- # agents.
16
- # - +fan_out+ for parallel invocation of the same agent across multiple inputs.
17
- #
18
- # @example Declarative DSL
19
- # class ResearchOrchestrator < Phronomy::MultiAgent::Orchestrator
20
- # model "gpt-4o"
21
- # instructions "You coordinate research tasks."
22
- # subagent :searcher, SearchAgent
23
- # subagent :summarizer, SummaryAgent
24
- # end
25
- #
26
- # result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
27
- #
28
- # @example Programmatic parallel dispatch
29
- # class MyOrchestrator < Phronomy::MultiAgent::Orchestrator
30
- # model "gpt-4o"
31
- # instructions "Dispatch tasks in parallel."
32
- #
33
- # def run(input)
34
- # results = dispatch_parallel(
35
- # { agent: SearchAgent, input: "topic A" },
36
- # { agent: AnalysisAgent, input: input }
37
- # )
38
- # results.map { |r| r[:output] }.join("\n")
39
- # end
40
- # end
41
- #
42
- # @example Fan-out (same agent, multiple inputs)
43
- # results = fan_out(agent: TranslationAgent, inputs: ["Hello", "World"])
44
6
  class Orchestrator < Agent::Base
45
7
  agent_definition id: "orchestrator", version: 1
46
- # Declares a named subagent and registers it as a tool accessible to the
47
- # LLM during an +invoke+ call.
48
- #
49
- # Each call appends a new tool to this class's tool list. The generated
50
- # tool's function name is +dispatch_to_<name>+. When the LLM calls the
51
- # tool, a fresh instance of +agent_class+ is created and +invoke+ is called
52
- # with the provided input string.
53
- #
54
- # @param name [Symbol] logical name that identifies the subagent
55
- # @param agent_class [Class] subclass of {Phronomy::Agent::Base}
56
- # @param on_error [Symbol] +:raise+ (default) re-raises any exception
57
- # from the subagent; +:skip+ returns +nil+ so the LLM can decide how to
58
- # proceed
59
- # @api public
60
- def self.subagent(name, agent_class, on_error: :raise)
61
- tool_class = Class.new(Phronomy::Agent::Context::Capability::Base) do
8
+
9
+ def self.subagent(name, agent_class, on_error: :raise, inherit_knowledge: true)
10
+ # A subagent Tool is logically asynchronous: ToolInvocation starts the
11
+ # child Agent and resumes when its completion Task settles. It must not
12
+ # occupy an OffloadPool worker while waiting for the child.
13
+ tool_class = Class.new(Phronomy::Tools::Agent) do
62
14
  tool_name "dispatch_to_#{name}"
63
15
  description "Dispatch work to the #{name} subagent (#{agent_class.name})"
64
- param :input, type: :string, desc: "The task or question for the subagent"
65
16
 
66
- # @_orchestrator_context is injected at call time by prepare_tool_class.
67
17
  attr_writer :_orchestrator_context
68
18
 
69
- define_method(:execute) do |input:|
70
- # Inherit the calling orchestrator's thread_id, config, and
71
- # InvocationContext so that child subagent spans and memory stay
72
- # connected to the parent invocation.
19
+ define_method(:execute) do |input:, cancellation_token: nil|
20
+ execute_async(
21
+ input: input,
22
+ cancellation_token: cancellation_token,
23
+ config: {}
24
+ ).wait_result
25
+ end
26
+
27
+ define_method(:execute_async) do |input:, cancellation_token: nil, config: {}|
73
28
  ctx = @_orchestrator_context || {}
74
29
  parent_ic = ctx[:invocation_context]
75
- task_config = ctx[:config] || {}
30
+ task_config = (ctx[:config] || {}).merge(config || {})
31
+
32
+ if cancellation_token && !task_config[:cancellation_token]
33
+ task_config = task_config.merge(cancellation_token: cancellation_token)
34
+ end
76
35
 
77
- # Propagate parent InvocationContext to the child agent so that
78
- # cancellation, deadline, and tracing carry through automatically.
79
36
  if parent_ic && !task_config[:invocation_context]
80
37
  child_ic = parent_ic.merge(parent_task_id: parent_ic.task_id)
81
38
  task_config = task_config.merge(invocation_context: child_ic)
82
39
  end
83
40
 
84
- result = agent_class.new.invoke_async(
41
+ agent = agent_class.new
42
+ if inherit_knowledge
43
+ Array(ctx[:knowledge]).each do |entry|
44
+ agent.add_knowledge(
45
+ entry.fetch(:content),
46
+ metadata: entry.fetch(:metadata, {})
47
+ )
48
+ end
49
+ end
50
+
51
+ source = agent.invoke_async(
85
52
  input,
86
53
  thread_id: ctx[:thread_id] || parent_ic&.thread_id,
87
54
  config: task_config
88
- ).wait_result
89
- result[:output]
90
- rescue
91
- raise if on_error == :raise
92
- nil
55
+ )
56
+ result_task = Phronomy::Task.deferred(
57
+ name: "subagent-tool-#{name}"
58
+ )
59
+ source.on_complete do |result, error|
60
+ if error
61
+ (on_error == :raise) ? result_task.fail(error) : result_task.complete(nil)
62
+ else
63
+ result_task.complete(result[:output])
64
+ end
65
+ end
66
+ result_task
67
+ rescue => error
68
+ result_task ||= Phronomy::Task.deferred(
69
+ name: "subagent-tool-#{name}"
70
+ )
71
+ (on_error == :raise) ? result_task.fail(error) : result_task.complete(nil)
72
+ result_task
93
73
  end
74
+ private :execute_async
94
75
  end
95
76
 
96
- # Track this tool class so prepare_tool_class can inject context.
97
77
  @_subagent_tool_classes = (@_subagent_tool_classes || []) + [tool_class]
98
-
99
- # Append without clobbering previously registered tools or aliases.
100
78
  @tools = (@tools || []) + [tool_class]
101
79
  @tool_aliases ||= {}
102
-
103
- registered_subagents[name] = {agent_class: agent_class, on_error: on_error}
80
+ registered_subagents[name] = {
81
+ agent_class: agent_class,
82
+ on_error: on_error,
83
+ inherit_knowledge: inherit_knowledge
84
+ }
104
85
  end
105
86
 
106
- # Returns the subagent tool classes registered on this specific class.
107
- # Used by {#prepare_tool_class} to inject context.
108
- # @return [Array<Class>]
109
- # @api private
110
87
  def self._subagent_tool_classes
111
88
  @_subagent_tool_classes || []
112
89
  end
113
90
 
114
- # Returns the subagent registry for this specific class (not inherited).
115
- #
116
- # @return [Hash{Symbol => Hash}]
117
- # @api public
118
91
  def self.registered_subagents
119
92
  @registered_subagents ||= {}
120
93
  end
121
94
 
122
- # Dispatches multiple heterogeneous agent tasks in parallel using
123
- # cooperative {Task}s. Each task is a Hash describing one agent invocation.
124
- #
125
- # Results are returned in the same order as the input +tasks+ array.
126
- # Concurrency is bounded by +max_concurrency+; when nil all tasks run at
127
- # once (original behaviour).
128
- #
129
- # Error semantics are controlled by +on_error+:
130
- # - +:raise+ (default) — every task runs to completion; the first
131
- # exception in input order is then re-raised in the calling task.
132
- # - +:skip+ — failed tasks return +nil+; no exception is raised.
133
- #
134
- # @param tasks [Array<Hash>]
135
- # @option task [Class] :agent agent class to invoke (required)
136
- # @option task [String] :input input string for the agent (required)
137
- # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
138
- # @option task [String] :thread_id forwarded to +agent#invoke+ (default: nil)
139
- # @param max_concurrency [Integer, nil] maximum number of concurrent tasks;
140
- # nil means no limit (all tasks run simultaneously)
141
- # @param on_error [Symbol] +:raise+ or +:skip+
142
- # @param timeout [Numeric, nil] maximum seconds to wait for all tasks;
143
- # nil means wait indefinitely. When the deadline is exceeded,
144
- # {Phronomy::TimeoutError} is raised and all surviving tasks are cancelled
145
- # cooperatively.
146
- # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] when provided, the
147
- # token is merged into each task's config (unless the task already sets one) so
148
- # that every child agent checks it before making LLM calls.
149
- # @param invocation_context [Phronomy::InvocationContext, nil] when provided,
150
- # the context (cancellation_token, deadline, thread_id) is propagated to each
151
- # child agent as a child InvocationContext.
152
- # @param force_kill [Boolean] deprecated — cooperative cancellation is always
153
- # used; this parameter is accepted for backwards compatibility but has no effect.
154
- # @return [Array<Hash, nil>] agent results in the same order as +tasks+
155
- # @raise [ArgumentError] if +on_error+ is not +:raise+ or +:skip+
156
- # @raise [ArgumentError] if +max_concurrency+ is not a positive Integer or nil
157
- # @raise [Phronomy::TimeoutError] if +timeout+ is exceeded
158
- # @api public
159
- def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, invocation_context: nil, force_kill: false)
160
- unless [:raise, :skip].include?(on_error)
161
- raise ArgumentError, "unknown on_error: #{on_error.inspect}"
162
- end
163
- if max_concurrency && !(max_concurrency.is_a?(Integer) && max_concurrency.positive?)
164
- raise ArgumentError, "max_concurrency must be a positive Integer"
95
+ def dispatch_parallel(
96
+ *tasks,
97
+ max_concurrency: nil,
98
+ on_error: :raise,
99
+ timeout: nil,
100
+ cancellation_token: nil,
101
+ invocation_context: nil,
102
+ inherit_knowledge: true
103
+ )
104
+ if Phronomy::Runtime.in_event_loop_context?
105
+ raise Phronomy::EventLoopReentrancyError,
106
+ "dispatch_parallel cannot block the EventLoop; use dispatch_parallel_async"
165
107
  end
108
+ dispatch_parallel_async(
109
+ *tasks,
110
+ max_concurrency: max_concurrency,
111
+ on_error: on_error,
112
+ timeout: timeout,
113
+ cancellation_token: cancellation_token,
114
+ invocation_context: invocation_context,
115
+ inherit_knowledge: inherit_knowledge
116
+ ).wait_result
117
+ end
166
118
 
167
- bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error, timeout: timeout, cancellation_token: cancellation_token, invocation_context: invocation_context, force_kill: force_kill)
119
+ def dispatch_parallel_async(
120
+ *tasks,
121
+ max_concurrency: nil,
122
+ on_error: :raise,
123
+ timeout: nil,
124
+ cancellation_token: nil,
125
+ invocation_context: nil,
126
+ inherit_knowledge: true
127
+ )
128
+ validate_parallel_options!(tasks, max_concurrency, on_error)
129
+ return Phronomy::Task.deferred(name: "fan-out-empty").tap { |task| task.complete([]) } if tasks.empty?
130
+
131
+ children = build_fan_out_children(
132
+ tasks,
133
+ cancellation_token: cancellation_token,
134
+ invocation_context: invocation_context,
135
+ inherit_knowledge: inherit_knowledge
136
+ )
137
+ invocation = FanOutInvocation.new(
138
+ children: children,
139
+ max_concurrency: max_concurrency || children.length,
140
+ on_error: on_error
141
+ )
142
+ effective_token = cancellation_token || invocation_context&.cancellation_token
143
+ FanOutSessionBuilder.start(
144
+ invocation: invocation,
145
+ timeout: timeout,
146
+ cancellation_token: effective_token
147
+ )
168
148
  end
169
149
 
170
- # Runs the same agent against multiple inputs in parallel (fan-out pattern).
171
- #
172
- # Accepts the same +max_concurrency:+ and +on_error:+ keyword arguments as
173
- # {#dispatch_parallel} and forwards them unchanged.
174
- #
175
- # @param agent [Class] agent class to invoke for every input
176
- # @param inputs [Array<String>] list of input strings
177
- # @param config [Hash] forwarded to every +agent#invoke+ call
178
- # @param thread_id [String, nil] forwarded to every +agent#invoke+ call
179
- # @param max_concurrency [Integer, nil] forwarded to {#dispatch_parallel}
180
- # @param on_error [Symbol] forwarded to {#dispatch_parallel}
181
- # @param invocation_context [Phronomy::InvocationContext, nil] forwarded to
182
- # {#dispatch_parallel} for child context propagation
183
- # @return [Array<Hash, nil>] results in the same order as +inputs+
184
- # @api public
185
- def fan_out(agent:, inputs:, config: {}, thread_id: nil, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, invocation_context: nil, force_kill: false)
150
+ def fan_out(
151
+ agent:,
152
+ inputs:,
153
+ config: {},
154
+ thread_id: nil,
155
+ max_concurrency: nil,
156
+ on_error: :raise,
157
+ timeout: nil,
158
+ cancellation_token: nil,
159
+ invocation_context: nil,
160
+ inherit_knowledge: true
161
+ )
186
162
  dispatch_parallel(
187
- *inputs.map { |input| {agent: agent, input: input, config: config, thread_id: thread_id} },
163
+ *inputs.map do |input|
164
+ {agent: agent, input: input, config: config, thread_id: thread_id}
165
+ end,
188
166
  max_concurrency: max_concurrency,
189
167
  on_error: on_error,
190
168
  timeout: timeout,
191
169
  cancellation_token: cancellation_token,
192
170
  invocation_context: invocation_context,
193
- force_kill: force_kill
171
+ inherit_knowledge: inherit_knowledge
194
172
  )
195
173
  end
196
174
 
197
- # Programmatically dispatches a single sub-agent from inside an orchestrator
198
- # instance, inheriting the parent's +thread_id+ and +config+ by default.
199
- #
200
- # @param agent_class [Class] subclass of {Phronomy::Agent::Base}
201
- # @param input [String] task or question for the sub-agent
202
- # @param config [Hash, nil] override config (falls back to parent's)
203
- # @param thread_id [String, nil] override thread_id (falls back to parent's)
204
- # @return [Hash] the sub-agent's result hash (+:output+, +:messages+)
205
- # @api public
206
- def subagent(agent_class, input, config: nil, thread_id: nil)
207
- ctx = @_orchestrator_context || {}
208
- parent_ic = ctx[:invocation_context]
209
- effective_config = config || ctx[:config] || {}
210
-
211
- # Propagate parent InvocationContext to the child agent.
212
- if parent_ic && !effective_config[:invocation_context]
213
- child_ic = parent_ic.merge(parent_task_id: parent_ic.task_id)
214
- effective_config = effective_config.merge(invocation_context: child_ic)
215
- end
175
+ def fan_out_async(
176
+ agent:,
177
+ inputs:,
178
+ config: {},
179
+ thread_id: nil,
180
+ max_concurrency: nil,
181
+ on_error: :raise,
182
+ timeout: nil,
183
+ cancellation_token: nil,
184
+ invocation_context: nil,
185
+ inherit_knowledge: true
186
+ )
187
+ dispatch_parallel_async(
188
+ *inputs.map do |input|
189
+ {agent: agent, input: input, config: config, thread_id: thread_id}
190
+ end,
191
+ max_concurrency: max_concurrency,
192
+ on_error: on_error,
193
+ timeout: timeout,
194
+ cancellation_token: cancellation_token,
195
+ invocation_context: invocation_context,
196
+ inherit_knowledge: inherit_knowledge
197
+ )
198
+ end
216
199
 
217
- agent_class.new.invoke_async(
200
+ def subagent(
201
+ agent_class,
202
+ input,
203
+ config: nil,
204
+ thread_id: nil,
205
+ inherit_knowledge: true
206
+ )
207
+ if Phronomy::Runtime.in_event_loop_context?
208
+ raise Phronomy::EventLoopReentrancyError,
209
+ "subagent cannot block the EventLoop; use the async Agent API"
210
+ end
211
+ build_subagent(
212
+ agent_class,
213
+ inherit_knowledge: inherit_knowledge
214
+ ).invoke_async(
218
215
  input,
219
- config: effective_config,
220
- thread_id: thread_id || ctx[:thread_id] || parent_ic&.thread_id
216
+ config: config || {},
217
+ thread_id: thread_id
221
218
  ).wait_result
222
219
  end
223
220
 
224
221
  private
225
222
 
226
- # Override invoke_once to expose the current thread_id and config via an
227
- # instance variable so that DSL-registered subagent tools can inherit them
228
- # without using Thread.current.
229
- def invoke_once(input, messages: [], thread_id: nil, config: {})
230
- prev = @_orchestrator_context
231
- @_orchestrator_context = {
232
- thread_id: thread_id,
233
- config: config,
234
- invocation_context: config[:invocation_context]
235
- }
236
- super
237
- ensure
238
- @_orchestrator_context = prev
239
- end
240
-
241
- # Override prepare_tool_class to inject the current orchestrator context
242
- # into DSL-registered subagent tools before each call.
243
- def prepare_tool_class(tool_class)
223
+ def prepare_tool_class(tool_class, invocation: nil)
244
224
  prepared = super
245
- orch = self
246
-
247
- # Only wrap subagent tools (those registered via the .subagent DSL).
248
225
  return prepared unless self.class._subagent_tool_classes.include?(tool_class)
249
226
 
250
- # Capture the effective tool name before building the anonymous subclass.
251
- # Class-level instance variables (@tool_name) are not inherited through
252
- # subclassing, so the wrapper must set it explicitly.
227
+ subagent_name = tool_class.tool_name.delete_prefix("dispatch_to_")
228
+ registration = self.class.registered_subagents.find do |name, _|
229
+ name.to_s == subagent_name
230
+ end&.last
231
+ inherits_knowledge = registration ? registration.fetch(:inherit_knowledge, true) : true
232
+
233
+ captured_context = {}
234
+ captured_context[:knowledge] = active_knowledge_snapshot if inherits_knowledge
235
+ if invocation
236
+ captured_context.merge!(
237
+ thread_id: invocation.thread_id,
238
+ config: invocation.config,
239
+ invocation_context: invocation.config[:invocation_context]
240
+ )
241
+ end
242
+ captured_context.freeze
243
+
253
244
  effective_name = prepared.new.name
254
245
  Class.new(prepared) do
255
246
  tool_name effective_name
256
247
  define_method(:call) do |args, **kwargs|
257
- self._orchestrator_context = orch.instance_variable_get(:@_orchestrator_context)
248
+ self._orchestrator_context = captured_context
249
+ super(args, **kwargs)
250
+ end
251
+ define_method(:call_async) do |args, **kwargs|
252
+ self._orchestrator_context = captured_context
258
253
  super(args, **kwargs)
259
254
  end
260
255
  end
261
256
  end
262
257
 
263
- # Task-based worker pool shared by {#dispatch_parallel} and {#fan_out}.
264
- #
265
- # Spawns one {Task} per input using a {TaskGroup} so that +max_concurrency+
266
- # acts as a semaphore: spare tasks block on {TaskGroup#spawn} until a slot
267
- # becomes available. Results are written back to +results+ in input order;
268
- # +errors+ captures the first error per position so that the first error in
269
- # *input* order is deterministically re-raised when +on_error: :raise+ is used.
270
- #
271
- # When +timeout+ is given, each spawned task is joined with the remaining
272
- # deadline. Any still-alive tasks are cancelled cooperatively via
273
- # {TaskGroup#cancel_all!} before {Phronomy::TimeoutError} is raised.
274
- # The +force_kill+ argument is deprecated: cooperative cancellation is always
275
- # used regardless of its value.
276
- #
277
- # Deadline tracking uses +Process.clock_gettime(Process::CLOCK_MONOTONIC)+
278
- # to avoid sensitivity to NTP adjustments and system-clock changes.
279
- def bounded_map(tasks, max_concurrency:, on_error:, timeout: nil, cancellation_token: nil, invocation_context: nil, force_kill: false) # rubocop:disable Lint/UnusedMethodArgument
280
- return [] if tasks.empty?
281
-
282
- results = Array.new(tasks.length)
283
- errors = Array.new(tasks.length)
284
- group = Phronomy::Runtime.instance.task_group(limit: max_concurrency || tasks.length)
285
-
286
- # Resolve the effective cancellation token: explicit argument wins;
287
- # fall back to the one embedded in the InvocationContext if present.
288
- effective_ct = cancellation_token || invocation_context&.cancellation_token
289
-
290
- spawned = tasks.each_with_index.map do |task, i|
291
- group.spawn do
292
- task_config = task.fetch(:config, {})
293
-
294
- # Merge the shared cancellation token unless the task already has one.
295
- if effective_ct && !task_config[:cancellation_token]
296
- task_config = task_config.merge(cancellation_token: effective_ct)
297
- end
258
+ def active_knowledge_snapshot
259
+ journal_projection.context_records.filter_map do |record|
260
+ next unless record.kind == :knowledge
298
261
 
299
- # Propagate parent InvocationContext to each child task so that
300
- # cancellation, deadline, and tracing carry through automatically.
301
- if invocation_context && !task_config[:invocation_context]
302
- child_ic = invocation_context.merge(parent_task_id: invocation_context.task_id)
303
- task_config = task_config.merge(invocation_context: child_ic)
304
- end
262
+ {
263
+ content: persistence.contents.fetch_text(record.content_ref),
264
+ metadata: (record.metadata || {}).dup.freeze
265
+ }.freeze
266
+ end.freeze
267
+ end
305
268
 
306
- results[i] = task[:agent].new.invoke_async(
307
- task[:input],
308
- config: task_config,
309
- thread_id: task[:thread_id] || invocation_context&.thread_id
310
- ).wait_result
311
- rescue => e
312
- errors[i] = e unless on_error == :skip
313
- end
314
- end
269
+ def build_subagent(agent_class, inherit_knowledge: true, knowledge_snapshot: nil)
270
+ agent = agent_class.new
271
+ return agent unless inherit_knowledge
315
272
 
316
- if timeout
317
- deadline = Phronomy::Concurrency::Deadline.in(timeout)
318
- spawned.each { |t| t.join([deadline.remaining_seconds, 0].max) }
273
+ snapshot = knowledge_snapshot || active_knowledge_snapshot
274
+ snapshot.each do |entry|
275
+ agent.add_knowledge(
276
+ entry.fetch(:content),
277
+ metadata: entry.fetch(:metadata, {})
278
+ )
279
+ end
280
+ agent
281
+ end
319
282
 
320
- alive = spawned.select(&:alive?)
321
- unless alive.empty?
322
- group.cancel_all!
323
- raise Phronomy::TimeoutError,
324
- "dispatch_parallel timed out after #{timeout}s " \
325
- "(#{alive.length} of #{spawned.length} tasks still running)"
326
- end
327
- else
328
- spawned.each(&:wait_result)
283
+ def validate_parallel_options!(tasks, max_concurrency, on_error)
284
+ unless %i[raise skip].include?(on_error)
285
+ raise ArgumentError, "unknown on_error: #{on_error.inspect}"
329
286
  end
287
+ if max_concurrency && !(max_concurrency.is_a?(Integer) && max_concurrency.positive?)
288
+ raise ArgumentError, "max_concurrency must be a positive Integer"
289
+ end
290
+ tasks.each do |task|
291
+ raise ArgumentError, "fan-out task must be a Hash" unless task.is_a?(Hash)
292
+ raise ArgumentError, "fan-out task requires :agent" unless task[:agent]
293
+ raise ArgumentError, "fan-out task requires :input" unless task.key?(:input)
294
+ end
295
+ end
330
296
 
331
- first_error = errors.compact.first
332
- raise first_error if first_error
297
+ def build_fan_out_children(
298
+ tasks,
299
+ cancellation_token:,
300
+ invocation_context:,
301
+ inherit_knowledge:
302
+ )
303
+ inheritance_flags = tasks.map { |task| task.fetch(:inherit_knowledge, inherit_knowledge) }
304
+ knowledge_snapshot = active_knowledge_snapshot if inheritance_flags.any?
305
+
306
+ tasks.each_with_index.map do |task, index|
307
+ task_config = task.fetch(:config, {}).dup
308
+ if cancellation_token && !task_config[:cancellation_token]
309
+ task_config[:cancellation_token] = cancellation_token
310
+ end
311
+ if invocation_context && !task_config[:invocation_context]
312
+ task_config[:invocation_context] = invocation_context.merge(
313
+ parent_task_id: invocation_context.task_id
314
+ )
315
+ end
333
316
 
334
- results
317
+ child_agent = build_subagent(
318
+ task.fetch(:agent),
319
+ inherit_knowledge: inheritance_flags[index],
320
+ knowledge_snapshot: knowledge_snapshot
321
+ )
322
+
323
+ FanOutInvocation::Child.new(
324
+ index: index,
325
+ agent: child_agent,
326
+ input: task.fetch(:input),
327
+ config: task_config,
328
+ thread_id: task[:thread_id] || invocation_context&.thread_id
329
+ )
330
+ end
335
331
  end
336
332
  end
337
333
  end