phronomy 0.15.1 → 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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +159 -28
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +400 -143
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +7 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +3 -19
  10. data/benchmark/bench_tool_schema.rb +2 -34
  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 +40 -1
  14. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  15. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  16. data/lib/phronomy/agent/activation_registry.rb +28 -0
  17. data/lib/phronomy/agent/agent_execution.rb +97 -0
  18. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  19. data/lib/phronomy/agent/agent_invocation.rb +44 -46
  20. data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
  21. data/lib/phronomy/agent/agent_root.rb +66 -0
  22. data/lib/phronomy/agent/async_event_api.rb +55 -475
  23. data/lib/phronomy/agent/base.rb +351 -514
  24. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  25. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  26. data/lib/phronomy/agent/context_assembler.rb +357 -0
  27. data/lib/phronomy/agent/context_candidate.rb +47 -0
  28. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  29. data/lib/phronomy/agent/context_importer.rb +217 -0
  30. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  31. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  32. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  33. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
  34. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  35. data/lib/phronomy/agent/context_plan.rb +25 -0
  36. data/lib/phronomy/agent/context_plan_validator.rb +134 -0
  37. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  38. data/lib/phronomy/agent/context_policy.rb +15 -0
  39. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  40. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  41. data/lib/phronomy/agent/context_request.rb +35 -0
  42. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  43. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  44. data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
  45. data/lib/phronomy/agent/immutable.rb +31 -0
  46. data/lib/phronomy/agent/journal_projection.rb +60 -0
  47. data/lib/phronomy/agent/journal_record.rb +67 -0
  48. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  49. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  50. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  51. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  52. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  53. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  54. data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
  55. data/lib/phronomy/agent/shared_state.rb +46 -138
  56. data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
  57. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  58. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  59. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  60. data/lib/phronomy/agent.rb +10 -16
  61. data/lib/phronomy/agent_busy_error.rb +5 -0
  62. data/lib/phronomy/canonical_json.rb +136 -0
  63. data/lib/phronomy/configuration.rb +17 -155
  64. data/lib/phronomy/content_store/base.rb +51 -0
  65. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  66. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  67. data/lib/phronomy/engine/event_loop.rb +3 -0
  68. data/lib/phronomy/engine/runtime.rb +15 -230
  69. data/lib/phronomy/engine/task_group.rb +30 -102
  70. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  71. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  72. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  73. data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
  74. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  75. data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
  76. data/lib/phronomy/persistence/in_memory.rb +247 -0
  77. data/lib/phronomy/persistence.rb +39 -0
  78. data/lib/phronomy/tools/agent.rb +14 -36
  79. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  80. data/lib/phronomy/version.rb +1 -1
  81. data/lib/phronomy.rb +9 -115
  82. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  83. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  84. data/scripts/api_snapshot.rb +1 -12
  85. data/scripts/migrate_spec_agent_definition.rb +108 -0
  86. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  87. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  88. metadata +54 -13
  89. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  90. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  91. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
  92. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  93. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  94. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  95. data/lib/phronomy/knowledge_source.rb +0 -12
  96. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  97. 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,84 +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
- # Declares a named subagent and registers it as a tool accessible to the
46
- # LLM during an +invoke+ call.
47
- #
48
- # Each call appends a new tool to this class's tool list. The generated
49
- # tool's function name is +dispatch_to_<name>+. When the LLM calls the
50
- # tool, a fresh instance of +agent_class+ is created and +invoke+ is called
51
- # with the provided input string.
52
- #
53
- # @param name [Symbol] logical name that identifies the subagent
54
- # @param agent_class [Class] subclass of {Phronomy::Agent::Base}
55
- # @param on_error [Symbol] +:raise+ (default) re-raises any exception
56
- # from the subagent; +:skip+ returns +nil+ so the LLM can decide how to
57
- # proceed
7
+ agent_definition id: "orchestrator", version: 1
8
+
58
9
  # @api public
59
- def self.subagent(name, agent_class, on_error: :raise)
10
+ def self.subagent(name, agent_class, on_error: :raise, inherit_knowledge: true)
60
11
  tool_class = Class.new(Phronomy::Agent::Context::Capability::Base) do
61
12
  tool_name "dispatch_to_#{name}"
62
13
  description "Dispatch work to the #{name} subagent (#{agent_class.name})"
63
14
  param :input, type: :string, desc: "The task or question for the subagent"
64
15
 
65
- # @_orchestrator_context is injected at call time by prepare_tool_class.
66
16
  attr_writer :_orchestrator_context
67
17
 
68
18
  define_method(:execute) do |input:|
