phronomy 0.4.0 → 0.5.1
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 +77 -0
- data/README.md +19 -15
- data/lib/phronomy/agent/base.rb +109 -379
- 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/react_agent.rb +37 -16
- data/lib/phronomy/agent/team_coordinator.rb +4 -4
- data/lib/phronomy/ruby_llm_patches.rb +15 -11
- data/lib/phronomy/tool/mcp_tool.rb +21 -7
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +0 -3
- metadata +6 -7
- data/lib/generators/phronomy/install/install_generator.rb +0 -41
- data/lib/generators/phronomy/install/templates/create_phronomy_messages.rb.tt +0 -15
- data/lib/generators/phronomy/install/templates/initializer.rb.tt +0 -18
- data/lib/generators/phronomy/install/templates/message_model.rb.tt +0 -8
- data/lib/phronomy/railtie.rb +0 -39
|
@@ -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
|
|
@@ -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|
|
|
@@ -16,8 +16,8 @@ module Phronomy
|
|
|
16
16
|
# - +finalize+ — signals that all tasks have been enqueued
|
|
17
17
|
#
|
|
18
18
|
# Worker persistence is implemented by passing each worker's accumulated
|
|
19
|
-
# +messages+ array back
|
|
20
|
-
# call, so the LLM retains context across multiple task assignments.
|
|
19
|
+
# +messages+ array back as a top-level +messages:+ argument on every subsequent
|
|
20
|
+
# +invoke+ call, so the LLM retains context across multiple task assignments.
|
|
21
21
|
#
|
|
22
22
|
# @example Basic usage
|
|
23
23
|
# class MigrationTeam < Phronomy::Agent::TeamCoordinator
|
|
@@ -38,7 +38,7 @@ module Phronomy
|
|
|
38
38
|
class TeamCoordinator
|
|
39
39
|
# Holds per-worker context between task invocations.
|
|
40
40
|
# Worker persistence is implemented by carrying +messages+ forward on each
|
|
41
|
-
# successive +agent#invoke+ call
|
|
41
|
+
# successive +agent#invoke+ call as the top-level +messages:+ argument..
|
|
42
42
|
WorkerState = Struct.new(
|
|
43
43
|
:index, # Integer — 0-based worker index
|
|
44
44
|
:agent, # Agent::Base instance
|
|
@@ -201,7 +201,7 @@ module Phronomy
|
|
|
201
201
|
worker = scheduler ? scheduler.call(available) : default_scheduler(available)
|
|
202
202
|
|
|
203
203
|
begin
|
|
204
|
-
result = worker.agent.invoke(task[:description],
|
|
204
|
+
result = worker.agent.invoke(task[:description], messages: worker.messages)
|
|
205
205
|
worker.messages = result[:messages]
|
|
206
206
|
worker.status = :available
|
|
207
207
|
entry = {task: task, result: result[:output], worker: worker.index, error: nil}
|
|
@@ -3,18 +3,22 @@
|
|
|
3
3
|
# Patches for upstream ruby_llm bugs that have not yet been released.
|
|
4
4
|
# Remove each patch once the fix is available in a published gem version.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
# Guard: apply monkey-patches only to affected ruby_llm versions so that
|
|
7
|
+
# upgrading the gem does not silently keep dead overrides in place.
|
|
8
|
+
if Gem::Version.new(RubyLLM::VERSION) <= Gem::Version.new("1.15.0")
|
|
9
|
+
module RubyLLM
|
|
10
|
+
module Streaming
|
|
11
|
+
private
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
# Upstream ruby_llm <= 1.15.0 assumes the SSE error chunk always has two
|
|
14
|
+
# lines ("event: error\ndata: {...}") and uses a fixed index [1], which
|
|
15
|
+
# raises NoMethodError when some providers (e.g. Qwen) return a single-line
|
|
16
|
+
# chunk ("data: {...}"). This patch finds the data line by content instead.
|
|
17
|
+
def handle_error_chunk(chunk, env)
|
|
18
|
+
data_line = chunk.split("\n").find { |l| l.start_with?("data: ") } || chunk.split("\n")[0]
|
|
19
|
+
error_data = data_line.delete_prefix("data: ")
|
|
20
|
+
parse_error_from_json(error_data, env, "Failed to parse error chunk")
|
|
21
|
+
end
|
|
18
22
|
end
|
|
19
23
|
end
|
|
20
24
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "net/http"
|
|
5
5
|
require "open3"
|
|
6
|
+
require "securerandom"
|
|
6
7
|
require "shellwords"
|
|
7
8
|
require "uri"
|
|
8
9
|
|
|
@@ -36,9 +37,13 @@ module Phronomy
|
|
|
36
37
|
# @param tool_name [String] the tool name as registered in the MCP server
|
|
37
38
|
# @return [McpTool] a configured subclass instance ready for use with an Agent
|
|
38
39
|
def from_server(server_uri, tool_name:)
|
|
40
|
+
# Use a short-lived transport only to query the tool definition,
|
|
41
|
+
# then close it. Each McpTool instance creates its own transport
|
|
42
|
+
# so that concurrent callers never share IO streams.
|
|
39
43
|
transport = build_transport(server_uri)
|
|
40
44
|
tool_def = transport.fetch_tool(tool_name)
|
|
41
|
-
|
|
45
|
+
transport.close
|
|
46
|
+
build_tool_class(tool_name, server_uri, tool_def).new
|
|
42
47
|
end
|
|
43
48
|
|
|
44
49
|
private
|
|
@@ -55,10 +60,10 @@ module Phronomy
|
|
|
55
60
|
end
|
|
56
61
|
end
|
|
57
62
|
|
|
58
|
-
def build_tool_class(tool_name,
|
|
63
|
+
def build_tool_class(tool_name, server_uri, tool_def)
|
|
59
64
|
klass = Class.new(McpTool)
|
|
60
65
|
klass.instance_variable_set(:@mcp_tool_name, tool_name)
|
|
61
|
-
klass.instance_variable_set(:@
|
|
66
|
+
klass.instance_variable_set(:@mcp_server_uri, server_uri)
|
|
62
67
|
|
|
63
68
|
# Register description and params from the MCP tool definition.
|
|
64
69
|
klass.description(tool_def[:description] || tool_name)
|
|
@@ -66,10 +71,15 @@ module Phronomy
|
|
|
66
71
|
klass.param(p[:name].to_sym, type: p[:type]&.to_sym || :string, desc: p[:description].to_s)
|
|
67
72
|
end
|
|
68
73
|
|
|
69
|
-
#
|
|
74
|
+
# Each instance creates its own transport so concurrent agent threads
|
|
75
|
+
# never share IO streams, eliminating the need for synchronisation.
|
|
76
|
+
klass.define_method(:initialize) do
|
|
77
|
+
uri = self.class.instance_variable_get(:@mcp_server_uri)
|
|
78
|
+
@mcp_transport = self.class.send(:build_transport, uri)
|
|
79
|
+
end
|
|
80
|
+
|
|
70
81
|
klass.define_method(:execute) do |**args|
|
|
71
|
-
|
|
72
|
-
.call_tool(tool_name, args)
|
|
82
|
+
@mcp_transport.call_tool(tool_name, args)
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
klass
|
|
@@ -108,7 +118,6 @@ module Phronomy
|
|
|
108
118
|
wait_thr = @wait_thr
|
|
109
119
|
@stderr_thread = nil
|
|
110
120
|
@wait_thr = nil
|
|
111
|
-
# Join outside the lock to avoid blocking on slow joins.
|
|
112
121
|
stderr_thread&.join(1)
|
|
113
122
|
wait_thr&.join(5)
|
|
114
123
|
end
|
|
@@ -208,6 +217,11 @@ module Phronomy
|
|
|
208
217
|
@read_timeout = read_timeout
|
|
209
218
|
end
|
|
210
219
|
|
|
220
|
+
# HTTP connections are stateless; close is a no-op, defined so that
|
|
221
|
+
# both transport classes share the same interface as StdioTransport.
|
|
222
|
+
def close
|
|
223
|
+
end
|
|
224
|
+
|
|
211
225
|
# Retrieve the tool definition from the server using MCP `tools/list`.
|
|
212
226
|
# @param tool_name [String]
|
|
213
227
|
# @return [Hash] { description:, parameters: }
|
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy.rb
CHANGED
|
@@ -5,7 +5,6 @@ require "ruby_llm"
|
|
|
5
5
|
require_relative "phronomy/ruby_llm_patches"
|
|
6
6
|
|
|
7
7
|
loader = Zeitwerk::Loader.for_gem
|
|
8
|
-
loader.ignore(File.expand_path("generators", __dir__))
|
|
9
8
|
# Teach Zeitwerk that "llm" maps to "LLM" so that file names such as
|
|
10
9
|
# ruby_llm_embeddings.rb resolve to RubyLLMEmbeddings (not RubyLlmEmbeddings).
|
|
11
10
|
loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
|
|
@@ -14,8 +13,6 @@ loader.setup
|
|
|
14
13
|
require_relative "phronomy/version"
|
|
15
14
|
require_relative "phronomy/token_usage"
|
|
16
15
|
|
|
17
|
-
require "phronomy/railtie" if defined?(Rails::Railtie)
|
|
18
|
-
|
|
19
16
|
module Phronomy
|
|
20
17
|
# Exception hierarchy
|
|
21
18
|
class Error < StandardError; end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phronomy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Raizo T.C.S
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ruby_llm
|
|
@@ -64,15 +64,15 @@ files:
|
|
|
64
64
|
- CHANGELOG.md
|
|
65
65
|
- README.md
|
|
66
66
|
- Rakefile
|
|
67
|
-
- lib/generators/phronomy/install/install_generator.rb
|
|
68
|
-
- lib/generators/phronomy/install/templates/create_phronomy_messages.rb.tt
|
|
69
|
-
- lib/generators/phronomy/install/templates/initializer.rb.tt
|
|
70
|
-
- lib/generators/phronomy/install/templates/message_model.rb.tt
|
|
71
67
|
- lib/phronomy.rb
|
|
72
68
|
- lib/phronomy/agent.rb
|
|
73
69
|
- lib/phronomy/agent/base.rb
|
|
74
70
|
- lib/phronomy/agent/before_completion_context.rb
|
|
75
71
|
- lib/phronomy/agent/checkpoint.rb
|
|
72
|
+
- lib/phronomy/agent/concerns/before_completion.rb
|
|
73
|
+
- lib/phronomy/agent/concerns/guardrailable.rb
|
|
74
|
+
- lib/phronomy/agent/concerns/retryable.rb
|
|
75
|
+
- lib/phronomy/agent/concerns/suspendable.rb
|
|
76
76
|
- lib/phronomy/agent/handoff.rb
|
|
77
77
|
- lib/phronomy/agent/orchestrator.rb
|
|
78
78
|
- lib/phronomy/agent/react_agent.rb
|
|
@@ -128,7 +128,6 @@ files:
|
|
|
128
128
|
- lib/phronomy/output_parser/json_parser.rb
|
|
129
129
|
- lib/phronomy/output_parser/structured_parser.rb
|
|
130
130
|
- lib/phronomy/prompt_template.rb
|
|
131
|
-
- lib/phronomy/railtie.rb
|
|
132
131
|
- lib/phronomy/ruby_llm_patches.rb
|
|
133
132
|
- lib/phronomy/runnable.rb
|
|
134
133
|
- lib/phronomy/splitter.rb
|