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.
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Rubyagents
6
+ class Sandbox
7
+ DEFAULT_TIMEOUT = 30 # seconds
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Executor strategy — selected once at load time based on the Ruby engine.
11
+ # Adding a new backend (e.g. TruffleRuby) is a new subclass + one constant.
12
+ # ---------------------------------------------------------------------------
13
+ class Executor
14
+ def self.for_platform
15
+ RUBY_ENGINE == "jruby" ? ThreadExecutor : ForkExecutor
16
+ end
17
+
18
+ def call(_timeout, &_block)
19
+ raise NotImplementedError, "#{self.class}#call not implemented"
20
+ end
21
+ end
22
+
23
+ # MRI / TruffleRuby: fork gives full process isolation and safe kill.
24
+ class ForkExecutor < Executor
25
+ def call(timeout, &block)
26
+ reader, writer = IO.pipe
27
+
28
+ pid = Process.fork do
29
+ reader.close
30
+ Marshal.dump(block.call, writer)
31
+ rescue => e
32
+ Marshal.dump({ error: "#{e.class}: #{e.message}" }, writer)
33
+ ensure
34
+ writer.close
35
+ exit!(0)
36
+ end
37
+
38
+ writer.close
39
+
40
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
41
+ loop do
42
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
43
+ if status
44
+ break
45
+ elsif Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
46
+ Process.kill("KILL", pid)
47
+ Process.waitpid(pid)
48
+ reader.close
49
+ return { error: "Execution timed out after #{timeout}s" }
50
+ end
51
+ sleep 0.05
52
+ end
53
+
54
+ data = reader.read
55
+ reader.close
56
+
57
+ data.empty? ? { output: "", result: nil, is_final_answer: false } : Marshal.load(data) # rubocop:disable Security/MarshalLoad
58
+ end
59
+ end
60
+
61
+ # JRuby >= 10.0.3.0: fork is unavailable; use a thread with a join-based timeout.
62
+ # STDOUT_MUTEX serializes $stdout redirection so concurrent sandbox calls
63
+ # (e.g. managed agents) don't interleave captured output.
64
+ class ThreadExecutor < Executor
65
+ STDOUT_MUTEX = Mutex.new
66
+
67
+ def call(timeout, &block)
68
+ result = nil
69
+ error = nil
70
+
71
+ thread = Thread.new do
72
+ STDOUT_MUTEX.synchronize { result = block.call }
73
+ rescue => e
74
+ error = "#{e.class}: #{e.message}"
75
+ end
76
+
77
+ unless thread.join(timeout)
78
+ thread.kill
79
+ return { error: "Execution timed out after #{timeout}s" }
80
+ end
81
+
82
+ error ? { error: error } : result
83
+ end
84
+ end
85
+
86
+ EXECUTOR = Executor.for_platform.new
87
+
88
+ attr_reader :timeout
89
+
90
+ def initialize(tools:, timeout: DEFAULT_TIMEOUT)
91
+ @tools = tools
92
+ @timeout = timeout
93
+ @tool_map = tools.each_with_object({}) { |t, h| h[t.class.tool_name] = t }
94
+ end
95
+
96
+ def execute(code)
97
+ EXECUTOR.call(@timeout) { run_in_child(code) }
98
+ end
99
+
100
+ private
101
+
102
+ def run_in_child(code)
103
+ # Capture stdout
104
+ stdout_capture = StringIO.new
105
+ $stdout = stdout_capture
106
+
107
+ # Create a clean execution context with tool methods
108
+ context = build_context
109
+
110
+ result = context.instance_eval(code, "(agent)", 1)
111
+ $stdout = STDOUT
112
+
113
+ output = stdout_capture.string
114
+ { output: output, result: result, is_final_answer: false }
115
+ rescue FinalAnswerException => e
116
+ $stdout = STDOUT
117
+ output = stdout_capture.string
118
+ { output: output, result: e.value, is_final_answer: true }
119
+ end
120
+
121
+ def build_context
122
+ ctx = Object.new
123
+
124
+ # Define each tool as a method on the context object
125
+ @tool_map.each do |name, tool|
126
+ if name == "final_answer"
127
+ # Wrap final_answer to raise FinalAnswerException
128
+ ctx.define_singleton_method(name) do |**kwargs|
129
+ result = tool.call(**kwargs)
130
+ raise FinalAnswerException.new(result)
131
+ end
132
+ else
133
+ ctx.define_singleton_method(name) do |**kwargs|
134
+ tool.call(**kwargs)
135
+ end
136
+ end
137
+ end
138
+
139
+ ctx
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyagents
4
+ class Tool
5
+ class << self
6
+ def inherited(subclass)
7
+ super
8
+ subclass.instance_variable_set(:@inputs, {})
9
+ end
10
+
11
+ def tool_name(value = nil)
12
+ if value
13
+ @tool_name = value
14
+ else
15
+ @tool_name || name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
16
+ end
17
+ end
18
+ alias_method :name, :tool_name
19
+
20
+ def description(value = nil)
21
+ if value
22
+ @description = value
23
+ else
24
+ @description || ""
25
+ end
26
+ end
27
+
28
+ def input(name, type:, description:, required: true)
29
+ @inputs[name] = { type: type, description: description, required: required }
30
+ end
31
+
32
+ def inputs
33
+ @inputs ||= {}
34
+ end
35
+
36
+ def output_type(value = nil)
37
+ if value
38
+ @output_type = value
39
+ else
40
+ @output_type || :string
41
+ end
42
+ end
43
+
44
+ def to_schema
45
+ properties = {}
46
+ required = []
47
+
48
+ inputs.each do |param_name, config|
49
+ properties[param_name] = {
50
+ type: config[:type].to_s,
51
+ description: config[:description]
52
+ }
53
+ required << param_name.to_s if config[:required]
54
+ end
55
+
56
+ {
57
+ name: tool_name,
58
+ description: description,
59
+ parameters: {
60
+ type: "object",
61
+ properties: properties,
62
+ required: required
63
+ }
64
+ }
65
+ end
66
+
67
+ def to_prompt
68
+ lines = ["Tool: #{tool_name}", "Description: #{description}"]
69
+
70
+ if inputs.any?
71
+ lines << "Inputs:"
72
+ inputs.each do |param_name, config|
73
+ opt = config[:required] ? "" : " (optional)"
74
+ lines << " - #{param_name} (#{config[:type]}#{opt}): #{config[:description]}"
75
+ end
76
+ end
77
+
78
+ lines << "Output type: #{output_type}"
79
+ lines.join("\n")
80
+ end
81
+ end
82
+
83
+ def call(**kwargs)
84
+ raise NotImplementedError, "#{self.class} must implement #call"
85
+ end
86
+ end
87
+
88
+ # Built-in tool: signals the final answer
89
+ class FinalAnswerTool < Tool
90
+ tool_name "final_answer"
91
+ description "Returns the final answer to the user's question"
92
+ input :answer, type: :string, description: "The final answer to return"
93
+ output_type :string
94
+
95
+ def call(answer:)
96
+ answer
97
+ end
98
+ end
99
+
100
+ # Block-based tool shorthand
101
+ # Usage:
102
+ # greet = Rubyagents.tool(:greet, "Greets a person", name: "The person's name") { |name:| "Hello, #{name}!" }
103
+ def self.tool(tool_name, desc, output: :string, **inputs, &block)
104
+ klass = Class.new(Tool) do
105
+ self.tool_name(tool_name.to_s)
106
+ self.description(desc)
107
+
108
+ inputs.each do |param_name, param_desc|
109
+ type, description = if param_desc.is_a?(Hash)
110
+ [param_desc[:type] || :string, param_desc[:description] || param_desc.to_s]
111
+ else
112
+ [:string, param_desc.to_s]
113
+ end
114
+ input param_name, type: type, description: description
115
+ end
116
+
117
+ self.output_type(output)
118
+
119
+ define_method(:call, &block)
120
+ end
121
+
122
+ klass.new
123
+ end
124
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyagents
4
+ class ToolCallingAgent < Agent
5
+ private
6
+
7
+ def system_prompt
8
+ prompt = if @prompt_templates.system_prompt
9
+ tool_descriptions = tools.map { |t| t.class.to_prompt }.join("\n\n")
10
+ @prompt_templates.system_prompt.gsub("{{tool_descriptions}}", tool_descriptions)
11
+ else
12
+ Prompt.tool_calling_agent_system(tools: tools)
13
+ end
14
+ prompt = "#{prompt}\n\n#{@instructions}" if @instructions
15
+ prompt
16
+ end
17
+
18
+ def generate_response(messages, &on_stream)
19
+ tool_schemas = tools.map { |t| t.class.to_schema }
20
+ spin = on_stream ? nil : UI.spinner("Thinking...")
21
+ spin&.start
22
+ response = @model.generate(messages, tools: tool_schemas, &on_stream)
23
+ spin&.stop
24
+ response
25
+ end
26
+
27
+ def parse_response(response)
28
+ [response.content, response.tool_calls]
29
+ end
30
+
31
+ def run_action(thought, tool_calls, response, llm_duration)
32
+ return nil unless tool_calls&.any?
33
+
34
+ results = []
35
+ final_answer_value = nil
36
+
37
+ tool_calls.each do |tc|
38
+ tool_name = tc.function.name
39
+ arguments = tc.function.arguments
40
+
41
+ UI.code("#{tool_name}(#{arguments.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")})")
42
+ notify(:on_tool_call, tool_name: tool_name, arguments: arguments)
43
+
44
+ if tool_name == "final_answer"
45
+ final_answer_value = arguments[:answer] || arguments["answer"]
46
+ results << "Final answer: #{final_answer_value}"
47
+ next
48
+ end
49
+
50
+ tool = @tool_map[tool_name]
51
+ unless tool
52
+ error_msg = "Unknown tool: #{tool_name}"
53
+ UI.error(error_msg)
54
+ results << "Error: #{error_msg}"
55
+ next
56
+ end
57
+
58
+ begin
59
+ # Convert string keys to symbols for tool call
60
+ sym_args = arguments.transform_keys(&:to_sym)
61
+ result = tool.call(**sym_args)
62
+ UI.observation(result.to_s)
63
+ results << result.to_s
64
+ rescue => e
65
+ UI.error("#{e.class}: #{e.message}")
66
+ results << "Error: #{e.message}"
67
+ end
68
+ end
69
+
70
+ total_output = results.join("\n")
71
+ UI.step_metrics(duration: llm_duration, token_usage: response.token_usage)
72
+
73
+ step = memory.add_step(
74
+ thought: thought,
75
+ tool_calls: tool_calls,
76
+ observation: total_output,
77
+ duration: llm_duration,
78
+ token_usage: response.token_usage
79
+ )
80
+ notify_callbacks(step)
81
+
82
+ final_answer_value
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyagents
4
+ class FileRead < Tool
5
+ tool_name "file_read"
6
+ description "Reads the contents of a file at the given path and returns it as text"
7
+ input :path, type: :string, description: "The path to the file to read"
8
+ output_type :string
9
+
10
+ MAX_CHARS = 50_000
11
+
12
+ def call(path:)
13
+ expanded = File.expand_path(path)
14
+ content = File.read(expanded)
15
+ content.length > MAX_CHARS ? content[0, MAX_CHARS] : content
16
+ rescue => e
17
+ "Error reading #{path}: #{e.message}"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rubyagents
6
+ class FileWrite < Tool
7
+ tool_name "file_write"
8
+ description "Writes content to a file at the given path, creating parent directories if needed"
9
+ input :path, type: :string, description: "The path to the file to write"
10
+ input :content, type: :string, description: "The content to write to the file"
11
+ output_type :string
12
+
13
+ def call(path:, content:)
14
+ expanded = File.expand_path(path)
15
+ FileUtils.mkdir_p(File.dirname(expanded))
16
+ File.write(expanded, content)
17
+ "Successfully wrote #{content.length} bytes to #{expanded}"
18
+ rescue => e
19
+ "Error writing #{path}: #{e.message}"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyagents
4
+ class ListGems < Tool
5
+ tool_name "list_gems"
6
+ description "Lists Ruby gems available in the current environment that you can require and use in your code"
7
+ output_type :string
8
+
9
+ def call(**_kwargs)
10
+ specs = Gem::Specification.sort_by(&:name)
11
+ lines = specs.map { |s| "#{s.name} (#{s.version}) - #{s.summary&.slice(0, 80)}" }
12
+ lines.join("\n")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyagents
4
+ class UserInput < Tool
5
+ tool_name "user_input"
6
+ description "Asks the user a question and returns their response. Use this when you need clarification."
7
+ input :question, type: :string, description: "The question to ask the user"
8
+ output_type :string
9
+
10
+ def call(question:)
11
+ $stderr.print "\n#{question}\n> "
12
+ response = $stdin.gets&.strip
13
+ response || ""
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "reverse_markdown"
6
+
7
+ module Rubyagents
8
+ class VisitWebpage < Tool
9
+ tool_name "visit_webpage"
10
+ description "Fetches the content of a webpage and returns it as markdown"
11
+ input :url, type: :string, description: "The URL of the webpage to visit"
12
+ output_type :string
13
+
14
+ def call(url:)
15
+ uri = URI(url)
16
+ uri = URI("https://#{url}") unless uri.scheme
17
+
18
+ http = Net::HTTP.new(uri.host, uri.port)
19
+ http.use_ssl = uri.scheme == "https"
20
+ http.read_timeout = 15
21
+ http.open_timeout = 10
22
+
23
+ request = Net::HTTP::Get.new(uri)
24
+ request["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
25
+ request["Accept"] = "text/html"
26
+
27
+ response = http.request(request)
28
+
29
+ # Follow redirects (one level)
30
+ if response.is_a?(Net::HTTPRedirection) && response["location"]
31
+ return call(url: response["location"])
32
+ end
33
+
34
+ body = response.body
35
+ .force_encoding("UTF-8")
36
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
37
+
38
+ md = ReverseMarkdown.convert(body, unknown_tags: :bypass, github_flavored: true).strip
39
+ md.empty? ? "No readable content found at #{url}" : md[0, 10_000]
40
+ rescue => e
41
+ "Error fetching #{url}: #{e.message}"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "cgi"
7
+
8
+ module Rubyagents
9
+ # NOTE: DuckDuckGo HTML scraping is fragile - selectors may break if DDG changes their markup.
10
+ class WebSearch < Tool
11
+ tool_name "web_search"
12
+ description "Searches the web using DuckDuckGo and returns results"
13
+ input :query, type: :string, description: "The search query"
14
+ output_type :string
15
+
16
+ def call(query:)
17
+ uri = URI("https://html.duckduckgo.com/html/")
18
+
19
+ http = Net::HTTP.new(uri.host, uri.port)
20
+ http.use_ssl = true
21
+ request = Net::HTTP::Post.new(uri)
22
+ request["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
23
+ request.set_form_data(q: query)
24
+
25
+ response = http.request(request)
26
+
27
+ # Parse basic results from HTML
28
+ results = response.body.scan(/<a rel="nofollow" class="result__a" href="(.*?)".*?>(.*?)<\/a>/m)
29
+ snippets = response.body.scan(/<a class="result__snippet".*?>(.*?)<\/a>/m)
30
+
31
+ output = results.first(5).each_with_index.map do |r, i|
32
+ url = CGI.unescapeHTML(r[0])
33
+ title = r[1].gsub(/<.*?>/, "").strip
34
+ snippet = snippets[i] ? snippets[i][0].gsub(/<.*?>/, "").strip : ""
35
+ "#{i + 1}. #{title}\n #{url}\n #{snippet}"
36
+ end
37
+
38
+ output.empty? ? "No results found for: #{query}" : output.join("\n\n")
39
+ rescue => e
40
+ "Search error: #{e.message}"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ JRUBY = RUBY_ENGINE == "jruby"
4
+
5
+ require "lipgloss" unless JRUBY # lipgloss is not available on JRuby >= 10.0.3.0
6
+ require "rouge"
7
+
8
+ module Rubyagents
9
+ module UI
10
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
11
+
12
+ # Passthrough style used on JRuby where lipgloss is unavailable
13
+ class NullStyle
14
+ def render(text)
15
+ text
16
+ end
17
+ end
18
+
19
+ module Styles
20
+ if JRUBY
21
+ def self.label; NullStyle.new; end
22
+ def self.plan_label; NullStyle.new; end
23
+ def self.plan_box; NullStyle.new; end
24
+ def self.final_answer; NullStyle.new; end
25
+ def self.error; NullStyle.new; end
26
+ def self.dim; NullStyle.new; end
27
+ def self.spinner_style; NullStyle.new; end
28
+ else
29
+ def self.label
30
+ @label ||= Lipgloss::Style.new.faint(true)
31
+ end
32
+
33
+ def self.plan_label
34
+ @plan_label ||= Lipgloss::Style.new
35
+ .bold(true)
36
+ .foreground("#011627")
37
+ .background("#FF9F1C")
38
+ .padding(0, 1)
39
+ end
40
+
41
+ def self.plan_box
42
+ @plan_box ||= Lipgloss::Style.new
43
+ .border(:rounded)
44
+ .border_foreground("#FF9F1C")
45
+ .padding(0, 2)
46
+ end
47
+
48
+ def self.final_answer
49
+ @final_answer ||= Lipgloss::Style.new
50
+ .bold(true)
51
+ .foreground("#2EC4B6")
52
+ end
53
+
54
+ def self.error
55
+ @error ||= Lipgloss::Style.new
56
+ .bold(true)
57
+ .foreground("#FF0000")
58
+ end
59
+
60
+ def self.dim
61
+ @dim ||= Lipgloss::Style.new.faint(true)
62
+ end
63
+
64
+ def self.spinner_style
65
+ @spinner_style ||= Lipgloss::Style.new
66
+ .foreground("#7B61FF")
67
+ end
68
+ end
69
+ end
70
+
71
+ class Spinner
72
+ def initialize(message)
73
+ @message = message
74
+ @running = false
75
+ @frame = 0
76
+ end
77
+
78
+ def start
79
+ @running = true
80
+ @thread = Thread.new do
81
+ while @running
82
+ char = SPINNER_FRAMES[@frame % SPINNER_FRAMES.size]
83
+ frame = Styles.spinner_style.render(char)
84
+ $stderr.print "\r\e[K#{frame} #{@message}"
85
+ @frame += 1
86
+ sleep 0.08
87
+ end
88
+ $stderr.print "\r\e[K"
89
+ end
90
+ end
91
+
92
+ def stop
93
+ @running = false
94
+ @thread&.join
95
+ end
96
+ end
97
+
98
+ class << self
99
+ def thought(text)
100
+ puts Styles.label.render("Thought: ") + text
101
+ end
102
+
103
+ def code(source)
104
+ puts
105
+ highlighted = rouge_formatter.format(rouge_lexer.lex(source))
106
+ highlighted.each_line { |line| puts " #{line.rstrip}" }
107
+ puts
108
+ end
109
+
110
+ def observation(text)
111
+ puts Styles.label.render("Result: ") + truncate(text, 200)
112
+ end
113
+
114
+ def error(text)
115
+ puts Styles.error.render("Error: ") + text
116
+ end
117
+
118
+ def plan(text)
119
+ label = Styles.plan_label.render(" Plan ")
120
+ body = Styles.plan_box.render(text)
121
+ puts "\n#{label}\n#{body}\n"
122
+ end
123
+
124
+ def step_metrics(duration:, token_usage:)
125
+ parts = []
126
+ parts << format("%.1fs", duration) if duration > 0
127
+ parts << token_usage.to_s if token_usage
128
+ return if parts.empty?
129
+
130
+ puts Styles.dim.render(parts.join(" | "))
131
+ puts
132
+ end
133
+
134
+ def run_summary(total_steps:, total_duration:, total_tokens:)
135
+ parts = ["#{total_steps} steps", format("%.1fs total", total_duration)]
136
+ parts << total_tokens.to_s if total_tokens.total_tokens > 0
137
+ puts Styles.dim.render(parts.join(" | "))
138
+ end
139
+
140
+ def final_answer(text)
141
+ puts
142
+ puts Styles.final_answer.render("Final answer: ") + text.to_s
143
+ end
144
+
145
+ def spinner(message)
146
+ Spinner.new(message)
147
+ end
148
+
149
+ def welcome
150
+ if JRUBY
151
+ puts "rubyagents v#{VERSION}"
152
+ puts "Code-first AI agents for Ruby"
153
+ else
154
+ title = Lipgloss::Style.new
155
+ .bold(true)
156
+ .foreground("#7B61FF")
157
+ .render("rubyagents")
158
+
159
+ version = Styles.dim.render("v#{VERSION}")
160
+ puts "#{title} #{version}"
161
+ puts Styles.dim.render("Code-first AI agents for Ruby")
162
+ end
163
+ puts
164
+ end
165
+
166
+ private
167
+
168
+ def rouge_lexer
169
+ @rouge_lexer ||= Rouge::Lexers::Ruby.new
170
+ end
171
+
172
+ def rouge_formatter
173
+ @rouge_formatter ||= Rouge::Formatters::Terminal256.new(Rouge::Themes::Monokai.new)
174
+ end
175
+
176
+ def truncate(text, max)
177
+ return text if text.length <= max
178
+ text[0...max] + Styles.dim.render("... (truncated)")
179
+ end
180
+
181
+ end
182
+ end
183
+ end