phronomy 0.16.0 → 0.17.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +54 -0
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +124 -92
  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 +1 -1
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  14. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  15. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  16. data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
  17. data/lib/phronomy/agent/agent_root.rb +1 -2
  18. data/lib/phronomy/agent/base.rb +135 -314
  19. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  20. data/lib/phronomy/agent/context_assembler.rb +65 -29
  21. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  22. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  23. data/lib/phronomy/agent/execution_coordinator.rb +0 -1
  24. data/lib/phronomy/agent/journal_projection.rb +28 -2
  25. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  26. data/lib/phronomy/agent/shared_state.rb +46 -138
  27. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  28. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  29. data/lib/phronomy/agent.rb +6 -10
  30. data/lib/phronomy/configuration.rb +15 -158
  31. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  32. data/lib/phronomy/engine/runtime.rb +15 -230
  33. data/lib/phronomy/engine/task_group.rb +30 -102
  34. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  35. data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
  36. data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
  37. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  38. data/lib/phronomy/version.rb +1 -1
  39. data/lib/phronomy.rb +3 -120
  40. data/scripts/api_snapshot.rb +1 -12
  41. metadata +3 -9
  42. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  43. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  44. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  45. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  46. data/lib/phronomy/knowledge_source.rb +0 -12
  47. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  48. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -2,96 +2,25 @@
2
2
 
3
3
  module Phronomy
4
4
  module LlmContextWindow
5
- # Raised when a model name is not found in the RubyLLM model registry and
6
- # no explicit context_window was provided.
7
- class UnknownModelError < Phronomy::Error; end
8
-
9
- # Calculates the effective token budget available for conversation history
10
- # and injected knowledge within a single LLM request.
11
- #
12
- # The window is divided as follows:
13
- #
14
- # context_window (total)
15
- # ├─ max_output_tokens (reserved for model output = max_output_tokens)
16
- # ├─ overhead (reserved for system prompt + tool definitions)
17
- # └─ effective_input_limit (available for memory + knowledge)
18
- #
19
- # @example Auto-derive from RubyLLM model registry
20
- # budget = Phronomy::LlmContextWindow::TokenBudget.new(model: "claude-3-5-sonnet-20241022")
21
- #
22
- # @example Explicit values (useful for local / unknown models)
23
- # budget = Phronomy::LlmContextWindow::TokenBudget.new(
24
- # context_window: 32_768,
25
- # max_output_tokens: 4_096
26
- # )
27
- #
28
- # @example With overhead for instructions + tool definitions
29
- # budget = Phronomy::LlmContextWindow::TokenBudget.new(
30
- # model: "gpt-4o",
31
- # overhead: 800
32
- # )
5
+ # Immutable arithmetic value for one resolved model context budget.
6
+ # Model-registry lookup belongs to Agent::TokenBudgetResolver.
33
7
  class TokenBudget
34
- # @return [Integer] total token limit of the model
35
- attr_reader :context_window
36
-
37
- # @return [Integer] tokens reserved for model output
38
- attr_reader :max_output_tokens
8
+ attr_reader :context_window, :max_output_tokens
39
9
 
40
- # @return [Integer] tokens reserved for instructions and tool definitions
41
- attr_reader :overhead
42
-
43
- # @param model [String, nil] model identifier looked up in RubyLLM
44
- # @param context_window [Integer, nil] explicit total token limit
45
- # @param max_output_tokens [Integer, nil] explicit output reservation; when nil
46
- # and model is given, uses max_output_tokens
47
- # @param overhead [Integer] tokens reserved for instructions/tools
48
10
  # @api private
49
- # mutant:disable - multiple genuine equivalent mutations: overhead/context_window/max_output_tokens .to_i vs .to_int vs Integer() vs omitted are equivalent for Integer inputs; (max_output_tokens||0).to_i vs (max_output_tokens).to_i and (||nil).to_i are genuine because nil.to_i==0; overhead:nil default is genuine because nil.to_i==0
50
- def initialize(model: nil, context_window: nil, max_output_tokens: nil, overhead: 0)
51
- @overhead = overhead.to_i
52
-
53
- if context_window
54
- # Explicit values — no registry lookup needed.
55
- @context_window = context_window.to_i
56
- @max_output_tokens = (max_output_tokens || 0).to_i
57
- elsif model
58
- ruby_llm_model = lookup_model!(model)
59
- @context_window = ruby_llm_model.context_window.to_i
60
- @max_output_tokens = (max_output_tokens || ruby_llm_model.max_output_tokens).to_i
61
- else
62
- raise ArgumentError, "Provide either model: or context_window:"
63
- end
11
+ def initialize(context_window:, max_output_tokens:)
12
+ @context_window = Integer(context_window)
13
+ @max_output_tokens = Integer(max_output_tokens)
64
14
  end