69
- # Inherit the calling orchestrator's thread_id, config, and
70
- # InvocationContext so that child subagent spans and memory stay
71
- # connected to the parent invocation.
72
19
  ctx = @_orchestrator_context || {}
73
20
  parent_ic = ctx[:invocation_context]
74
21
  task_config = ctx[:config] || {}
75
22
 
76
- # Propagate parent InvocationContext to the child agent so that
77
- # cancellation, deadline, and tracing carry through automatically.
78
23
  if parent_ic && !task_config[:invocation_context]
79
24
  child_ic = parent_ic.merge(parent_task_id: parent_ic.task_id)
80
25
  task_config = task_config.merge(invocation_context: child_ic)
81
26
  end
82
27
 
83
- 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(
84
39
  input,
85
40
  thread_id: ctx[:thread_id] || parent_ic&.thread_id,
86
41
  config: task_config
@@ -92,229 +47,223 @@ module Phronomy
92
47
  end
93
48
  end
94
49
 
95
- # Track this tool class so prepare_tool_class can inject context.
96
50
  @_subagent_tool_classes = (@_subagent_tool_classes || []) + [tool_class]
97
-
98
- # Append without clobbering previously registered tools or aliases.
99
51
  @tools = (@tools || []) + [tool_class]
100
52
  @tool_aliases ||= {}
101
-
102
- 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
+ }
103
58
  end
104
59
 
105
- # Returns the subagent tool classes registered on this specific class.
106
- # Used by {#prepare_tool_class} to inject context.
107
- # @return [Array<Class>]
108
- # @api private
109
60
  def self._subagent_tool_classes
110
61
  @_subagent_tool_classes || []
111
62
  end
112
63
 
113
- # Returns the subagent registry for this specific class (not inherited).
114
- #
115
- # @return [Hash{Symbol => Hash}]
116
64
  # @api public
117
65
  def self.registered_subagents
118
66
  @registered_subagents ||= {}
119
67
  end
120
68
 
121
- # Dispatches multiple heterogeneous agent tasks in parallel using
122
- # cooperative {Task}s. Each task is a Hash describing one agent invocation.
123
- #
124
- # Results are returned in the same order as the input +tasks+ array.
125
- # Concurrency is bounded by +max_concurrency+; when nil all tasks run at
126
- # once (original behaviour).
127
- #
128
- # Error semantics are controlled by +on_error+:
129
- # - +:raise+ (default) — every task runs to completion; the first
130
- # exception in input order is then re-raised in the calling task.
131
- # - +:skip+ — failed tasks return +nil+; no exception is raised.
132
- #
133
- # @param tasks [Array<Hash>]
134
- # @option task [Class] :agent agent class to invoke (required)
135
- # @option task [String] :input input string for the agent (required)
136
- # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
137
- # @option task [String] :thread_id forwarded to +agent#invoke+ (default: nil)
138
- # @param max_concurrency [Integer, nil] maximum number of concurrent tasks;
139
- # nil means no limit (all tasks run simultaneously)
140
- # @param on_error [Symbol] +:raise+ or +:skip+
141
- # @param timeout [Numeric, nil] maximum seconds to wait for all tasks;
142
- # nil means wait indefinitely. When the deadline is exceeded,
143
- # {Phronomy::TimeoutError} is raised and all surviving tasks are cancelled
144
- # cooperatively.
145
- # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] when provided, the
146
- # token is merged into each task's config (unless the task already sets one) so
147
- # that every child agent checks it before making LLM calls.
148
- # @param invocation_context [Phronomy::InvocationContext, nil] when provided,
149
- # the context (cancellation_token, deadline, thread_id) is propagated to each
150
- # child agent as a child InvocationContext.
151
- # @param force_kill [Boolean] deprecated — cooperative cancellation is always
152
- # used; this parameter is accepted for backwards compatibility but has no effect.
153
- # @return [Array<Hash, nil>] agent results in the same order as +tasks+
154
- # @raise [ArgumentError] if +on_error+ is not +:raise+ or +:skip+
155
- # @raise [ArgumentError] if +max_concurrency+ is not a positive Integer or nil
156
- # @raise [Phronomy::TimeoutError] if +timeout+ is exceeded
157
69
  # @api public
158
- def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, invocation_context: nil, force_kill: false)
159
- 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)
160
80
  raise ArgumentError, "unknown on_error: #{on_error.inspect}"
161
81
  end
162
82
  if max_concurrency && !(max_concurrency.is_a?(Integer) && max_concurrency.positive?)
163
83
  raise ArgumentError, "max_concurrency must be a positive Integer"
