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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +54 -0
- data/CONTRIBUTING.md +28 -16
- data/README.md +124 -92
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +1 -1
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/base.rb +135 -314
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +0 -1
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +15 -158
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
- data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +3 -120
- data/scripts/api_snapshot.rb +1 -12
- metadata +3 -9
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- 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
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
@
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
160
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
119
|
+
inherit_knowledge: inherit_knowledge
|
|
194
120
|
)
|
|
195
121
|
end
|
|
196
122
|
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
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(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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:
|
|
220
|
-
thread_id: 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
|
-
#
|
|
227
|
-
#
|
|
228
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 =
|
|
174
|
+
self._orchestrator_context = captured_context
|
|
258
175
|
super(args, **kwargs)
|
|
259
176
|
end
|
|
260
177
|
end
|
|
261
178
|
end
|
|
262
179
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
285
|
-
|
|
286
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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 =>
|
|
312
|
-
errors[
|
|
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 { |
|
|
266
|
+
spawned.each { |task| task.join([deadline.remaining_seconds, 0].max) }
|
|
319
267
|
|
|
320
268
|
alive = spawned.select(&:alive?)
|
|
321
269
|
unless alive.empty?
|