65
15
 
66
- # Tokens available for conversation history and knowledge after reservations.
67
- # Always >= 0.
68
- #
69
- # @return [Integer]
70
16
  # @api private
71
17
  def effective_input_limit
72
- [@context_window - @max_output_tokens - @overhead, 0].max
18
+ [@context_window - @max_output_tokens, 0].max
73
19
  end
74
20
 
75
- # Tokens still available after `used` tokens have been allocated.
76
- #
77
- # @param used [Integer] tokens already committed (e.g. from knowledge injection)
78
- # @return [Integer] remaining tokens (always >= 0)
79
21
  # @api private
80
- # mutant:disable - used.to_i vs used vs used.to_int vs Integer(used) are genuine equivalents when used is an Integer; used:nil default is genuine because nil.to_i==0==default 0
81
22
  def available(used: 0)
82
- [effective_input_limit - used.to_i, 0].max
83
- end
84
-
85
- private
86
-
87
- # mutant:disable - raise(UnknownModelError) and raise(UnknownModelError,nil) and raise(UnknownModelError,"Model '#{nil}' not found") in both branches are genuine equivalents (spec checks exception class only, not message text)
88
- def lookup_model!(model_name)
89
- found = RubyLLM.models.find(model_name)
90
- raise UnknownModelError, "Model '#{model_name}' not found in RubyLLM registry" unless found
91
-
92
- found
93
- rescue RubyLLM::ModelNotFoundError
94
- raise UnknownModelError, "Model '#{model_name}' not found in RubyLLM registry"
23
+ [effective_input_limit - Integer(used), 0].max
95
24
  end
96
25
  end
97
26
  end
@@ -3,85 +3,39 @@
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
8
+
59
9
  # @api public
60
- def self.subagent(name, agent_class, on_error: :raise)
10
+ def self.subagent(name, agent_class, on_error: :raise, inherit_knowledge: true)
61
11
  tool_class = Class.new(Phronomy::Agent::Context::Capability::Base) do
62
12
  tool_name "dispatch_to_#{name}"
63
13
  description "Dispatch work to the #{name} subagent (#{agent_class.name})"
64
14
  param :input, type: :string, desc: "The task or question for the subagent"
65
15
 
66
- # @_orchestrator_context is injected at call time by prepare_tool_class.
67
16
  attr_writer :_orchestrator_context
68
17
 
69
18
  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.
73
19
  ctx = @_orchestrator_context || {}
74
20
  parent_ic = ctx[:invocation_context]
75
21
  task_config = ctx[:config] || {}
76
22
 
77
- # Propagate parent InvocationContext to the child agent so that
78
- # cancellation, deadline, and tracing carry through automatically.
79
23
  if parent_ic && !task_config[:invocation_context]
80
24
  child_ic = parent_ic.merge(parent_task_id: parent_ic.task_id)
81
25
  task_config = task_config.merge(invocation_context: child_ic)
82
26
  end
83
27
 