164
84
  end
165
85
 
166
- 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
+ )
167
95
  end
168
96
 
169
- # Runs the same agent against multiple inputs in parallel (fan-out pattern).
170
- #
171
- # Accepts the same +max_concurrency:+ and +on_error:+ keyword arguments as
172
- # {#dispatch_parallel} and forwards them unchanged.
173
- #
174
- # @param agent [Class] agent class to invoke for every input
175
- # @param inputs [Array<String>] list of input strings
176
- # @param config [Hash] forwarded to every +agent#invoke+ call
177
- # @param thread_id [String, nil] forwarded to every +agent#invoke+ call
178
- # @param max_concurrency [Integer, nil] forwarded to {#dispatch_parallel}
179
- # @param on_error [Symbol] forwarded to {#dispatch_parallel}
180
- # @param invocation_context [Phronomy::InvocationContext, nil] forwarded to
181
- # {#dispatch_parallel} for child context propagation
182
- # @return [Array<Hash, nil>] results in the same order as +inputs+
183
97
  # @api public
184
- 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
+ )
185
110
  dispatch_parallel(
186
- *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,
187
114
  max_concurrency: max_concurrency,
188
115
  on_error: on_error,
189
116
  timeout: timeout,
190
117
  cancellation_token: cancellation_token,
191
118
  invocation_context: invocation_context,
192
- force_kill: force_kill
119
+ inherit_knowledge: inherit_knowledge
193
120
  )
194
121
  end
195
122
 
196
- # Programmatically dispatches a single sub-agent from inside an orchestrator
197
- # instance, inheriting the parent's +thread_id+ and +config+ by default.
198
- #
199
- # @param agent_class [Class] subclass of {Phronomy::Agent::Base}
200
- # @param input [String] task or question for the sub-agent
201
- # @param config [Hash, nil] override config (falls back to parent's)
202
- # @param thread_id [String, nil] override thread_id (falls back to parent's)
203
- # @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.
204
127
  # @api public
205
- def subagent(agent_class, input, config: nil, thread_id: nil)
206
- ctx = @_orchestrator_context || {}
207
- parent_ic = ctx[:invocation_context]
208
- effective_config = config || ctx[:config] || {}
209
-
210
- # Propagate parent InvocationContext to the child agent.
211
- if parent_ic && !effective_config[:invocation_context]
212
- child_ic = parent_ic.merge(parent_task_id: parent_ic.task_id)
213
- effective_config = effective_config.merge(invocation_context: child_ic)
214
- end
215
-
216
- 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(
217
139
  input,
218
- config: effective_config,
219
- thread_id: thread_id || ctx[:thread_id] || parent_ic&.thread_id
140
+ config: config || {},
141
+ thread_id: thread_id
220
142
  ).wait_result
221
143
  end
222
144
 
223
145
  private
224
146
 
225
- # Override invoke_once to expose the current thread_id and config via an
226
- # instance variable so that DSL-registered subagent tools can inherit them
227
- # without using Thread.current.
228
- def invoke_once(input, messages: [], thread_id: nil, config: {})
229
- prev = @_orchestrator_context
230
- @_orchestrator_context = {
231
- thread_id: thread_id,
232
- config: config,
233
- invocation_context: config[:invocation_context]
234
- }
235
- super
236
- ensure
237
- @_orchestrator_context = prev
238
- end
239
-
240
- # Override prepare_tool_class to inject the current orchestrator context
241
- # into DSL-registered subagent tools before each call.
242
- 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)
243
150
  prepared = super
244
- orch = self
245
-
246
- # Only wrap subagent tools (those registered via the .subagent DSL).
247
151
  return prepared unless self.class._subagent_tool_classes.include?(tool_class)
248
152
 
249
- # Capture the effective tool name before building the anonymous subclass.
250
- # Class-level instance variables (@tool_name) are not inherited through
251
- # 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
+
252
170
  effective_name = prepared.new.name
253
171
  Class.new(prepared) do
254
172
  tool_name effective_name
255
173
  define_method(:call) do |args, **kwargs|
256
- self._orchestrator_context = orch.instance_variable_get(:@_orchestrator_context)
174
+ self._orchestrator_context = captured_context
257
175
  super(args, **kwargs)
258
176
  end
259
177
  end
260
178
  end
261
179
 
