jrubyagents 0.2.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 +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +255 -0
- data/ROADMAP.md +56 -0
- data/Rakefile +6 -0
- data/examples/custom_tool.rb +24 -0
- data/examples/fibonacci.rb +7 -0
- data/examples/with_tools.rb +12 -0
- data/exe/rubyagents +6 -0
- data/lib/rubyagents/agent.rb +316 -0
- data/lib/rubyagents/callback.rb +12 -0
- data/lib/rubyagents/cli.rb +146 -0
- data/lib/rubyagents/code_agent.rb +85 -0
- data/lib/rubyagents/errors.rb +21 -0
- data/lib/rubyagents/mcp.rb +128 -0
- data/lib/rubyagents/memory.rb +240 -0
- data/lib/rubyagents/model.rb +99 -0
- data/lib/rubyagents/models/ruby_llm_adapter.rb +158 -0
- data/lib/rubyagents/prompt.rb +123 -0
- data/lib/rubyagents/sandbox.rb +142 -0
- data/lib/rubyagents/tool.rb +124 -0
- data/lib/rubyagents/tool_calling_agent.rb +85 -0
- data/lib/rubyagents/tools/file_read.rb +20 -0
- data/lib/rubyagents/tools/file_write.rb +22 -0
- data/lib/rubyagents/tools/list_gems.rb +15 -0
- data/lib/rubyagents/tools/user_input.rb +16 -0
- data/lib/rubyagents/tools/visit_webpage.rb +44 -0
- data/lib/rubyagents/tools/web_search.rb +43 -0
- data/lib/rubyagents/ui.rb +183 -0
- data/lib/rubyagents/version.rb +5 -0
- data/lib/rubyagents.rb +21 -0
- data/rubyagents.gemspec +44 -0
- metadata +220 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyagents
|
|
4
|
+
class Agent
|
|
5
|
+
attr_reader :name, :description, :model, :tools, :max_steps, :memory, :planning_interval, :step_callbacks, :callbacks
|
|
6
|
+
|
|
7
|
+
def initialize(model:, tools: [], agents: [], name: nil, description: nil,
|
|
8
|
+
max_steps: 10, planning_interval: nil, step_callbacks: [],
|
|
9
|
+
callbacks: [], final_answer_checks: [], prompt_templates: nil,
|
|
10
|
+
instructions: nil, output_type: nil)
|
|
11
|
+
@name = name
|
|
12
|
+
@description = description
|
|
13
|
+
@model = model.is_a?(String) ? Model.for(model) : model
|
|
14
|
+
@tools = build_tools(tools, agents)
|
|
15
|
+
@max_steps = max_steps
|
|
16
|
+
@planning_interval = planning_interval
|
|
17
|
+
@step_callbacks = step_callbacks
|
|
18
|
+
@callbacks = Array(callbacks)
|
|
19
|
+
@final_answer_checks = final_answer_checks
|
|
20
|
+
@prompt_templates = prompt_templates || PromptTemplates.new
|
|
21
|
+
@instructions = instructions
|
|
22
|
+
@output_type = output_type
|
|
23
|
+
@memory = nil
|
|
24
|
+
@interrupt_switch = false
|
|
25
|
+
@step_number = 0
|
|
26
|
+
@final_answer_value = nil
|
|
27
|
+
@tool_map = @tools.each_with_object({}) { |t, h| h[t.class.tool_name] = t }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run(task, reset: true, return_full_result: false, &on_stream)
|
|
31
|
+
@interrupt_switch = false
|
|
32
|
+
|
|
33
|
+
if reset || @memory.nil?
|
|
34
|
+
@memory = Memory.new(system_prompt: system_prompt, task: task)
|
|
35
|
+
else
|
|
36
|
+
memory.add_user_message(task)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
40
|
+
notify(:on_run_start, task: task)
|
|
41
|
+
|
|
42
|
+
(1..max_steps).each do |step_number|
|
|
43
|
+
raise InterruptError, "Agent interrupted" if @interrupt_switch
|
|
44
|
+
|
|
45
|
+
notify(:on_step_start, step_number: step_number)
|
|
46
|
+
maybe_plan(step_number)
|
|
47
|
+
|
|
48
|
+
# Call LLM with timing
|
|
49
|
+
llm_duration, response = timed { generate_response(memory.to_messages, &on_stream) }
|
|
50
|
+
|
|
51
|
+
# Parse response
|
|
52
|
+
thought, action = parse_response(response)
|
|
53
|
+
UI.thought(thought) if thought
|
|
54
|
+
|
|
55
|
+
# Check for final answer or execute
|
|
56
|
+
if action
|
|
57
|
+
result = run_action(thought, action, response, llm_duration)
|
|
58
|
+
if result
|
|
59
|
+
error_msg = validate_final_answer(result)
|
|
60
|
+
error_msg ||= validate_output_type(result)
|
|
61
|
+
if error_msg
|
|
62
|
+
memory.add_user_message(error_msg)
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
total_timing = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
66
|
+
built = build_result(result, total_timing, return_full_result)
|
|
67
|
+
notify(:on_run_end, result: built)
|
|
68
|
+
return built
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
UI.step_metrics(duration: llm_duration, token_usage: response.token_usage)
|
|
72
|
+
step = memory.add_step(thought: thought || response.content, code: nil, observation: nil,
|
|
73
|
+
duration: llm_duration, token_usage: response.token_usage)
|
|
74
|
+
notify_callbacks(step)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Max steps reached
|
|
79
|
+
UI.run_summary(
|
|
80
|
+
total_steps: memory.action_steps.size,
|
|
81
|
+
total_duration: memory.total_duration,
|
|
82
|
+
total_tokens: memory.total_tokens
|
|
83
|
+
)
|
|
84
|
+
UI.error("Max steps (#{max_steps}) reached without a final answer")
|
|
85
|
+
notify(:on_error, error: MaxStepsError.new("Max steps (#{max_steps}) reached"))
|
|
86
|
+
|
|
87
|
+
total_timing = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
88
|
+
output = memory.last_step&.observation
|
|
89
|
+
result = RunResult.new(
|
|
90
|
+
output: output,
|
|
91
|
+
state: "max_steps",
|
|
92
|
+
steps: memory.action_steps,
|
|
93
|
+
token_usage: memory.total_tokens,
|
|
94
|
+
timing: total_timing
|
|
95
|
+
)
|
|
96
|
+
notify(:on_run_end, result: return_full_result ? result : output)
|
|
97
|
+
return_full_result ? result : output
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# --- Step-by-step execution ---
|
|
101
|
+
|
|
102
|
+
def step(task_or_message = nil)
|
|
103
|
+
if @memory.nil?
|
|
104
|
+
raise ArgumentError, "task required on first call" unless task_or_message
|
|
105
|
+
@memory = Memory.new(system_prompt: system_prompt, task: task_or_message)
|
|
106
|
+
@step_number = 0
|
|
107
|
+
@interrupt_switch = false
|
|
108
|
+
@final_answer_value = nil
|
|
109
|
+
elsif task_or_message
|
|
110
|
+
memory.add_user_message(task_or_message)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@step_number += 1
|
|
114
|
+
raise MaxStepsError, "Max steps (#{max_steps}) reached" if @step_number > max_steps
|
|
115
|
+
raise InterruptError, "Agent interrupted" if @interrupt_switch
|
|
116
|
+
|
|
117
|
+
maybe_plan(@step_number)
|
|
118
|
+
|
|
119
|
+
llm_duration, response = timed { generate_response(memory.to_messages) }
|
|
120
|
+
thought, action = parse_response(response)
|
|
121
|
+
|
|
122
|
+
if action
|
|
123
|
+
result = run_action(thought, action, response, llm_duration)
|
|
124
|
+
if result
|
|
125
|
+
error_msg = validate_final_answer(result)
|
|
126
|
+
error_msg ||= validate_output_type(result)
|
|
127
|
+
if error_msg
|
|
128
|
+
memory.add_user_message(error_msg)
|
|
129
|
+
else
|
|
130
|
+
@final_answer_value = result
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
memory.add_step(thought: thought || response.content,
|
|
135
|
+
duration: llm_duration, token_usage: response.token_usage)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
memory.last_step
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def done? = !@final_answer_value.nil?
|
|
142
|
+
|
|
143
|
+
def final_answer_value = @final_answer_value
|
|
144
|
+
|
|
145
|
+
def reset!
|
|
146
|
+
@memory = nil
|
|
147
|
+
@step_number = 0
|
|
148
|
+
@final_answer_value = nil
|
|
149
|
+
@interrupt_switch = false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def interrupt
|
|
153
|
+
@interrupt_switch = true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def system_prompt
|
|
159
|
+
raise NotImplementedError
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def generate_response(messages, &on_stream)
|
|
163
|
+
spin = on_stream ? nil : UI.spinner("Thinking...")
|
|
164
|
+
spin&.start
|
|
165
|
+
response = @model.generate(messages, &on_stream)
|
|
166
|
+
spin&.stop
|
|
167
|
+
response
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_response(response)
|
|
171
|
+
raise NotImplementedError
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def execute(action)
|
|
175
|
+
raise NotImplementedError
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def run_action(thought, action, response, llm_duration)
|
|
179
|
+
raise NotImplementedError
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def maybe_plan(step_number)
|
|
183
|
+
return unless planning_interval
|
|
184
|
+
if step_number == 1
|
|
185
|
+
run_planning_step(initial: true)
|
|
186
|
+
elsif (step_number % planning_interval) == 1
|
|
187
|
+
run_planning_step(initial: false)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def run_planning_step(initial: true)
|
|
192
|
+
spin = UI.spinner("Planning...")
|
|
193
|
+
spin.start
|
|
194
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
195
|
+
|
|
196
|
+
planning_messages = memory.to_messages
|
|
197
|
+
if initial
|
|
198
|
+
plan_prompt = @prompt_templates.planning_initial || Prompt.initial_plan
|
|
199
|
+
planning_messages[0] = { role: "system", content: plan_prompt }
|
|
200
|
+
else
|
|
201
|
+
plan_prompt = @prompt_templates.planning_update || Prompt.update_plan(progress_summary: memory.progress_summary)
|
|
202
|
+
planning_messages[0] = { role: "system", content: plan_prompt }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
response = @model.generate(planning_messages)
|
|
206
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
207
|
+
spin.stop
|
|
208
|
+
|
|
209
|
+
UI.plan(response.content)
|
|
210
|
+
UI.step_metrics(duration: duration, token_usage: response.token_usage)
|
|
211
|
+
memory.add_plan(plan: response.content, duration: duration, token_usage: response.token_usage)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_final_answer(answer)
|
|
215
|
+
@final_answer_checks.each_with_index do |check, i|
|
|
216
|
+
unless check.call(answer, memory)
|
|
217
|
+
return "Final answer rejected by check ##{i + 1}. Please try a different answer."
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def validate_output_type(answer)
|
|
224
|
+
return nil unless @output_type
|
|
225
|
+
case @output_type
|
|
226
|
+
when Hash
|
|
227
|
+
require "json-schema"
|
|
228
|
+
errors = JSON::Validator.fully_validate(@output_type, answer)
|
|
229
|
+
errors.empty? ? nil : "Output validation failed: #{errors.join("; ")}"
|
|
230
|
+
when Proc
|
|
231
|
+
@output_type.call(answer) ? nil : "Output validation failed: custom validator returned falsy"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def build_tools(tool_classes, agents)
|
|
236
|
+
instances = [FinalAnswerTool.new]
|
|
237
|
+
|
|
238
|
+
tool_classes.each do |klass|
|
|
239
|
+
instances << (klass.is_a?(Tool) ? klass : klass.new)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
agents.each do |agent|
|
|
243
|
+
instances << ManagedAgentTool.for(agent)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
instances
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def build_result(output, timing, return_full_result)
|
|
250
|
+
UI.run_summary(
|
|
251
|
+
total_steps: memory.action_steps.size,
|
|
252
|
+
total_duration: memory.total_duration,
|
|
253
|
+
total_tokens: memory.total_tokens
|
|
254
|
+
)
|
|
255
|
+
UI.final_answer(output)
|
|
256
|
+
|
|
257
|
+
result = RunResult.new(
|
|
258
|
+
output: output,
|
|
259
|
+
state: "success",
|
|
260
|
+
steps: memory.action_steps,
|
|
261
|
+
token_usage: memory.total_tokens,
|
|
262
|
+
timing: timing
|
|
263
|
+
)
|
|
264
|
+
return_full_result ? result : output
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def notify_callbacks(step)
|
|
268
|
+
@step_callbacks.each { |cb| cb.call(step, agent: self) }
|
|
269
|
+
notify(:on_step_end, step: step)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def notify(event, **payload)
|
|
273
|
+
@callbacks.each do |cb|
|
|
274
|
+
cb.send(event, **payload, agent: self) if cb.respond_to?(event)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def format_output(result)
|
|
279
|
+
parts = []
|
|
280
|
+
parts << result[:output] unless result[:output].to_s.empty?
|
|
281
|
+
parts << result[:result].inspect unless result[:result].nil?
|
|
282
|
+
parts.join("\n")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def timed
|
|
286
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
287
|
+
result = yield
|
|
288
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
289
|
+
[duration, result]
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
class ManagedAgentTool < Tool
|
|
294
|
+
tool_name "call_agent"
|
|
295
|
+
description "Delegates a task to a managed agent"
|
|
296
|
+
input :task, type: :string, description: "Task to delegate"
|
|
297
|
+
output_type :string
|
|
298
|
+
|
|
299
|
+
# Factory that creates an anonymous subclass per agent instance
|
|
300
|
+
def self.for(agent)
|
|
301
|
+
klass = Class.new(self) do
|
|
302
|
+
tool_name(agent.name || "agent")
|
|
303
|
+
description(agent.description || "A managed agent")
|
|
304
|
+
end
|
|
305
|
+
klass.new(agent)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def initialize(agent)
|
|
309
|
+
@agent = agent
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def call(task:, **_kwargs)
|
|
313
|
+
@agent.run(task)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyagents
|
|
4
|
+
class Callback
|
|
5
|
+
def on_run_start(task:, agent:) = nil
|
|
6
|
+
def on_step_start(step_number:, agent:) = nil
|
|
7
|
+
def on_step_end(step:, agent:) = nil
|
|
8
|
+
def on_tool_call(tool_name:, arguments:, agent:) = nil
|
|
9
|
+
def on_error(error:, agent:) = nil
|
|
10
|
+
def on_run_end(result:, agent:) = nil
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Rubyagents
|
|
6
|
+
class CLI
|
|
7
|
+
TOOL_MAP = {
|
|
8
|
+
"web_search" => -> { require_relative "tools/web_search"; WebSearch },
|
|
9
|
+
"visit_webpage" => -> { require_relative "tools/visit_webpage"; VisitWebpage },
|
|
10
|
+
"user_input" => -> { require_relative "tools/user_input"; UserInput },
|
|
11
|
+
"file_read" => -> { require_relative "tools/file_read"; FileRead },
|
|
12
|
+
"file_write" => -> { require_relative "tools/file_write"; FileWrite }
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def self.run(argv = ARGV)
|
|
16
|
+
new(argv).run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(argv)
|
|
20
|
+
@argv = argv
|
|
21
|
+
@options = {
|
|
22
|
+
model: "openai/gpt-5.2",
|
|
23
|
+
tools: [],
|
|
24
|
+
mcp: [],
|
|
25
|
+
interactive: false,
|
|
26
|
+
max_steps: 10,
|
|
27
|
+
planning_interval: nil,
|
|
28
|
+
agent_type: "code"
|
|
29
|
+
}
|
|
30
|
+
parse_options!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run
|
|
34
|
+
if @options[:interactive]
|
|
35
|
+
interactive_mode
|
|
36
|
+
elsif @query
|
|
37
|
+
single_query(@query)
|
|
38
|
+
else
|
|
39
|
+
puts @parser.help
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def parse_options!
|
|
46
|
+
@parser = OptionParser.new do |opts|
|
|
47
|
+
opts.banner = "Usage: rubyagents [options] \"query\""
|
|
48
|
+
opts.separator ""
|
|
49
|
+
|
|
50
|
+
opts.on("-m", "--model MODEL", "Model to use (default: openai/gpt-5.2)") do |m|
|
|
51
|
+
@options[:model] = m
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
opts.on("-t", "--tools TOOLS", "Comma-separated tool names (#{TOOL_MAP.keys.join(", ")})") do |t|
|
|
55
|
+
@options[:tools] = t.split(",").map(&:strip)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
opts.on("-a", "--agent-type TYPE", "Agent type: code or tool_calling (default: code)") do |a|
|
|
59
|
+
@options[:agent_type] = a
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
opts.on("-p", "--plan N", Integer, "Re-plan every N steps") do |n|
|
|
63
|
+
@options[:planning_interval] = n
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opts.on("-i", "--interactive", "Interactive mode") do
|
|
67
|
+
@options[:interactive] = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
opts.on("--mcp COMMAND", "MCP server command (repeatable)") do |cmd|
|
|
71
|
+
@options[:mcp] << cmd
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
opts.on("-s", "--max-steps N", Integer, "Max agent steps (default: 10)") do |n|
|
|
75
|
+
@options[:max_steps] = n
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
opts.on("-v", "--version", "Show version") do
|
|
79
|
+
puts "rubyagents #{VERSION}"
|
|
80
|
+
exit
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
opts.on("-h", "--help", "Show help") do
|
|
84
|
+
puts opts
|
|
85
|
+
exit
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@parser.parse!(@argv)
|
|
90
|
+
@query = @argv.join(" ") unless @argv.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_agent
|
|
94
|
+
tools = @options[:tools].filter_map do |name|
|
|
95
|
+
loader = TOOL_MAP[name]
|
|
96
|
+
if loader
|
|
97
|
+
loader.call
|
|
98
|
+
else
|
|
99
|
+
$stderr.puts "Unknown tool: #{name}. Available: #{TOOL_MAP.keys.join(", ")}"
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@options[:mcp].each do |cmd|
|
|
105
|
+
mcp_tools = Rubyagents.tools_from_mcp(command: cmd)
|
|
106
|
+
tools.concat(mcp_tools)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
agent_class = @options[:agent_type] == "tool_calling" ? ToolCallingAgent : CodeAgent
|
|
110
|
+
|
|
111
|
+
agent_class.new(
|
|
112
|
+
model: @options[:model],
|
|
113
|
+
tools: tools,
|
|
114
|
+
max_steps: @options[:max_steps],
|
|
115
|
+
planning_interval: @options[:planning_interval]
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def single_query(query)
|
|
120
|
+
UI.welcome
|
|
121
|
+
agent = build_agent
|
|
122
|
+
agent.run(query)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def interactive_mode
|
|
126
|
+
UI.welcome
|
|
127
|
+
puts "Type your queries (Ctrl+C to exit)\n\n"
|
|
128
|
+
|
|
129
|
+
agent = build_agent
|
|
130
|
+
first = true
|
|
131
|
+
|
|
132
|
+
loop do
|
|
133
|
+
prompt = RUBY_ENGINE == "jruby" ? ">> " : Lipgloss::Style.new.bold(true).foreground("#7B61FF").render(">> ")
|
|
134
|
+
print prompt
|
|
135
|
+
query = $stdin.gets&.strip
|
|
136
|
+
break if query.nil? || query.empty?
|
|
137
|
+
|
|
138
|
+
agent.run(query, reset: first)
|
|
139
|
+
first = false
|
|
140
|
+
puts
|
|
141
|
+
end
|
|
142
|
+
rescue Interrupt
|
|
143
|
+
puts "\nGoodbye!"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyagents
|
|
4
|
+
class CodeAgent < Agent
|
|
5
|
+
CODE_BLOCK_RE = /```ruby\s*\n(.*?)```/m
|
|
6
|
+
|
|
7
|
+
def initialize(model:, tools: [], agents: [], name: nil, description: nil,
|
|
8
|
+
max_steps: 10, timeout: 30, planning_interval: nil, step_callbacks: [],
|
|
9
|
+
callbacks: [], final_answer_checks: [], prompt_templates: nil,
|
|
10
|
+
instructions: nil, output_type: nil)
|
|
11
|
+
require_relative "tools/list_gems"
|
|
12
|
+
tools = [ListGems] + tools unless tools.any? { |t| t == ListGems || (t.is_a?(Tool) && t.is_a?(ListGems)) }
|
|
13
|
+
super(model: model, tools: tools, agents: agents, name: name, description: description,
|
|
14
|
+
max_steps: max_steps, planning_interval: planning_interval, step_callbacks: step_callbacks,
|
|
15
|
+
callbacks: callbacks, final_answer_checks: final_answer_checks,
|
|
16
|
+
prompt_templates: prompt_templates, instructions: instructions, output_type: output_type)
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def system_prompt
|
|
23
|
+
prompt = if @prompt_templates.system_prompt
|
|
24
|
+
tool_descriptions = tools.map { |t| t.class.to_prompt }.join("\n\n")
|
|
25
|
+
@prompt_templates.system_prompt.gsub("{{tool_descriptions}}", tool_descriptions)
|
|
26
|
+
else
|
|
27
|
+
Prompt.code_agent_system(tools: tools)
|
|
28
|
+
end
|
|
29
|
+
prompt = "#{prompt}\n\n#{@instructions}" if @instructions
|
|
30
|
+
prompt
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse_response(response)
|
|
34
|
+
content = response.content
|
|
35
|
+
|
|
36
|
+
thought = nil
|
|
37
|
+
if content =~ /Thought:\s*(.*?)(?=Code:|```ruby)/m
|
|
38
|
+
thought = $1.strip
|
|
39
|
+
elsif content =~ /Thought:\s*(.*)/m
|
|
40
|
+
thought = $1.strip
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
code = nil
|
|
44
|
+
if content =~ CODE_BLOCK_RE
|
|
45
|
+
code = $1.strip
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
[thought, code]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run_action(thought, code, response, llm_duration)
|
|
52
|
+
UI.code(code)
|
|
53
|
+
exec_duration, result = timed { execute(code) }
|
|
54
|
+
total_duration = llm_duration + exec_duration
|
|
55
|
+
|
|
56
|
+
if result[:error]
|
|
57
|
+
UI.error(result[:error])
|
|
58
|
+
UI.step_metrics(duration: total_duration, token_usage: response.token_usage)
|
|
59
|
+
step = memory.add_step(thought: thought, code: code, error: result[:error],
|
|
60
|
+
duration: total_duration, token_usage: response.token_usage)
|
|
61
|
+
notify_callbacks(step)
|
|
62
|
+
nil
|
|
63
|
+
else
|
|
64
|
+
output = format_output(result)
|
|
65
|
+
UI.observation(output)
|
|
66
|
+
UI.step_metrics(duration: total_duration, token_usage: response.token_usage)
|
|
67
|
+
|
|
68
|
+
step = memory.add_step(thought: thought, code: code, observation: output,
|
|
69
|
+
duration: total_duration, token_usage: response.token_usage)
|
|
70
|
+
notify_callbacks(step)
|
|
71
|
+
|
|
72
|
+
result[:is_final_answer] ? result[:result] : nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def execute(code)
|
|
77
|
+
spin = UI.spinner("Executing code...")
|
|
78
|
+
spin.start
|
|
79
|
+
sandbox = Sandbox.new(tools: tools, timeout: @timeout)
|
|
80
|
+
result = sandbox.execute(code)
|
|
81
|
+
spin.stop
|
|
82
|
+
result
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyagents
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class AgentError < Error; end
|
|
6
|
+
class ParsingError < AgentError; end
|
|
7
|
+
class ExecutionError < AgentError; end
|
|
8
|
+
class GenerationError < AgentError; end
|
|
9
|
+
class MaxStepsError < AgentError; end
|
|
10
|
+
class InterruptError < AgentError; end
|
|
11
|
+
|
|
12
|
+
# Inherits Exception (not StandardError) so agent-generated `rescue => e` won't catch it
|
|
13
|
+
class FinalAnswerException < Exception # rubocop:disable Lint/InheritException
|
|
14
|
+
attr_reader :value
|
|
15
|
+
|
|
16
|
+
def initialize(value)
|
|
17
|
+
@value = value
|
|
18
|
+
super("Final answer reached")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rubyagents
|
|
7
|
+
module MCP
|
|
8
|
+
class StdioTransport
|
|
9
|
+
def initialize(command:)
|
|
10
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*Array(command))
|
|
11
|
+
@request_id = 0
|
|
12
|
+
handshake!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def send_request(request:)
|
|
16
|
+
@request_id += 1
|
|
17
|
+
payload = { jsonrpc: "2.0", id: @request_id }.merge(request)
|
|
18
|
+
write_message(payload)
|
|
19
|
+
read_response(@request_id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def close
|
|
23
|
+
@stdin.close unless @stdin.closed?
|
|
24
|
+
@stdout.close unless @stdout.closed?
|
|
25
|
+
@stderr.close unless @stderr.closed?
|
|
26
|
+
@wait_thread.join
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def handshake!
|
|
32
|
+
# Send initialize
|
|
33
|
+
@request_id += 1
|
|
34
|
+
init_request = {
|
|
35
|
+
jsonrpc: "2.0",
|
|
36
|
+
id: @request_id,
|
|
37
|
+
method: "initialize",
|
|
38
|
+
params: {
|
|
39
|
+
protocolVersion: "2024-11-05",
|
|
40
|
+
capabilities: {},
|
|
41
|
+
clientInfo: { name: "rubyagents", version: Rubyagents::VERSION }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
write_message(init_request)
|
|
45
|
+
read_response(@request_id)
|
|
46
|
+
|
|
47
|
+
# Send initialized notification (no id)
|
|
48
|
+
write_message({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write_message(hash)
|
|
52
|
+
line = JSON.generate(hash)
|
|
53
|
+
@stdin.puts(line)
|
|
54
|
+
@stdin.flush
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def read_response(expected_id)
|
|
58
|
+
loop do
|
|
59
|
+
line = @stdout.gets
|
|
60
|
+
raise "MCP server closed connection" unless line
|
|
61
|
+
msg = JSON.parse(line.strip)
|
|
62
|
+
# Skip notifications (no id)
|
|
63
|
+
next unless msg.key?("id")
|
|
64
|
+
return msg if msg["id"] == expected_id
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class MCPToolWrapper < Tool
|
|
70
|
+
attr_reader :mcp_tool, :client
|
|
71
|
+
|
|
72
|
+
def self.for(mcp_tool, client)
|
|
73
|
+
klass = Class.new(self) do
|
|
74
|
+
tool_name(mcp_tool["name"])
|
|
75
|
+
description(mcp_tool["description"] || "")
|
|
76
|
+
|
|
77
|
+
schema = mcp_tool.dig("inputSchema", "properties") || {}
|
|
78
|
+
required_fields = mcp_tool.dig("inputSchema", "required") || []
|
|
79
|
+
schema.each do |param_name, param_def|
|
|
80
|
+
input param_name.to_sym,
|
|
81
|
+
type: (param_def["type"] || "string").to_sym,
|
|
82
|
+
description: param_def["description"] || "",
|
|
83
|
+
required: required_fields.include?(param_name)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
klass.new(mcp_tool, client)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize(mcp_tool, client)
|
|
90
|
+
@mcp_tool = mcp_tool
|
|
91
|
+
@client = client
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def call(**kwargs)
|
|
95
|
+
response = @client.send_request(
|
|
96
|
+
request: {
|
|
97
|
+
method: "tools/call",
|
|
98
|
+
params: { name: @mcp_tool["name"], arguments: kwargs.transform_keys(&:to_s) }
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
# Extract text content from MCP response
|
|
102
|
+
content = response.dig("result", "content") || []
|
|
103
|
+
content.filter_map { |c| c["text"] if c["type"] == "text" }.join("\n")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.tools_from_mcp(command:)
|
|
109
|
+
transport = MCP::StdioTransport.new(command: command)
|
|
110
|
+
response = transport.send_request(request: { method: "tools/list", params: {} })
|
|
111
|
+
tools = response.dig("result", "tools") || []
|
|
112
|
+
tools.map { |t| MCP::MCPToolWrapper.for(t, transport) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.tool_from_mcp(command:, tool_name:)
|
|
116
|
+
transport = MCP::StdioTransport.new(command: command)
|
|
117
|
+
response = transport.send_request(request: { method: "tools/list", params: {} })
|
|
118
|
+
tools = response.dig("result", "tools") || []
|
|
119
|
+
tool_def = tools.find { |t| t["name"] == tool_name }
|
|
120
|
+
|
|
121
|
+
unless tool_def
|
|
122
|
+
available = tools.map { |t| t["name"] }.join(", ")
|
|
123
|
+
raise ArgumentError, "Tool #{tool_name.inspect} not found. Available: #{available}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
MCP::MCPToolWrapper.for(tool_def, transport)
|
|
127
|
+
end
|
|
128
|
+
end
|