84
- result = agent_class.new.invoke_async(
28
+ agent = agent_class.new
29
+ if inherit_knowledge
30
+ Array(ctx[:knowledge]).each do |entry|
31
+ agent.add_knowledge(
32
+ entry.fetch(:content),
33
+ metadata: entry.fetch(:metadata, {})
34
+ )
35
+ end
36
+ end
37
+
38
+ result = agent.invoke_async(
85
39
  input,
86
40
  thread_id: ctx[:thread_id] || parent_ic&.thread_id,
87
41
  config: task_config
@@ -93,229 +47,223 @@ module Phronomy
93
47
  end
94
48
  end
95
49
 
96
- # Track this tool class so prepare_tool_class can inject context.
97
50
  @_subagent_tool_classes = (@_subagent_tool_classes || []) + [tool_class]
98
-
99
- # Append without clobbering previously registered tools or aliases.
100
51
  @tools = (@tools || []) + [tool_class]
101
52
  @tool_aliases ||= {}
102
-
103
- registered_subagents[name] = {agent_class: agent_class, on_error: on_error}
53
+ registered_subagents[name] = {
54
+ agent_class: agent_class,
55
+ on_error: on_error,
56
+ inherit_knowledge: inherit_knowledge
57
+ }
104
58
  end
105
59
 
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
60
  def self._subagent_tool_classes
111
61
  @_subagent_tool_classes || []
112
62
  end
113
63
 
114
- # Returns the subagent registry for this specific class (not inherited).
115
- #
116
- # @return [Hash{Symbol => Hash}]
117
64
  # @api public
118
65
  def self.registered_subagents
119
66
  @registered_subagents ||= {}
120
67
  end
121
68
 
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
69
  # @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)
70
+ def dispatch_parallel(
71
+ *tasks,
72
+ max_concurrency: nil,
73
+ on_error: :raise,
74
+ timeout: nil,
75
+ cancellation_token: nil,
76
+ invocation_context: nil,
77
+ inherit_knowledge: true
78
+ )
79
+ unless %i[raise skip].include?(on_error)
161
80
  raise ArgumentError, "unknown on_error: #{on_error.inspect}"
162
81
  end
163
82
  if max_concurrency && !(max_concurrency.is_a?(Integer) && max_concurrency.positive?)
164
83
  raise ArgumentError, "max_concurrency must be a positive Integer"
165
84
  end
166
85
 
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)
86
+ bounded_map(
87
+ tasks,
88
+ max_concurrency: max_concurrency,
89
+ on_error: on_error,
90
+ timeout: timeout,
91
+ cancellation_token: cancellation_token,
92
+ invocation_context: invocation_context,
93
+ inherit_knowledge: inherit_knowledge
94
+ )
168
95
  end
169
96
 
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
97
  # @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)
98
+ def fan_out(
99
+ agent:,
100
+ inputs:,
101
+ config: {},
102
+ thread_id: nil,
103
+ max_concurrency: nil,
104
+ on_error: :raise,
105
+ timeout: nil,
106
+ cancellation_token: nil,
107
+ invocation_context: nil,
108
+ inherit_knowledge: true
109
+ )
186
110
  dispatch_parallel(
187
- *inputs.map { |input| {agent: agent, input: input, config: config, thread_id: thread_id} },
111
+ *inputs.map do |input|
112
+ {agent: agent, input: input, config: config, thread_id: thread_id}
113
+ end,
188
114
  max_concurrency: max_concurrency,
189
115
  on_error: on_error,
190
116
  timeout: timeout,
191
117
  cancellation_token: cancellation_token,
192
118
  invocation_context: invocation_context,
193
- force_kill: force_kill
119
+ inherit_knowledge: inherit_knowledge
194
120
  )
195
121
  end
196
122
 
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+)
123
+ # Programmatic single-subagent dispatch. Context propagation is explicit:
124
+ # pass invocation_context in +config+ when this call must inherit a parent.
125
+ # Active parent Knowledge is inherited by default; pass
126
+ # +inherit_knowledge: false+ to create an isolated subagent.
205
127
  # @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
216
-
217
- agent_class.new.invoke_async(
128
+ def subagent(
129
+ agent_class,
130
+ input,
131
+ config: nil,
132
+ thread_id: nil,
133
+ inherit_knowledge: true
134
+ )
135
+ build_subagent(
136
+ agent_class,
137
+ inherit_knowledge: inherit_knowledge
138
+ ).invoke_async(
218
139
  input,
219
- config: effective_config,
220
- thread_id: thread_id || ctx[:thread_id] || parent_ic&.thread_id
140
+ config: config || {},
141
+ thread_id: thread_id
221
142
  ).wait_result
222
143
  end
223
144
 
224
145
  private
225
146
 
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)
147
+ # Capture the current invocation directly while materializing Tool classes.
148
+ # No legacy invoke_once/thread-local bridge is involved.
149
+ def prepare_tool_class(tool_class, invocation: nil)
244
150
  prepared = super
245
- orch = self
246
-
247
- # Only wrap subagent tools (those registered via the .subagent DSL).
248
151
  return prepared unless self.class._subagent_tool_classes.include?(tool_class)
249
152
 
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.
153
+ subagent_name = tool_class.tool_name.delete_prefix("dispatch_to_")
154
+ registration = self.class.registered_subagents.find do |name, _|
155
+ name.to_s == subagent_name
156
+ end&.last
157
+ inherits_knowledge = registration ? registration.fetch(:inherit_knowledge, true) : true
158
+
159
+ captured_context = {}
160
+ captured_context[:knowledge] = active_knowledge_snapshot if inherits_knowledge
161
+ if invocation
162
+ captured_context.merge!(
163
+ thread_id: invocation.thread_id,
164
+ config: invocation.config,
165
+ invocation_context: invocation.config[:invocation_context]
166
+ )
167
+ end
168
+ captured_context.freeze
169
+
253
170
  effective_name = prepared.new.name
