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,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
|