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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +151 -1134
- data/CONTRIBUTING.md +28 -16
- data/README.md +69 -1249
- 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 +26 -6
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
- 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/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +170 -333
- data/lib/phronomy/agent/context/capability/base.rb +177 -298
- 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 +6 -7
- 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 +48 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +180 -336
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +4 -171
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +89 -190
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +49 -377
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
- data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -121
- data/scripts/api_snapshot.rb +4 -15
- metadata +24 -38
- 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/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -265
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- 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
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- 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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
-
|
|
171
|
+
inherit_knowledge: inherit_knowledge
|
|
194
172
|
)
|
|
195
173
|
end
|
|
196
174
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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:
|
|
220
|
-
thread_id: 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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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 =
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|