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.
@@ -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(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|