gemlings 0.3.1 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +39 -1
- data/gemlings.gemspec +1 -1
- data/lib/gemlings/agent.rb +14 -7
- data/lib/gemlings/cli.rb +17 -5
- data/lib/gemlings/code_agent.rb +5 -4
- data/lib/gemlings/memory.rb +18 -2
- data/lib/gemlings/models/ruby_llm_adapter.rb +23 -3
- data/lib/gemlings/ruby_llm.rb +71 -0
- data/lib/gemlings/sandbox.rb +53 -7
- data/lib/gemlings/tool_calling_agent.rb +12 -5
- data/lib/gemlings/ui.rb +8 -0
- data/lib/gemlings/version.rb +1 -1
- data/lib/gemlings.rb +1 -0
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 626d2d204d6ea651987fd9fa08fa642e94741d6086ee397a20be6eba8f78af82
|
|
4
|
+
data.tar.gz: da993ac05fe07c53e670b5e71b984aa5a7b879a2daed380c6ab4b1febad3ce98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ff5eb3cd06d891dfa5c6994822f5d1cf1957366ad59ed14552357114add8acc7b777bd1486dd3ea759f41a35c00bf5306726e6aed1394195bc5dd2364c3dbcf
|
|
7
|
+
data.tar.gz: 0a61d59266d86180ca27d4087f0f8ab33d037c6b681bc4715d7969a7ace021bc464c3b6ebae408c19ff50da863f17af9d8c0a5496f775e1d0bf02536aa9c4d4e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
Streaming output, configurable sandboxing, and RubyLLM interop.
|
|
6
|
+
|
|
7
|
+
- **Streaming output** -- `agent.run("task", stream: true)` prints LLM tokens to the terminal in real-time; CLI flag: `gemlings -S`
|
|
8
|
+
- **Configurable sandbox executors** -- Choose `:fork`, `:thread`, or `:box` via `CodeAgent.new(executor: :box)`; auto-detects the best option per platform
|
|
9
|
+
- **Ruby::Box executor** -- On Ruby 4.0+ with `RUBY_BOX=1`, the `:box` executor adds namespace isolation so agent code can't leak monkey-patches or constants into the host
|
|
10
|
+
- **RubyLLM tool interop** -- `Gemlings.tool_from_ruby_llm(MyTool)` wraps any `RubyLLM::Tool` for use in gemlings agents
|
|
11
|
+
- **RubyLLM agent interop** -- `Gemlings.agent_from_ruby_llm(MyAgent)` wraps a `RubyLLM::Agent` or `Chat` as a managed sub-agent
|
|
12
|
+
- **Test coverage** -- Added specs for agent base class, CLI, prompt templates, and UI (106 -> 172 tests)
|
|
13
|
+
|
|
14
|
+
## 0.3.2
|
|
15
|
+
|
|
16
|
+
Bug fixes and Ollama improvements.
|
|
17
|
+
|
|
18
|
+
- **Fix Anthropic tool_result format** -- ToolCallingAgent now emits structured `tool_result` blocks instead of plain text observations, fixing 400 errors on multi-step runs with Anthropic models (thanks @parolkar)
|
|
19
|
+
- **Fix trailing whitespace in messages** -- Strip trailing whitespace from message content to avoid Anthropic API rejections
|
|
20
|
+
- **Fix Ollama connectivity** -- Default `ollama_api_base` to `http://localhost:11434/v1` so Ollama works out of the box without setting `OLLAMA_HOST`
|
|
21
|
+
- **Fix MCP transport leak** -- Close MCP transport on error paths (no tools found, tool name not found)
|
|
22
|
+
- **Fix `planning_interval: 0`** -- Guard against `ZeroDivisionError` when planning interval is zero
|
|
23
|
+
|
|
3
24
|
## 0.3.0
|
|
4
25
|
|
|
5
26
|
JRuby 10 support, interactive UI, and CI.
|
data/README.md
CHANGED
|
@@ -90,6 +90,23 @@ tools = Gemlings.tools_from_mcp(command: ["npx", "-y", "@modelcontextprotocol/se
|
|
|
90
90
|
tool = Gemlings.tool_from_mcp(command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"], tool_name: "read_file")
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
### RubyLLM tools
|
|
94
|
+
|
|
95
|
+
Use existing [RubyLLM](https://rubyllm.com) tools directly:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
class SearchTool < RubyLLM::Tool
|
|
99
|
+
description "Search the web"
|
|
100
|
+
param :query, type: :string, desc: "Search query"
|
|
101
|
+
def execute(query:) = "results for #{query}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
agent = Gemlings::CodeAgent.new(
|
|
105
|
+
model: "anthropic/claude-sonnet-4-20250514",
|
|
106
|
+
tools: [Gemlings.tool_from_ruby_llm(SearchTool)]
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
93
110
|
## Multi-agent workflows
|
|
94
111
|
|
|
95
112
|
Nest agents as tools. The manager agent calls sub-agents by name:
|
|
@@ -110,6 +127,21 @@ manager = Gemlings::CodeAgent.new(
|
|
|
110
127
|
manager.run("Find out when Ruby 3.4 was released and summarize the key features")
|
|
111
128
|
```
|
|
112
129
|
|
|
130
|
+
RubyLLM agents work too:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class Researcher < RubyLLM::Agent
|
|
134
|
+
model "openai/gpt-4o"
|
|
135
|
+
tools SearchTool
|
|
136
|
+
instructions "You are a research assistant."
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
manager = Gemlings::CodeAgent.new(
|
|
140
|
+
model: "anthropic/claude-sonnet-4-20250514",
|
|
141
|
+
agents: [Gemlings.agent_from_ruby_llm(Researcher, name: "researcher", description: "Researches topics")]
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
113
145
|
## Output validation
|
|
114
146
|
|
|
115
147
|
Validate final answers against a JSON Schema:
|
|
@@ -214,6 +246,7 @@ gemlings -m openai/gpt-4o -t web_search "Who won the latest Super Bowl?"
|
|
|
214
246
|
gemlings -a tool_calling -m openai/gpt-4o "What is 6 * 7?"
|
|
215
247
|
gemlings --mcp "npx -y @modelcontextprotocol/server-filesystem /tmp" "List files in /tmp"
|
|
216
248
|
gemlings -i # interactive mode
|
|
249
|
+
gemlings -S "What is 2+2?" # stream tokens to terminal
|
|
217
250
|
```
|
|
218
251
|
|
|
219
252
|
## Configuration
|
|
@@ -231,8 +264,13 @@ gemlings -i # interactive mode
|
|
|
231
264
|
| `final_answer_checks:` | `[]` | Procs `(answer, memory) -> bool` |
|
|
232
265
|
| `callbacks:` | `[]` | Array of `Callback` instances |
|
|
233
266
|
| `step_callbacks:` | `[]` | Procs `(step, agent:) -> void` |
|
|
267
|
+
| `executor:` | auto | CodeAgent sandbox: `:fork`, `:thread`, or `:box` (Ruby 4.0+) |
|
|
268
|
+
|
|
269
|
+
Requires Ruby 3.2+. JRuby 10+ is also supported. On Ruby 4.0+ with `RUBY_BOX=1`, the `:box` executor adds namespace isolation via `Ruby::Box`.
|
|
270
|
+
|
|
271
|
+
## Ecosystem
|
|
234
272
|
|
|
235
|
-
|
|
273
|
+
- [gemlings_browser](https://github.com/parolkar/gemlings_browser) -- Browser automation tools for gemlings agents
|
|
236
274
|
|
|
237
275
|
## License
|
|
238
276
|
|
data/gemlings.gemspec
CHANGED
|
@@ -35,7 +35,7 @@ Gem::Specification.new do |spec|
|
|
|
35
35
|
spec.add_dependency "rouge", "~> 4.0"
|
|
36
36
|
spec.add_dependency "json-schema", "~> 4.0"
|
|
37
37
|
spec.add_dependency "bigdecimal"
|
|
38
|
-
spec.add_dependency "mcp", "
|
|
38
|
+
spec.add_dependency "mcp", ">= 0.9.2"
|
|
39
39
|
spec.add_dependency "ruby_llm", "~> 1.1"
|
|
40
40
|
|
|
41
41
|
spec.add_development_dependency "rake", "~> 13.0"
|
data/lib/gemlings/agent.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Gemlings
|
|
|
27
27
|
@tool_map = @tools.each_with_object({}) { |t, h| h[t.class.tool_name] = t }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def run(task, reset: true, return_full_result: false, &on_stream)
|
|
30
|
+
def run(task, reset: true, return_full_result: false, stream: false, &on_stream)
|
|
31
31
|
@interrupt_switch = false
|
|
32
32
|
|
|
33
33
|
if reset || @memory.nil?
|
|
@@ -46,7 +46,7 @@ module Gemlings
|
|
|
46
46
|
maybe_plan(step_number)
|
|
47
47
|
|
|
48
48
|
# Call LLM with timing
|
|
49
|
-
llm_duration, response = timed { generate_response(memory.to_messages, &on_stream) }
|
|
49
|
+
llm_duration, response = timed { generate_response(memory.to_messages, stream: stream, &on_stream) }
|
|
50
50
|
|
|
51
51
|
# Parse response
|
|
52
52
|
thought, action = parse_response(response)
|
|
@@ -159,11 +159,18 @@ module Gemlings
|
|
|
159
159
|
raise NotImplementedError
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
-
def generate_response(messages, &on_stream)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
def generate_response(messages, stream: false, &on_stream)
|
|
163
|
+
if on_stream
|
|
164
|
+
response = @model.generate(messages, &on_stream)
|
|
165
|
+
elsif stream
|
|
166
|
+
response = @model.generate(messages) { |token| UI.stream_token(token) }
|
|
167
|
+
UI.stream_end
|
|
168
|
+
else
|
|
169
|
+
spin = UI.spinner("Thinking...")
|
|
170
|
+
spin.start
|
|
171
|
+
response = @model.generate(messages)
|
|
172
|
+
spin.stop
|
|
173
|
+
end
|
|
167
174
|
response
|
|
168
175
|
end
|
|
169
176
|
|
data/lib/gemlings/cli.rb
CHANGED
|
@@ -23,9 +23,11 @@ module Gemlings
|
|
|
23
23
|
tools: [],
|
|
24
24
|
mcp: [],
|
|
25
25
|
interactive: false,
|
|
26
|
+
stream: false,
|
|
26
27
|
max_steps: 10,
|
|
27
28
|
planning_interval: nil,
|
|
28
|
-
agent_type: "code"
|
|
29
|
+
agent_type: "code",
|
|
30
|
+
executor: nil
|
|
29
31
|
}
|
|
30
32
|
parse_options!
|
|
31
33
|
end
|
|
@@ -67,6 +69,10 @@ module Gemlings
|
|
|
67
69
|
@options[:interactive] = true
|
|
68
70
|
end
|
|
69
71
|
|
|
72
|
+
opts.on("-S", "--stream", "Stream LLM tokens to the terminal") do
|
|
73
|
+
@options[:stream] = true
|
|
74
|
+
end
|
|
75
|
+
|
|
70
76
|
opts.on("--mcp COMMAND", "MCP server command (repeatable)") do |cmd|
|
|
71
77
|
@options[:mcp] << cmd
|
|
72
78
|
end
|
|
@@ -75,6 +81,10 @@ module Gemlings
|
|
|
75
81
|
@options[:max_steps] = n
|
|
76
82
|
end
|
|
77
83
|
|
|
84
|
+
opts.on("-e", "--executor NAME", "Sandbox executor: fork, thread, box (default: auto)") do |e|
|
|
85
|
+
@options[:executor] = e.to_sym
|
|
86
|
+
end
|
|
87
|
+
|
|
78
88
|
opts.on("-v", "--version", "Show version") do
|
|
79
89
|
puts "gemlings #{VERSION}"
|
|
80
90
|
exit
|
|
@@ -108,18 +118,20 @@ module Gemlings
|
|
|
108
118
|
|
|
109
119
|
agent_class = @options[:agent_type] == "tool_calling" ? ToolCallingAgent : CodeAgent
|
|
110
120
|
|
|
111
|
-
|
|
121
|
+
opts = {
|
|
112
122
|
model: @options[:model],
|
|
113
123
|
tools: tools,
|
|
114
124
|
max_steps: @options[:max_steps],
|
|
115
125
|
planning_interval: @options[:planning_interval]
|
|
116
|
-
|
|
126
|
+
}
|
|
127
|
+
opts[:executor] = @options[:executor] if @options[:executor] && agent_class == CodeAgent
|
|
128
|
+
agent_class.new(**opts)
|
|
117
129
|
end
|
|
118
130
|
|
|
119
131
|
def single_query(query)
|
|
120
132
|
UI.welcome
|
|
121
133
|
agent = build_agent
|
|
122
|
-
agent.run(query)
|
|
134
|
+
agent.run(query, stream: @options[:stream])
|
|
123
135
|
end
|
|
124
136
|
|
|
125
137
|
def interactive_mode
|
|
@@ -135,7 +147,7 @@ module Gemlings
|
|
|
135
147
|
query = $stdin.gets&.strip
|
|
136
148
|
break if query.nil? || query.empty?
|
|
137
149
|
|
|
138
|
-
agent.run(query, reset: first)
|
|
150
|
+
agent.run(query, reset: first, stream: @options[:stream])
|
|
139
151
|
first = false
|
|
140
152
|
puts
|
|
141
153
|
end
|
data/lib/gemlings/code_agent.rb
CHANGED
|
@@ -5,9 +5,9 @@ module Gemlings
|
|
|
5
5
|
CODE_BLOCK_RE = /```ruby\s*\n(.*?)```/m
|
|
6
6
|
|
|
7
7
|
def initialize(model:, tools: [], agents: [], name: nil, description: nil,
|
|
8
|
-
max_steps: 10, timeout: 30,
|
|
9
|
-
|
|
10
|
-
instructions: nil, output_type: nil)
|
|
8
|
+
max_steps: 10, timeout: 30, executor: nil, planning_interval: nil,
|
|
9
|
+
step_callbacks: [], callbacks: [], final_answer_checks: [],
|
|
10
|
+
prompt_templates: nil, instructions: nil, output_type: nil)
|
|
11
11
|
require_relative "tools/list_gems"
|
|
12
12
|
tools = [ListGems] + tools unless tools.any? { |t| t == ListGems || (t.is_a?(Tool) && t.is_a?(ListGems)) }
|
|
13
13
|
super(model: model, tools: tools, agents: agents, name: name, description: description,
|
|
@@ -15,6 +15,7 @@ module Gemlings
|
|
|
15
15
|
callbacks: callbacks, final_answer_checks: final_answer_checks,
|
|
16
16
|
prompt_templates: prompt_templates, instructions: instructions, output_type: output_type)
|
|
17
17
|
@timeout = timeout
|
|
18
|
+
@executor = executor ? Sandbox.resolve_executor(executor) : nil
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
private
|
|
@@ -76,7 +77,7 @@ module Gemlings
|
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
def execute(code)
|
|
79
|
-
sandbox = Sandbox.new(tools: tools, timeout: @timeout)
|
|
80
|
+
sandbox = Sandbox.new(tools: tools, timeout: @timeout, executor: @executor)
|
|
80
81
|
sandbox.execute(code)
|
|
81
82
|
end
|
|
82
83
|
end
|
data/lib/gemlings/memory.rb
CHANGED
|
@@ -102,7 +102,18 @@ module Gemlings
|
|
|
102
102
|
messages << assistant_msg if assistant_msg
|
|
103
103
|
|
|
104
104
|
if step.observation
|
|
105
|
-
|
|
105
|
+
if step.tool_calls&.any?
|
|
106
|
+
# Some LLM APIs (e.g. Anthropic) require that every tool_use block in an
|
|
107
|
+
# assistant message is immediately followed by a tool_result block in the
|
|
108
|
+
# next user message — a plain text "Observation:" message is rejected.
|
|
109
|
+
# Build a structured tool_result entry for each tool call in this step.
|
|
110
|
+
tool_results = step.tool_calls.map do |tc|
|
|
111
|
+
{ type: "tool_result", tool_use_id: tc.id, content: step.observation }
|
|
112
|
+
end
|
|
113
|
+
messages << { role: "user", content: tool_results }
|
|
114
|
+
else
|
|
115
|
+
messages << { role: "user", content: "Observation: #{step.observation}" }
|
|
116
|
+
end
|
|
106
117
|
elsif step.error
|
|
107
118
|
messages << {
|
|
108
119
|
role: "user",
|
|
@@ -113,7 +124,12 @@ module Gemlings
|
|
|
113
124
|
end
|
|
114
125
|
end
|
|
115
126
|
|
|
116
|
-
|
|
127
|
+
# Some LLM APIs (e.g. Anthropic) reject messages whose string content ends with
|
|
128
|
+
# trailing whitespace. Strip it from every message to avoid API errors.
|
|
129
|
+
messages.each do |m|
|
|
130
|
+
m[:content] = sanitize_utf8(m[:content]) if m[:content].is_a?(String)
|
|
131
|
+
m[:content] = m[:content].rstrip if m[:content].is_a?(String)
|
|
132
|
+
end
|
|
117
133
|
end
|
|
118
134
|
|
|
119
135
|
def last_step
|
|
@@ -44,7 +44,8 @@ module Gemlings
|
|
|
44
44
|
config.gemini_api_key = ENV["GEMINI_API_KEY"] if ENV["GEMINI_API_KEY"]
|
|
45
45
|
config.deepseek_api_key = ENV["DEEPSEEK_API_KEY"] if ENV["DEEPSEEK_API_KEY"]
|
|
46
46
|
config.openrouter_api_key = ENV["OPENROUTER_API_KEY"] if ENV["OPENROUTER_API_KEY"]
|
|
47
|
-
|
|
47
|
+
ollama_base = ENV["OLLAMA_HOST"] || "http://localhost:11434"
|
|
48
|
+
config.ollama_api_base = "#{ollama_base.chomp("/")}/v1"
|
|
48
49
|
end
|
|
49
50
|
|
|
50
51
|
@configured = true
|
|
@@ -58,7 +59,7 @@ module Gemlings
|
|
|
58
59
|
|
|
59
60
|
def load_messages(chat, messages)
|
|
60
61
|
messages.each do |msg|
|
|
61
|
-
role
|
|
62
|
+
role = msg[:role]
|
|
62
63
|
content = msg[:content]
|
|
63
64
|
|
|
64
65
|
case role
|
|
@@ -71,11 +72,30 @@ module Gemlings
|
|
|
71
72
|
end
|
|
72
73
|
chat.add_message(attrs)
|
|
73
74
|
else
|
|
74
|
-
|
|
75
|
+
# A user message whose content is an array of tool_result hashes is the
|
|
76
|
+
# structured response to one or more tool_use calls in the preceding
|
|
77
|
+
# assistant message. ruby_llm expects these to be added as separate
|
|
78
|
+
# messages with role: :tool and a tool_call_id, not as plain user text.
|
|
79
|
+
if tool_result_content?(content)
|
|
80
|
+
content.each do |tr|
|
|
81
|
+
chat.add_message(
|
|
82
|
+
role: :tool,
|
|
83
|
+
content: (tr[:content] || tr["content"]).to_s,
|
|
84
|
+
tool_call_id: tr[:tool_use_id] || tr["tool_use_id"]
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
chat.add_message(role: role.to_sym, content: content || "")
|
|
89
|
+
end
|
|
75
90
|
end
|
|
76
91
|
end
|
|
77
92
|
end
|
|
78
93
|
|
|
94
|
+
def tool_result_content?(content)
|
|
95
|
+
content.is_a?(Array) &&
|
|
96
|
+
content.all? { |c| c.is_a?(Hash) && (c[:type] == "tool_result" || c["type"] == "tool_result") }
|
|
97
|
+
end
|
|
98
|
+
|
|
79
99
|
def load_tools(chat, tool_schemas)
|
|
80
100
|
tool_schemas.each { |schema| chat.with_tool(build_stub_tool(schema)) }
|
|
81
101
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemlings
|
|
4
|
+
# Wraps a RubyLLM::Tool (class or instance) as a Gemlings::Tool.
|
|
5
|
+
def self.tool_from_ruby_llm(tool)
|
|
6
|
+
require "ruby_llm"
|
|
7
|
+
instance = tool.is_a?(Class) ? tool.new : tool
|
|
8
|
+
|
|
9
|
+
raise ArgumentError, "Expected a RubyLLM::Tool, got #{instance.class}" unless instance.is_a?(RubyLLM::Tool)
|
|
10
|
+
|
|
11
|
+
tool_name = instance.name
|
|
12
|
+
tool_desc = instance.description || ""
|
|
13
|
+
params = instance.parameters || {}
|
|
14
|
+
|
|
15
|
+
klass = Class.new(Tool) do
|
|
16
|
+
self.tool_name(tool_name)
|
|
17
|
+
description(tool_desc)
|
|
18
|
+
|
|
19
|
+
params.each do |pname, param|
|
|
20
|
+
input pname.to_sym,
|
|
21
|
+
type: (param.type || "string").to_sym,
|
|
22
|
+
description: param.description || "",
|
|
23
|
+
required: param.required != false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
wrapper = klass.new
|
|
28
|
+
wrapper.define_singleton_method(:call) do |**kwargs|
|
|
29
|
+
instance.call(kwargs)
|
|
30
|
+
end
|
|
31
|
+
wrapper
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Wraps a RubyLLM::Agent (class or instance) as a managed Gemlings agent.
|
|
35
|
+
# The agent can be passed in the `agents:` array of a Gemlings agent.
|
|
36
|
+
def self.agent_from_ruby_llm(agent, name: nil, description: nil)
|
|
37
|
+
require "ruby_llm"
|
|
38
|
+
|
|
39
|
+
chat = if agent.is_a?(Class) && agent < RubyLLM::Agent
|
|
40
|
+
agent.chat
|
|
41
|
+
elsif agent.is_a?(RubyLLM::Agent)
|
|
42
|
+
agent.chat
|
|
43
|
+
elsif agent.respond_to?(:ask)
|
|
44
|
+
agent
|
|
45
|
+
else
|
|
46
|
+
raise ArgumentError, "Expected a RubyLLM::Agent class, instance, or RubyLLM::Chat, got #{agent.class}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
agent_name = name || agent.class.name&.split("::")&.last&.downcase || "ruby_llm_agent"
|
|
50
|
+
agent_desc = description || "A RubyLLM agent"
|
|
51
|
+
|
|
52
|
+
RubyLLMAgentWrapper.new(chat, name: agent_name, description: agent_desc)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Minimal agent-like wrapper around a RubyLLM::Chat so it can be used
|
|
56
|
+
# with Gemlings::ManagedAgentTool.
|
|
57
|
+
class RubyLLMAgentWrapper
|
|
58
|
+
attr_reader :name, :description
|
|
59
|
+
|
|
60
|
+
def initialize(chat, name:, description:)
|
|
61
|
+
@chat = chat
|
|
62
|
+
@name = name
|
|
63
|
+
@description = description
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run(task)
|
|
67
|
+
response = @chat.ask(task)
|
|
68
|
+
response.content
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/gemlings/sandbox.rb
CHANGED
|
@@ -7,12 +7,17 @@ module Gemlings
|
|
|
7
7
|
DEFAULT_TIMEOUT = 30 # seconds
|
|
8
8
|
|
|
9
9
|
# ---------------------------------------------------------------------------
|
|
10
|
-
# Executor strategy —
|
|
11
|
-
# Adding a new backend (e.g. TruffleRuby) is a new subclass + one constant.
|
|
10
|
+
# Executor strategy — user-selectable, with sensible per-platform defaults.
|
|
12
11
|
# ---------------------------------------------------------------------------
|
|
12
|
+
EXECUTORS = {}
|
|
13
|
+
|
|
13
14
|
class Executor
|
|
14
|
-
def self.
|
|
15
|
-
|
|
15
|
+
def self.inherited(subclass)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.available?
|
|
20
|
+
true
|
|
16
21
|
end
|
|
17
22
|
|
|
18
23
|
def call(_timeout, &_block)
|
|
@@ -22,6 +27,10 @@ module Gemlings
|
|
|
22
27
|
|
|
23
28
|
# MRI / TruffleRuby: fork gives full process isolation and safe kill.
|
|
24
29
|
class ForkExecutor < Executor
|
|
30
|
+
def self.available?
|
|
31
|
+
Process.respond_to?(:fork)
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
def call(timeout, &block)
|
|
26
35
|
reader, writer = IO.pipe
|
|
27
36
|
|
|
@@ -57,6 +66,24 @@ module Gemlings
|
|
|
57
66
|
data.empty? ? { output: "", result: nil, is_final_answer: false } : Marshal.load(data) # rubocop:disable Security/MarshalLoad
|
|
58
67
|
end
|
|
59
68
|
end
|
|
69
|
+
EXECUTORS[:fork] = ForkExecutor
|
|
70
|
+
|
|
71
|
+
# Ruby 4.0+: Fork with Ruby::Box namespace isolation.
|
|
72
|
+
# Agent code runs in a separate process AND a separate namespace, so
|
|
73
|
+
# monkey-patches, constants, and class variables can't leak into the host.
|
|
74
|
+
class BoxExecutor < ForkExecutor
|
|
75
|
+
def self.available?
|
|
76
|
+
super && defined?(Ruby::Box) && Ruby::Box.enabled?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def call(timeout, &block)
|
|
80
|
+
super(timeout) do
|
|
81
|
+
box = Ruby::Box.new
|
|
82
|
+
block.call(box)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
EXECUTORS[:box] = BoxExecutor
|
|
60
87
|
|
|
61
88
|
# JRuby: fork is unavailable; use a thread with a join-based timeout.
|
|
62
89
|
# STDOUT_MUTEX serializes $stdout redirection so concurrent sandbox calls
|
|
@@ -82,19 +109,38 @@ module Gemlings
|
|
|
82
109
|
error ? { error: error } : result
|
|
83
110
|
end
|
|
84
111
|
end
|
|
112
|
+
EXECUTORS[:thread] = ThreadExecutor
|
|
113
|
+
|
|
114
|
+
def self.default_executor
|
|
115
|
+
if BoxExecutor.available?
|
|
116
|
+
:box
|
|
117
|
+
elsif ForkExecutor.available?
|
|
118
|
+
:fork
|
|
119
|
+
else
|
|
120
|
+
:thread
|
|
121
|
+
end
|
|
122
|
+
end
|
|
85
123
|
|
|
86
|
-
|
|
124
|
+
def self.resolve_executor(name)
|
|
125
|
+
klass = EXECUTORS[name]
|
|
126
|
+
raise ArgumentError, "Unknown executor: #{name.inspect}. Available: #{EXECUTORS.keys.join(", ")}" unless klass
|
|
127
|
+
unless klass.available?
|
|
128
|
+
raise Error, "Executor #{name.inspect} is not available on this platform (#{RUBY_ENGINE} #{RUBY_VERSION})"
|
|
129
|
+
end
|
|
130
|
+
klass.new
|
|
131
|
+
end
|
|
87
132
|
|
|
88
133
|
attr_reader :timeout
|
|
89
134
|
|
|
90
|
-
def initialize(tools:, timeout: DEFAULT_TIMEOUT)
|
|
135
|
+
def initialize(tools:, timeout: DEFAULT_TIMEOUT, executor: nil)
|
|
91
136
|
@tools = tools
|
|
92
137
|
@timeout = timeout
|
|
138
|
+
@executor = executor || self.class.resolve_executor(self.class.default_executor)
|
|
93
139
|
@tool_map = tools.each_with_object({}) { |t, h| h[t.class.tool_name] = t }
|
|
94
140
|
end
|
|
95
141
|
|
|
96
142
|
def execute(code)
|
|
97
|
-
|
|
143
|
+
@executor.call(@timeout) { run_in_child(code) }
|
|
98
144
|
end
|
|
99
145
|
|
|
100
146
|
private
|
|
@@ -15,12 +15,19 @@ module Gemlings
|
|
|
15
15
|
prompt
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def generate_response(messages, &on_stream)
|
|
18
|
+
def generate_response(messages, stream: false, &on_stream)
|
|
19
19
|
tool_schemas = tools.map { |t| t.class.to_schema }
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
if on_stream
|
|
21
|
+
response = @model.generate(messages, tools: tool_schemas, &on_stream)
|
|
22
|
+
elsif stream
|
|
23
|
+
response = @model.generate(messages, tools: tool_schemas) { |token| UI.stream_token(token) }
|
|
24
|
+
UI.stream_end
|
|
25
|
+
else
|
|
26
|
+
spin = UI.spinner("Thinking...")
|
|
27
|
+
spin.start
|
|
28
|
+
response = @model.generate(messages, tools: tool_schemas)
|
|
29
|
+
spin.stop
|
|
30
|
+
end
|
|
24
31
|
response
|
|
25
32
|
end
|
|
26
33
|
|
data/lib/gemlings/ui.rb
CHANGED
|
@@ -208,6 +208,14 @@ module Gemlings
|
|
|
208
208
|
Spinner.new(message)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
def stream_token(text)
|
|
212
|
+
$stderr.print text
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def stream_end
|
|
216
|
+
$stderr.print "\n"
|
|
217
|
+
end
|
|
218
|
+
|
|
211
219
|
def welcome
|
|
212
220
|
if LIPGLOSS_AVAILABLE
|
|
213
221
|
title = Lipgloss::Style.new
|
data/lib/gemlings/version.rb
CHANGED
data/lib/gemlings.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gemlings
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Hasiński
|
|
@@ -83,16 +83,16 @@ dependencies:
|
|
|
83
83
|
name: mcp
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
85
85
|
requirements:
|
|
86
|
-
- - "
|
|
86
|
+
- - ">="
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
|
-
version:
|
|
88
|
+
version: 0.9.2
|
|
89
89
|
type: :runtime
|
|
90
90
|
prerelease: false
|
|
91
91
|
version_requirements: !ruby/object:Gem::Requirement
|
|
92
92
|
requirements:
|
|
93
|
-
- - "
|
|
93
|
+
- - ">="
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
|
-
version:
|
|
95
|
+
version: 0.9.2
|
|
96
96
|
- !ruby/object:Gem::Dependency
|
|
97
97
|
name: ruby_llm
|
|
98
98
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -181,6 +181,7 @@ files:
|
|
|
181
181
|
- lib/gemlings/model.rb
|
|
182
182
|
- lib/gemlings/models/ruby_llm_adapter.rb
|
|
183
183
|
- lib/gemlings/prompt.rb
|
|
184
|
+
- lib/gemlings/ruby_llm.rb
|
|
184
185
|
- lib/gemlings/sandbox.rb
|
|
185
186
|
- lib/gemlings/tool.rb
|
|
186
187
|
- lib/gemlings/tool_calling_agent.rb
|