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.
@@ -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(config[:messages]).dup
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 [String, Hash]
59
- # @param config [Hash]
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(config[:messages]).dup
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
- # Inject any existing history (from previous loop iterations or loaded memory).
111
- messages.each { |m| chat.add_message(m) }
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
- messages.each { |m| chat.add_message(m) }
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 via +config[:messages]+ on every subsequent +invoke+
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 via +config[:messages]+.
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], config: {messages: worker.messages})
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
- module RubyLLM
7
- module Streaming
8
- private
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
- # Upstream ruby_llm <= 1.15.0 assumes the SSE error chunk always has two
11
- # lines ("event: error\ndata: {...}") and uses a fixed index [1], which
12
- # raises NoMethodError when some providers (e.g. Qwen) return a single-line
13
- # chunk ("data: {...}"). This patch finds the data line by content instead.
14
- def handle_error_chunk(chunk, env)
15
- data_line = chunk.split("\n").find { |l| l.start_with?("data: ") } || chunk.split("\n")[0]
16
- error_data = data_line.delete_prefix("data: ")
17
- parse_error_from_json(error_data, env, "Failed to parse error chunk")
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
- build_tool_class(tool_name, tool_def, transport).new
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, tool_def, transport)
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(:@mcp_transport, transport)
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
- # Define #execute to forward the call to the MCP server.
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
- self.class.instance_variable_get(:@mcp_transport)
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: }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.1"
5
5
  end
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.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-19 00:00:00.000000000 Z
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