254
171
  Class.new(prepared) do
255
172
  tool_name effective_name
256
173
  define_method(:call) do |args, **kwargs|
257
- self._orchestrator_context = orch.instance_variable_get(:@_orchestrator_context)
174
+ self._orchestrator_context = captured_context
258
175
  super(args, **kwargs)
259
176
  end
260
177
  end
261
178
  end
262
179
 
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
180
+ def active_knowledge_snapshot
181
+ journal_projection.context_records.filter_map do |record|
182
+ next unless record.kind == :knowledge
183
+
184
+ {
185
+ content: persistence.contents.fetch_text(record.content_ref),
186
+ metadata: (record.metadata || {}).dup.freeze
187
+ }.freeze
188
+ end.freeze
189
+ end
190
+
191
+ def build_subagent(
192
+ agent_class,
193
+ inherit_knowledge: true,
194
+ knowledge_snapshot: nil
195
+ )
196
+ agent = agent_class.new
197
+ return agent unless inherit_knowledge
198
+
199
+ snapshot = knowledge_snapshot || active_knowledge_snapshot
200
+ snapshot.each do |entry|
201
+ agent.add_knowledge(
202
+ entry.fetch(:content),
203
+ metadata: entry.fetch(:metadata, {})
204
+ )
205
+ end
206
+ agent
207
+ end
208
+
209
+ def bounded_map(
210
+ tasks,
211
+ max_concurrency:,
212
+ on_error:,
213
+ timeout: nil,
214
+ cancellation_token: nil,
215
+ invocation_context: nil,
216
+ inherit_knowledge: true
217
+ )
280
218
  return [] if tasks.empty?
281
219
 
220
+ inheritance_flags = tasks.map do |task|
221
+ task.fetch(:inherit_knowledge, inherit_knowledge)
222
+ end
223
+ knowledge_snapshot = active_knowledge_snapshot if inheritance_flags.any?
224
+
282
225
  results = Array.new(tasks.length)
283
226
  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.
227
+ group = Phronomy::Runtime.instance.task_group(
228
+ limit: max_concurrency || tasks.length
229
+ )
288
230
  effective_ct = cancellation_token || invocation_context&.cancellation_token
289
231
 
290
- spawned = tasks.each_with_index.map do |task, i|
232
+ spawned = tasks.each_with_index.map do |task, index|
291
233
  group.spawn do
292
234
  task_config = task.fetch(:config, {})
293
235
 
294
- # Merge the shared cancellation token unless the task already has one.
295
236
  if effective_ct && !task_config[:cancellation_token]
296
237
  task_config = task_config.merge(cancellation_token: effective_ct)
297
238
  end
298
239
 
299
- # Propagate parent InvocationContext to each child task so that
300
- # cancellation, deadline, and tracing carry through automatically.
301
240
  if invocation_context && !task_config[:invocation_context]
302
- child_ic = invocation_context.merge(parent_task_id: invocation_context.task_id)
241
+ child_ic = invocation_context.merge(
242
+ parent_task_id: invocation_context.task_id
243
+ )
303
244
  task_config = task_config.merge(invocation_context: child_ic)
304
245
  end
305
246
 
306
- results[i] = task[:agent].new.invoke_async(
247
+ task_inherits_knowledge = inheritance_flags[index]
248
+ agent = build_subagent(
249
+ task[:agent],
250
+ inherit_knowledge: task_inherits_knowledge,
251
+ knowledge_snapshot: knowledge_snapshot
252
+ )
253
+
254
+ results[index] = agent.invoke_async(
307
255
  task[:input],
308
256
  config: task_config,
309
257
  thread_id: task[:thread_id] || invocation_context&.thread_id
310
258
  ).wait_result
311
- rescue => e
312
- errors[i] = e unless on_error == :skip
259
+ rescue => error
260
+ errors[index] = error unless on_error == :skip
313
261
  end
314
262
  end
315
263
 
316
264
  if timeout
317
265
  deadline = Phronomy::Concurrency::Deadline.in(timeout)
318
- spawned.each { |t| t.join([deadline.remaining_seconds, 0].max) }
266
+ spawned.each { |task| task.join([deadline.remaining_seconds, 0].max) }
319
267
 
320
268
  alive = spawned.select(&:alive?)
321
269
  unless alive.empty?