262
- # Task-based worker pool shared by {#dispatch_parallel} and {#fan_out}.
263
- #
264
- # Spawns one {Task} per input using a {TaskGroup} so that +max_concurrency+
265
- # acts as a semaphore: spare tasks block on {TaskGroup#spawn} until a slot
266
- # becomes available. Results are written back to +results+ in input order;
267
- # +errors+ captures the first error per position so that the first error in
268
- # *input* order is deterministically re-raised when +on_error: :raise+ is used.
269
- #
270
- # When +timeout+ is given, each spawned task is joined with the remaining
271
- # deadline. Any still-alive tasks are cancelled cooperatively via
272
- # {TaskGroup#cancel_all!} before {Phronomy::TimeoutError} is raised.
273
- # The +force_kill+ argument is deprecated: cooperative cancellation is always
274
- # used regardless of its value.
275
- #
276
- # Deadline tracking uses +Process.clock_gettime(Process::CLOCK_MONOTONIC)+
277
- # to avoid sensitivity to NTP adjustments and system-clock changes.
278
- 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
+ )
279
218
  return [] if tasks.empty?
280
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
+
281
225
  results = Array.new(tasks.length)
282
226
  errors = Array.new(tasks.length)
283
- group = Phronomy::Runtime.instance.task_group(limit: max_concurrency || tasks.length)
284
-
285
- # Resolve the effective cancellation token: explicit argument wins;
286
- # 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
+ )
287
230
  effective_ct = cancellation_token || invocation_context&.cancellation_token
288
231
 
289
- spawned = tasks.each_with_index.map do |task, i|
232
+ spawned = tasks.each_with_index.map do |task, index|
290
233
  group.spawn do
291
234
  task_config = task.fetch(:config, {})
292
235
 
293
- # Merge the shared cancellation token unless the task already has one.
294
236
  if effective_ct && !task_config[:cancellation_token]
295
237
  task_config = task_config.merge(cancellation_token: effective_ct)
296
238
  end
297
239
 
298
- # Propagate parent InvocationContext to each child task so that
299
- # cancellation, deadline, and tracing carry through automatically.
300
240
  if invocation_context && !task_config[:invocation_context]
301
- 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
+ )
302
244
  task_config = task_config.merge(invocation_context: child_ic)
303
245
  end
304
246
 
305
- 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(
306
255
  task[:input],
307
256
  config: task_config,
308
257
  thread_id: task[:thread_id] || invocation_context&.thread_id
309
258
  ).wait_result
310
- rescue => e
311
- errors[i] = e unless on_error == :skip
259
+ rescue => error
260
+ errors[index] = error unless on_error == :skip
312
261
  end
313
262
  end
314
263
 
315
264
  if timeout
316
265
  deadline = Phronomy::Concurrency::Deadline.in(timeout)
317
- spawned.each { |t| t.join([deadline.remaining_seconds, 0].max) }
266
+ spawned.each { |task| task.join([deadline.remaining_seconds, 0].max) }
318
267
 
319
268
  alive = spawned.select(&:alive?)
320
269
  unless alive.empty?
@@ -31,16 +31,18 @@ module Phronomy
31
31
  return super if tool_calls.size <= 1
32
32
 
33
33
  if @on[:tool_call_batch]
34
- tool_calls.each { @on[:new_message]&.call }
34
+ tool_calls.each { run_callbacks(:before_message, :new_message) }
35
35
  @on[:tool_call_batch].call(tool_calls)
36
36
  return
37
37
  end
38
38
 
39
39
  # Direct ParallelToolChat fallback. Agent execution never reaches this
40
40
  # branch because AgentInvocation installs the batch interceptor first.
41
+ # RubyLLM >= 1.15 additive callbacks are dispatched together with their
42
+ # legacy equivalents, matching RubyLLM::Chat semantics.
41
43
  tool_calls.each do |tool_call|
42
- @on[:new_message]&.call
43
- @on[:tool_call]&.call(tool_call)
44
+ run_callbacks(:before_message, :new_message)
45
+ run_callbacks(:before_tool_call, :tool_call, tool_call)
44
46
  end
45
47
 
46
48
  cancellation_token = @cancellation_token
@@ -78,7 +80,7 @@ module Phronomy
78
80
  halt_result = nil
79
81
  tool_results.each do |item|
80
82
  result = item[:result]
81
- @on[:tool_result]&.call(result)
83
+ run_callbacks(:after_tool_result, :tool_result, result)
82
84
  tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
83
85
  content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
84
86
  message = add_message(
@@ -86,7 +88,7 @@ module Phronomy
86
88
  content: content,
87
89
  tool_call_id: item[:tool_call].id
88
90
  )
89
- @on[:end_message]&.call(message)
91
+ run_callbacks(:after_message, :end_message, message)
90
92
  halt_result = result if result.is_a?(RubyLLM::Tool::Halt)
91
93
  end
92
94