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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +159 -28
- data/CONTRIBUTING.md +28 -16
- data/README.md +400 -143
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +7 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +3 -19
- data/benchmark/bench_tool_schema.rb +2 -34
- 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 +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +44 -46
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
- data/lib/phronomy/agent/agent_root.rb +66 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +351 -514
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +357 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +134 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +60 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +10 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +17 -155
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +9 -115
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/api_snapshot.rb +1 -12
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -13
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
- 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/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,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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
159
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
119
|
+
inherit_knowledge: inherit_knowledge
|
|
193
120
|
)
|
|
194
121
|
end
|
|
195
122
|
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
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(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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:
|
|
219
|
-
thread_id: 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
|
-
#
|
|
226
|
-
#
|
|
227
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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 =
|
|
174
|
+
self._orchestrator_context = captured_context
|
|
257
175
|
super(args, **kwargs)
|
|
258
176
|
end
|
|
259
177
|
end
|
|
260
178
|
end
|
|
261
179
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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(
|
|
284
|
-
|
|
285
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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 =>
|
|
311
|
-
errors[
|
|
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 { |
|
|
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 {
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|