phronomy 0.3.0 → 0.5.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/CHANGELOG.md +83 -0
- data/README.md +85 -16
- data/lib/phronomy/agent/base.rb +117 -382
- data/lib/phronomy/agent/checkpoint.rb +12 -5
- data/lib/phronomy/agent/concerns/before_completion.rb +105 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +42 -0
- data/lib/phronomy/agent/concerns/retryable.rb +88 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +116 -0
- data/lib/phronomy/agent/orchestrator.rb +119 -0
- data/lib/phronomy/agent/react_agent.rb +37 -16
- data/lib/phronomy/agent/shared_state.rb +303 -0
- data/lib/phronomy/agent/team_coordinator.rb +285 -0
- data/lib/phronomy/{trust_pipeline.rb → generator_verifier.rb} +95 -108
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +41 -22
- data/lib/phronomy.rb +17 -0
- metadata +12 -6
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Concerns
|
|
6
|
+
# Adds before_completion hook support to an agent.
|
|
7
|
+
#
|
|
8
|
+
# Included in {Phronomy::Agent::Base}. Hooks are executed just before every
|
|
9
|
+
# LLM call (global → class → instance order) and may inject or override
|
|
10
|
+
# LLM parameters such as temperature or model.
|
|
11
|
+
module BeforeCompletion
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend(ClassMethods)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Class-level DSL methods mixed into the including agent class.
|
|
17
|
+
module ClassMethods
|
|
18
|
+
# Sets or reads the class-level before_completion hook.
|
|
19
|
+
# The hook is called before every LLM request for instances of this class.
|
|
20
|
+
# Receives a {Phronomy::Agent::BeforeCompletionContext}; must return a Hash
|
|
21
|
+
# of params to merge into the LLM call, or nil to pass through unchanged.
|
|
22
|
+
#
|
|
23
|
+
# @param callable [#call, nil] lambda/proc to register, or nil to clear
|
|
24
|
+
# @return [#call, nil]
|
|
25
|
+
# @example
|
|
26
|
+
# class MyAgent < Phronomy::Agent::Base
|
|
27
|
+
# before_completion ->(ctx) { { temperature: 0.2 } }
|
|
28
|
+
# end
|
|
29
|
+
def before_completion(callable = nil)
|
|
30
|
+
if callable.nil? && !block_given?
|
|
31
|
+
@before_completion
|
|
32
|
+
else
|
|
33
|
+
@before_completion = callable
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [#call, nil]
|
|
38
|
+
def _before_completion
|
|
39
|
+
@before_completion
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Instance-level before_completion hook. When set, takes precedence over
|
|
44
|
+
# the class-level hook for this specific agent instance only.
|
|
45
|
+
# @return [#call, nil]
|
|
46
|
+
attr_accessor :before_completion
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Collects and runs all registered before_completion hooks in order
|
|
51
|
+
# (global → class → instance) and applies the merged params to the chat.
|
|
52
|
+
#
|
|
53
|
+
# @param chat [RubyLLM::Chat] the assembled chat object
|
|
54
|
+
# @param config [Hash] the invocation config hash
|
|
55
|
+
# @return [Hash] the merged params applied to the chat
|
|
56
|
+
def run_before_completion_hooks!(chat, config)
|
|
57
|
+
hooks = [
|
|
58
|
+
Phronomy.configuration.before_completion,
|
|
59
|
+
self.class._before_completion,
|
|
60
|
+
@before_completion
|
|
61
|
+
].compact
|
|
62
|
+
|
|
63
|
+
return {} if hooks.empty?
|
|
64
|
+
|
|
65
|
+
ctx = BeforeCompletionContext.new(
|
|
66
|
+
agent: self,
|
|
67
|
+
messages: chat.messages,
|
|
68
|
+
config: config,
|
|
69
|
+
params: {}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
merged = {}
|
|
73
|
+
hooks.each do |hook|
|
|
74
|
+
result = hook.call(ctx)
|
|
75
|
+
merged.merge!(result) if result.is_a?(Hash)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
apply_before_completion_params!(chat, merged)
|
|
79
|
+
merged
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Applies a merged param hash returned by before_completion hooks to
|
|
83
|
+
# the chat object using the appropriate RubyLLM::Chat API methods.
|
|
84
|
+
# When overriding the model, reuses the agent's configured provider and
|
|
85
|
+
# assume_exists setting so that local/namespaced models continue to work.
|
|
86
|
+
#
|
|
87
|
+
# @param chat [RubyLLM::Chat]
|
|
88
|
+
# @param params [Hash]
|
|
89
|
+
def apply_before_completion_params!(chat, params)
|
|
90
|
+
params.each do |key, value|
|
|
91
|
+
case key
|
|
92
|
+
when :model
|
|
93
|
+
prov = self.class.provider
|
|
94
|
+
chat.with_model(value, provider: prov, assume_exists: !prov.nil?)
|
|
95
|
+
when :temperature
|
|
96
|
+
chat.with_temperature(value)
|
|
97
|
+
else
|
|
98
|
+
chat.with_params(key => value)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Concerns
|
|
6
|
+
# Adds input and output guardrail support to an agent.
|
|
7
|
+
#
|
|
8
|
+
# Included in {Phronomy::Agent::Base}. Guardrails are run on the raw
|
|
9
|
+
# input string before the LLM is called, and on the raw output string
|
|
10
|
+
# before the result is returned to the caller.
|
|
11
|
+
module Guardrailable
|
|
12
|
+
# Attach a guardrail that validates input before every #invoke call.
|
|
13
|
+
# @param guardrail [Phronomy::Guardrail::InputGuardrail]
|
|
14
|
+
# @return [self]
|
|
15
|
+
def add_input_guardrail(guardrail)
|
|
16
|
+
@input_guardrails ||= []
|
|
17
|
+
@input_guardrails << guardrail
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Attach a guardrail that validates output before it is returned.
|
|
22
|
+
# @param guardrail [Phronomy::Guardrail::OutputGuardrail]
|
|
23
|
+
# @return [self]
|
|
24
|
+
def add_output_guardrail(guardrail)
|
|
25
|
+
@output_guardrails ||= []
|
|
26
|
+
@output_guardrails << guardrail
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def run_input_guardrails!(input)
|
|
33
|
+
(@input_guardrails || []).each { |g| g.run!(input) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_output_guardrails!(output)
|
|
37
|
+
(@output_guardrails || []).each { |g| g.run!(output) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Concerns
|
|
6
|
+
# Adds configurable retry behaviour to an agent.
|
|
7
|
+
#
|
|
8
|
+
# Included in {Phronomy::Agent::Base}. The retry loop wraps the full
|
|
9
|
+
# #invoke_once call; {Phronomy::GuardrailError} is never retried.
|
|
10
|
+
module Retryable
|
|
11
|
+
def self.included(base)
|
|
12
|
+
base.extend(ClassMethods)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Class-level DSL methods mixed into the including agent class.
|
|
16
|
+
module ClassMethods
|
|
17
|
+
# Configures a retry policy that wraps the full #invoke call.
|
|
18
|
+
# GuardrailError is never retried regardless of this setting.
|
|
19
|
+
#
|
|
20
|
+
# @param times [Integer] maximum retry attempts (default: 0)
|
|
21
|
+
# @param wait [Symbol, Numeric] :exponential, :linear, or a fixed Float
|
|
22
|
+
# @param base [Float] base wait time in seconds (default: 1.0)
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# class MyAgent < Phronomy::Agent::Base
|
|
26
|
+
# retry_policy times: 2, wait: :exponential, base: 1.0
|
|
27
|
+
# end
|
|
28
|
+
def retry_policy(times: 0, wait: 0, base: 1.0)
|
|
29
|
+
@_retry_policy = {times: times, wait: wait, base: base}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the configured retry policy, or nil when none is set.
|
|
33
|
+
# @return [Hash, nil]
|
|
34
|
+
attr_reader :_retry_policy
|
|
35
|
+
|
|
36
|
+
# Injectable sleep callable for testing (shared with Tool::Base pattern).
|
|
37
|
+
# @return [#call]
|
|
38
|
+
def _sleep_proc
|
|
39
|
+
@_sleep_proc || method(:sleep)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Overrides the sleep callable used between retries.
|
|
43
|
+
# @param proc [#call]
|
|
44
|
+
attr_writer :_sleep_proc
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
|
|
50
|
+
def _invoke_impl(input, messages: [], thread_id: nil, config: {})
|
|
51
|
+
policy = self.class._retry_policy
|
|
52
|
+
attempt = 0
|
|
53
|
+
begin
|
|
54
|
+
invoke_once(input, messages: messages, thread_id: thread_id, config: config)
|
|
55
|
+
rescue Phronomy::GuardrailError
|
|
56
|
+
raise
|
|
57
|
+
rescue
|
|
58
|
+
if policy && attempt < policy[:times]
|
|
59
|
+
wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
|
|
60
|
+
self.class._sleep_proc.call(wait) if wait > 0
|
|
61
|
+
attempt += 1
|
|
62
|
+
retry
|
|
63
|
+
end
|
|
64
|
+
raise
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Computes the agent-level retry wait duration.
|
|
69
|
+
# @param strategy [Symbol, Numeric]
|
|
70
|
+
# @param base [Float]
|
|
71
|
+
# @param attempt [Integer]
|
|
72
|
+
# @return [Float]
|
|
73
|
+
def compute_agent_retry_wait(strategy, base, attempt)
|
|
74
|
+
case strategy
|
|
75
|
+
when :exponential
|
|
76
|
+
(2**attempt) * base
|
|
77
|
+
when :linear
|
|
78
|
+
(attempt + 1) * base
|
|
79
|
+
when Numeric
|
|
80
|
+
strategy.to_f
|
|
81
|
+
else
|
|
82
|
+
base.to_f
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Concerns
|
|
6
|
+
# Adds suspend/resume and tool-approval support to an agent.
|
|
7
|
+
#
|
|
8
|
+
# Included in {Phronomy::Agent::Base}. When a tool decorated with
|
|
9
|
+
# +requires_approval true+ is called and no synchronous approval handler
|
|
10
|
+
# has been registered, the invocation is suspended and a
|
|
11
|
+
# {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
|
|
12
|
+
module Suspendable
|
|
13
|
+
# Registers a callback that is invoked before executing any tool that has
|
|
14
|
+
# +requires_approval true+ set. The block receives the tool name (String)
|
|
15
|
+
# and the arguments Hash, and must return a truthy value to allow execution.
|
|
16
|
+
# Returning a falsy value causes the tool to return a denial message instead
|
|
17
|
+
# of executing.
|
|
18
|
+
#
|
|
19
|
+
# When no handler is registered and a tool with +requires_approval+ is
|
|
20
|
+
# called, #invoke returns a suspended result hash containing a
|
|
21
|
+
# {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
|
|
22
|
+
# obtaining an approval decision from the user or an external system.
|
|
23
|
+
#
|
|
24
|
+
# @example Synchronous handler
|
|
25
|
+
# agent = MyAgent.new
|
|
26
|
+
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
27
|
+
# @return [self]
|
|
28
|
+
def on_approval_required(&block)
|
|
29
|
+
@approval_handler = block
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
|
|
34
|
+
#
|
|
35
|
+
# This method reconstructs the conversation state captured at suspension
|
|
36
|
+
# time, injects the tool result (executed or denied), and continues the
|
|
37
|
+
# LLM loop until it produces a final answer.
|
|
38
|
+
#
|
|
39
|
+
# @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
|
|
40
|
+
# the suspended #invoke call
|
|
41
|
+
# @param approved [Boolean] +true+ to execute the pending tool; +false+
|
|
42
|
+
# to inject a denial message and let the LLM handle it gracefully
|
|
43
|
+
# @param config [Hash] same runtime options as #invoke
|
|
44
|
+
# @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
|
|
45
|
+
# @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
|
|
46
|
+
def resume(checkpoint, approved:, config: {})
|
|
47
|
+
# Build a fresh chat with all tools registered.
|
|
48
|
+
chat = build_chat
|
|
49
|
+
|
|
50
|
+
# Re-apply system instructions so the LLM has the same persona/context
|
|
51
|
+
# as the original invocation. build_cached_system_text is memoised, so
|
|
52
|
+
# a Proc- or PromptTemplate-based instructions block is re-evaluated
|
|
53
|
+
# against the original input rather than using a stale cached value.
|
|
54
|
+
system_text = build_cached_system_text(checkpoint.original_input)
|
|
55
|
+
apply_instructions(chat, system_text) if system_text
|
|
56
|
+
|
|
57
|
+
# Restore the full conversation (history + user + assistant with tool call).
|
|
58
|
+
checkpoint.messages.each { |msg| chat.messages << msg }
|
|
59
|
+
|
|
60
|
+
# Determine the tool result: execute it or inject a denial string.
|
|
61
|
+
tool_result =
|
|
62
|
+
if approved
|
|
63
|
+
tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
|
|
64
|
+
tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
|
|
65
|
+
else
|
|
66
|
+
"Tool execution denied."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Inject the tool result so the LLM can continue.
|
|
70
|
+
chat.add_message(
|
|
71
|
+
role: :tool,
|
|
72
|
+
content: tool_result.to_s,
|
|
73
|
+
tool_call_id: checkpoint.pending_tool_call_id
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Continue the React loop.
|
|
77
|
+
response = chat.complete
|
|
78
|
+
|
|
79
|
+
output = response.content
|
|
80
|
+
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
81
|
+
|
|
82
|
+
run_output_guardrails!(output)
|
|
83
|
+
|
|
84
|
+
{output: output, suspended: false, messages: chat.messages, usage: usage}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Registers an on_tool_call hook on the chat object that raises SuspendSignal
|
|
90
|
+
# when an approval-required tool is about to be executed and no synchronous
|
|
91
|
+
# on_approval_required handler has been registered.
|
|
92
|
+
#
|
|
93
|
+
# Does nothing when:
|
|
94
|
+
# - a synchronous handler is already registered (@approval_handler is set), or
|
|
95
|
+
# - none of the agent's tools have requires_approval set.
|
|
96
|
+
#
|
|
97
|
+
# @param chat [RubyLLM::Chat]
|
|
98
|
+
def _register_suspension_hook!(chat)
|
|
99
|
+
return if @approval_handler
|
|
100
|
+
return if self.class.tools.none? { |tc| tc.requires_approval }
|
|
101
|
+
|
|
102
|
+
chat.on_tool_call do |tool_call|
|
|
103
|
+
tool_instance = chat.tools[tool_call.name.to_sym]
|
|
104
|
+
if tool_instance&.requires_approval
|
|
105
|
+
raise SuspendSignal.new(
|
|
106
|
+
tool_name: tool_call.name,
|
|
107
|
+
args: tool_call.arguments,
|
|
108
|
+
tool_call_id: tool_call.id
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
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::Agent::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::Agent::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
|
+
class Orchestrator < 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
|
|
58
|
+
def self.subagent(name, agent_class, on_error: :raise)
|
|
59
|
+
tool_class = Class.new(Phronomy::Tool::Base) do
|
|
60
|
+
tool_name "dispatch_to_#{name}"
|
|
61
|
+
description "Dispatch work to the #{name} subagent (#{agent_class.name})"
|
|
62
|
+
param :input, type: :string, desc: "The task or question for the subagent"
|
|
63
|
+
|
|
64
|
+
define_method(:execute) do |input:|
|
|
65
|
+
result = agent_class.new.invoke(input)
|
|
66
|
+
result[:output]
|
|
67
|
+
rescue
|
|
68
|
+
raise if on_error == :raise
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Append without clobbering previously registered tools or aliases.
|
|
74
|
+
@tools = (@tools || []) + [tool_class]
|
|
75
|
+
@tool_aliases ||= {}
|
|
76
|
+
|
|
77
|
+
registered_subagents[name] = {agent_class: agent_class, on_error: on_error}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the subagent registry for this specific class (not inherited).
|
|
81
|
+
#
|
|
82
|
+
# @return [Hash{Symbol => Hash}]
|
|
83
|
+
def self.registered_subagents
|
|
84
|
+
@registered_subagents ||= {}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Dispatches multiple heterogeneous agent tasks in parallel using Ruby
|
|
88
|
+
# threads. Each task is a Hash describing one agent invocation.
|
|
89
|
+
#
|
|
90
|
+
# Results are returned in the same order as the input +tasks+ array.
|
|
91
|
+
# If any thread raises an exception, the exception is re-raised in the
|
|
92
|
+
# calling thread after all threads have completed (via +Thread#value+).
|
|
93
|
+
#
|
|
94
|
+
# @param tasks [Array<Hash>]
|
|
95
|
+
# @option task [Class] :agent agent class to invoke (required)
|
|
96
|
+
# @option task [String] :input input string for the agent (required)
|
|
97
|
+
# @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
|
|
98
|
+
# @return [Array<Hash>] agent results in the same order as +tasks+
|
|
99
|
+
def dispatch_parallel(*tasks)
|
|
100
|
+
threads = tasks.map do |task|
|
|
101
|
+
Thread.new do
|
|
102
|
+
task[:agent].new.invoke(task[:input], config: task.fetch(:config, {}))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
threads.map(&:value)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Runs the same agent against multiple inputs in parallel (fan-out pattern).
|
|
109
|
+
#
|
|
110
|
+
# @param agent [Class] agent class to invoke for every input
|
|
111
|
+
# @param inputs [Array<String>] list of input strings
|
|
112
|
+
# @param config [Hash] forwarded to every +agent#invoke+ call
|
|
113
|
+
# @return [Array<Hash>] results in the same order as +inputs+
|
|
114
|
+
def fan_out(agent:, inputs:, config: {})
|
|
115
|
+
dispatch_parallel(*inputs.map { |input| {agent: agent, input: input, config: config} })
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -9,7 +9,7 @@ module Phronomy
|
|
|
9
9
|
|
|
10
10
|
# Performs a single (non-retried) ReAct invocation.
|
|
11
11
|
# Overrides Base#invoke_once so that Base#invoke's retry loop is inherited.
|
|
12
|
-
def invoke_once(input, config: {})
|
|
12
|
+
def invoke_once(input, messages: [], thread_id: nil, config: {})
|
|
13
13
|
caller_meta = {}
|
|
14
14
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
15
15
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
@@ -18,17 +18,16 @@ module Phronomy
|
|
|
18
18
|
# Run input guardrails before any LLM interaction.
|
|
19
19
|
run_input_guardrails!(input)
|
|
20
20
|
|
|
21
|
-
config[:thread_id]
|
|
22
21
|
max_iter = self.class.max_iterations
|
|
23
22
|
|
|
24
23
|
# Seed with app-managed conversation history when provided.
|
|
25
|
-
messages = Array(
|
|
24
|
+
messages = Array(messages).dup
|
|
26
25
|
user_asked = false
|
|
27
26
|
total_usage = Phronomy::TokenUsage.zero
|
|
28
27
|
iterations_exhausted = true
|
|
29
28
|
|
|
30
29
|
max_iter.times do
|
|
31
|
-
response = step(messages, input, user_asked: user_asked, config: config)
|
|
30
|
+
response = step(messages, input, user_asked: user_asked, thread_id: thread_id, config: config)
|
|
32
31
|
user_asked = true
|
|
33
32
|
messages = response[:messages]
|
|
34
33
|
total_usage += response[:usage]
|
|
@@ -55,12 +54,14 @@ module Phronomy
|
|
|
55
54
|
# Streaming version of #invoke for the ReAct loop.
|
|
56
55
|
# Yields {Phronomy::Agent::StreamEvent} events while the LLM-tool loop runs.
|
|
57
56
|
#
|
|
58
|
-
# @param input
|
|
59
|
-
# @param
|
|
57
|
+
# @param input [String, Hash]
|
|
58
|
+
# @param messages [Array<RubyLLM::Message>] same as #invoke
|
|
59
|
+
# @param thread_id [String, nil] same as #invoke
|
|
60
|
+
# @param config [Hash]
|
|
60
61
|
# @yield [Phronomy::Agent::StreamEvent]
|
|
61
62
|
# @return [Hash] { output:, messages:, usage: }
|
|
62
|
-
def stream(input, config: {}, &block)
|
|
63
|
-
return invoke(input, config: config) unless block
|
|
63
|
+
def stream(input, messages: [], thread_id: nil, config: {}, &block)
|
|
64
|
+
return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
|
|
64
65
|
|
|
65
66
|
caller_meta = {}
|
|
66
67
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
@@ -69,16 +70,15 @@ module Phronomy
|
|
|
69
70
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
70
71
|
run_input_guardrails!(input)
|
|
71
72
|
|
|
72
|
-
config[:thread_id]
|
|
73
73
|
max_iter = self.class.max_iterations
|
|
74
74
|
|
|
75
|
-
messages = Array(
|
|
75
|
+
messages = Array(messages).dup
|
|
76
76
|
user_asked = false
|
|
77
77
|
total_usage = Phronomy::TokenUsage.zero
|
|
78
78
|
iterations_exhausted = true
|
|
79
79
|
|
|
80
80
|
max_iter.times do
|
|
81
|
-
response = stream_step(messages, input, user_asked: user_asked, config: config, &block)
|
|
81
|
+
response = stream_step(messages, input, user_asked: user_asked, thread_id: thread_id, config: config, &block)
|
|
82
82
|
user_asked = true
|
|
83
83
|
messages = response[:messages]
|
|
84
84
|
total_usage += response[:usage]
|
|
@@ -104,11 +104,23 @@ module Phronomy
|
|
|
104
104
|
|
|
105
105
|
private
|
|
106
106
|
|
|
107
|
-
def step(messages, initial_input, user_asked: false, config: {})
|
|
107
|
+
def step(messages, initial_input, user_asked: false, thread_id: nil, config: {})
|
|
108
108
|
chat = build_chat
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
if user_asked
|
|
111
|
+
# Subsequent loop iteration — messages already contains the full conversation
|
|
112
|
+
# (including the user's original input from the first step); apply system
|
|
113
|
+
# instructions and replay the accumulated history, then let the LLM continue.
|
|
114
|
+
system_text = build_cached_system_text(initial_input)
|
|
115
|
+
apply_instructions(chat, system_text) if system_text
|
|
116
|
+
messages.each { |m| chat.add_message(m) }
|
|
117
|
+
else
|
|
118
|
+
# First iteration — assemble context (system + history) via build_context so
|
|
119
|
+
# that trimming, compaction, and knowledge sources are applied consistently.
|
|
120
|
+
context = build_context(initial_input, messages: messages, thread_id: thread_id, config: config)
|
|
121
|
+
apply_instructions(chat, context[:system]) if context[:system]
|
|
122
|
+
context[:messages].each { |m| chat.messages << m }
|
|
123
|
+
end
|
|
112
124
|
|
|
113
125
|
# Run before_completion hooks before each LLM call in the ReAct loop.
|
|
114
126
|
run_before_completion_hooks!(chat, config)
|
|
@@ -130,9 +142,18 @@ module Phronomy
|
|
|
130
142
|
|
|
131
143
|
# Streaming variant of #step. Yields :token / :tool_call / :tool_result events
|
|
132
144
|
# via the block while the LLM call is in progress.
|
|
133
|
-
def stream_step(messages, initial_input, user_asked: false, config: {}, &block)
|
|
145
|
+
def stream_step(messages, initial_input, user_asked: false, thread_id: nil, config: {}, &block)
|
|
134
146
|
chat = build_chat
|
|
135
|
-
|
|
147
|
+
|
|
148
|
+
if user_asked
|
|
149
|
+
system_text = build_cached_system_text(initial_input)
|
|
150
|
+
apply_instructions(chat, system_text) if system_text
|
|
151
|
+
messages.each { |m| chat.add_message(m) }
|
|
152
|
+
else
|
|
153
|
+
context = build_context(initial_input, messages: messages, thread_id: thread_id, config: config)
|
|
154
|
+
apply_instructions(chat, context[:system]) if context[:system]
|
|
155
|
+
context[:messages].each { |m| chat.messages << m }
|
|
156
|
+
end
|
|
136
157
|
|
|
137
158
|
current_tool_call = nil
|
|
138
159
|
chat.on_tool_call do |tc|
|