phronomy 0.9.0 → 0.10.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.
@@ -1,205 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # ReAct pattern (Reasoning + Acting) agent.
6
- # Repeats the LLM <-> Tool loop until no more tool calls are made.
7
- class ReactAgent < Base
8
- private
9
-
10
- # Performs a single (non-retried) ReAct invocation.
11
- # Overrides Base#invoke_once so that Base#invoke's retry loop is inherited.
12
- def invoke_once(input, messages: [], thread_id: nil, config: {})
13
- caller_meta = {}
14
- caller_meta[:user_id] = config[:user_id] if config[:user_id]
15
- caller_meta[:session_id] = config[:session_id] if config[:session_id]
16
- if (ic = config[:invocation_context])
17
- caller_meta[:task_id] = ic.task_id if ic.task_id
18
- caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
19
- end
20
-
21
- trace("agent.invoke", input: input, **caller_meta) do |_span|
22
- # Run input guardrails before any LLM interaction.
23
- run_input_guardrails!(input)
24
-
25
- max_iter = self.class.max_iterations
26
-
27
- # Seed with app-managed conversation history when provided.
28
- messages = Array(messages).dup
29
- user_asked = false
30
- total_usage = Phronomy::TokenUsage.zero
31
- iterations_exhausted = true
32
-
33
- max_iter.times do
34
- response = step(messages, input, user_asked: user_asked, thread_id: thread_id, config: config)
35
- user_asked = true
36
- messages = response[:messages]
37
- total_usage += response[:usage]
38
- if response[:done]
39
- iterations_exhausted = false
40
- break
41
- end
42
- end
43
-
44
- # Select the last assistant-produced content as the output, skipping
45
- # raw tool result messages (role: :tool) to avoid returning tool JSON
46
- # or status strings as the agent's answer when iterations are exhausted.
47
- output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
48
-
49
- # Run output guardrails before returning to the caller.
50
- run_output_guardrails!(output)
51
-
52
- result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
53
- [result, total_usage]
54
- end
55
- end
56
-
57
- public
58
-
59
- # Streaming version of #invoke for the ReAct loop.
60
- # Yields {Phronomy::Agent::StreamEvent} events while the LLM-tool loop runs.
61
- #
62
- # @param input [String, Hash]
63
- # @param messages [Array<RubyLLM::Message>] same as #invoke
64
- # @param thread_id [String, nil] same as #invoke
65
- # @param config [Hash]
66
- # @yield [Phronomy::Agent::StreamEvent]
67
- # @return [Hash] { output:, messages:, usage: }
68
- # @api public
69
- def stream(input, messages: [], thread_id: nil, config: {}, &block)
70
- return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
71
-
72
- caller_meta = {}
73
- caller_meta[:user_id] = config[:user_id] if config[:user_id]
74
- caller_meta[:session_id] = config[:session_id] if config[:session_id]
75
- if (ic = config[:invocation_context])
76
- caller_meta[:task_id] = ic.task_id if ic.task_id
77
- caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
78
- end
79
-
80
- trace("agent.invoke", input: input, **caller_meta) do |_span|
81
- run_input_guardrails!(input)
82
-
83
- max_iter = self.class.max_iterations
84
-
85
- messages = Array(messages).dup
86
- user_asked = false
87
- total_usage = Phronomy::TokenUsage.zero
88
- iterations_exhausted = true
89
-
90
- max_iter.times do
91
- response = stream_step(messages, input, user_asked: user_asked, thread_id: thread_id, config: config, &block)
92
- user_asked = true
93
- messages = response[:messages]
94
- total_usage += response[:usage]
95
- if response[:done]
96
- iterations_exhausted = false
97
- break
98
- end
99
- end
100
-
101
- # Select the last assistant-produced content as the output, skipping
102
- # raw tool result messages (role: :tool) — same as the non-streaming path.
103
- output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
104
- run_output_guardrails!(output)
105
-
106
- result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
107
- block.call(StreamEvent.new(type: :done, payload: result))
108
- [result, total_usage]
109
- end
110
- rescue => e
111
- block&.call(StreamEvent.new(type: :error, payload: {error: e}))
112
- raise
113
- end
114
-
115
- private
116
-
117
- def step(messages, initial_input, user_asked: false, thread_id: nil, config: {})
118
- chat = build_chat
119
-
120
- context = build_context(
121
- initial_input,
122
- messages: messages,
123
- thread_id: thread_id,
124
- config: config,
125
- budget: build_token_budget,
126
- instruction: build_instructions(initial_input),
127
- tools: self.class.tools + _handoff_tools
128
- )
129
- apply_instructions(chat, context[:system]) if context[:system]
130
- (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
131
- context[:messages].each { |m| chat.add_message(m) }
132
-
133
- # Run before_completion hooks before each LLM call in the ReAct loop.
134
- run_before_completion_hooks!(chat, config)
135
-
136
- # Route the LLM call through the configured LLMAdapter so that the
137
- # blocking HTTP request runs inside BlockingAdapterPool and the
138
- # adapter can be swapped without changing agent code.
139
- # Passing nil as message signals the adapter to call chat.complete
140
- # (no new user turn) for continuation iterations.
141
- adapter = Phronomy.configuration.llm_adapter
142
- message = user_asked ? nil : extract_message(initial_input)
143
- response = adapter.complete_async(chat, message, config: config).await
144
-
145
- usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
146
- tool_calls = chat.messages.last&.tool_calls
147
- done = tool_calls.nil? || tool_calls.empty?
148
- {messages: chat.messages, done: done, usage: usage}
149
- end
150
-
151
- # Streaming variant of #step. Yields :token / :tool_call / :tool_result events
152
- # via the block while the LLM call is in progress.
153
- def stream_step(messages, initial_input, user_asked: false, thread_id: nil, config: {}, &block)
154
- chat = build_chat
155
-
156
- context = build_context(
157
- initial_input,
158
- messages: messages,
159
- thread_id: thread_id,
160
- config: config,
161
- budget: build_token_budget,
162
- instruction: build_instructions(initial_input),
163
- tools: self.class.tools + _handoff_tools
164
- )
165
- apply_instructions(chat, context[:system]) if context[:system]
166
- (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
167
- context[:messages].each { |m| chat.add_message(m) }
168
-
169
- current_tool_call = nil
170
- chat.on_tool_call do |tc|
171
- current_tool_call = tc
172
- block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tc}))
173
- end
174
- chat.on_tool_result do |tr|
175
- block.call(StreamEvent.new(type: :tool_result, payload: {
176
- tool_call_id: current_tool_call&.id,
177
- tool_name: current_tool_call&.name,
178
- tool_result: tr
179
- }))
180
- end
181
-
182
- # Run before_completion hooks before each LLM call in the streaming loop.
183
- run_before_completion_hooks!(chat, config)
184
-
185
- # Route the streaming LLM call through the configured LLMAdapter so that
186
- # the blocking HTTP request runs inside BlockingAdapterPool.
187
- # Passing nil as message signals the adapter to call chat.complete
188
- # (no new user turn) for continuation iterations.
189
- # Streaming chunks and tool event callbacks are delivered directly via the
190
- # block on the pool worker thread; pending.await yields cooperatively until
191
- # streaming is complete.
192
- adapter = Phronomy.configuration.llm_adapter
193
- message = user_asked ? nil : extract_message(initial_input)
194
- streaming_block = proc { |chunk| block.call(StreamEvent.new(type: :token, payload: {content: chunk.content})) }
195
- pending = adapter.stream_async(chat, message, config: config, &streaming_block)
196
- response = pending.await
197
-
198
- usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
199
- tool_calls = chat.messages.last&.tool_calls
200
- done = tool_calls.nil? || tool_calls.empty?
201
- {messages: chat.messages, done: done, usage: usage}
202
- end
203
- end
204
- end
205
- end