gemlings 0.3.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e1ddaa59fb419c9b79901c5bc307303a2fe7b8f9d2860918d2cda34a95b917f
4
- data.tar.gz: c136329db04947954e039bdd139081572aaae1c66a08c3196eb63fc36d453448
3
+ metadata.gz: 626d2d204d6ea651987fd9fa08fa642e94741d6086ee397a20be6eba8f78af82
4
+ data.tar.gz: da993ac05fe07c53e670b5e71b984aa5a7b879a2daed380c6ab4b1febad3ce98
5
5
  SHA512:
6
- metadata.gz: d45d3793c999be51f362b868ef95039da9dadd7e52a1fb8199508a14553b0a07974146a99c652adc357a7581d997febb1639415eb3fb3bf9dbea8e97c49c868e
7
- data.tar.gz: 6a0daa95cf7d74009dca1857b0f44036d663efb23cb3e8187ec0d5888b4463640634b9e8edb8f8bd493d955b770232e6c665b253986cbe72996801d906490324
6
+ metadata.gz: 4ff5eb3cd06d891dfa5c6994822f5d1cf1957366ad59ed14552357114add8acc7b777bd1486dd3ea759f41a35c00bf5306726e6aed1394195bc5dd2364c3dbcf
7
+ data.tar.gz: 0a61d59266d86180ca27d4087f0f8ab33d037c6b681bc4715d7969a7ace021bc464c3b6ebae408c19ff50da863f17af9d8c0a5496f775e1d0bf02536aa9c4d4e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 0.3.2
4
15
 
5
16
  Bug fixes and Ollama improvements.
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
- Requires Ruby 3.2+. JRuby 10+ is also supported.
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", "~> 0.7"
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"
@@ -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
- spin = on_stream ? nil : UI.spinner("Thinking...")
164
- spin&.start
165
- response = @model.generate(messages, &on_stream)
166
- spin&.stop
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
- agent_class.new(
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
@@ -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, planning_interval: nil, step_callbacks: [],
9
- callbacks: [], final_answer_checks: [], prompt_templates: nil,
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
@@ -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
@@ -7,12 +7,17 @@ module Gemlings
7
7
  DEFAULT_TIMEOUT = 30 # seconds
8
8
 
9
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.
10
+ # Executor strategy — user-selectable, with sensible per-platform defaults.
12
11
  # ---------------------------------------------------------------------------
12
+ EXECUTORS = {}
13
+
13
14
  class Executor
14
- def self.for_platform
15
- RUBY_ENGINE == "jruby" ? ThreadExecutor : ForkExecutor
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
- EXECUTOR = Executor.for_platform.new
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
- EXECUTOR.call(@timeout) { run_in_child(code) }
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
- spin = on_stream ? nil : UI.spinner("Thinking...")
21
- spin&.start
22
- response = @model.generate(messages, tools: tool_schemas, &on_stream)
23
- spin&.stop
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemlings
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/gemlings.rb CHANGED
@@ -18,4 +18,5 @@ require_relative "gemlings/agent"
18
18
  require_relative "gemlings/code_agent"
19
19
  require_relative "gemlings/tool_calling_agent"
20
20
  require_relative "gemlings/mcp"
21
+ require_relative "gemlings/ruby_llm"
21
22
  require_relative "gemlings/cli"
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.3.2
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: '0.7'
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: '0.7'
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