omnibot-ruby 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 075266e9395d95dedce797206f30b73cdfd972aa59a97813f87e1b6b7f6ce75b
4
+ data.tar.gz: 4cda42ac155f91a4422000ebc2773c328062694cef3e9867292c47d8d5e7e393
5
+ SHA512:
6
+ metadata.gz: 2bbf2240ffcae6a4225abf426d0e0799e075cff632b10cb3357bd03383263564af5706f71bc205a7c8d7adb6111bd863c857087a0efadf8008288907c7be5b36
7
+ data.tar.gz: ff9256f23f891d0664f2e476c797b02462bbd3dd2c2f9ff41c1a70ce4f509c56c1769d8ac171e146fe957b244ace503dfa3dbacf137b57c964d6b40065489b59
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Johannes Dwicahyo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # omnibot-ruby
2
+
3
+ Rails-first LLM agents for Ruby.
4
+
5
+ ## Why
6
+
7
+ `omnibot-ruby` is a Rails-idiomatic agent framework built on [`ruby_llm`](https://github.com/crmne/ruby_llm) — a class DSL for bounded tool-calling agents that lives in your Rails app, not a Python sidecar you have to deploy, proxy, and keep in sync. It ships two primitives: **Agent** (v0.1, available now — a bounded tool-calling loop with fast paths, structured extraction, and streaming) and **Workflow** (coming in v0.2 — a durable, ActiveRecord-checkpointed graph engine for multi-step conversations with human handover, so long-running flows survive restarts without a Redis checkpointer). Everything runs in-process, which means token streaming to ActionCable/Turbo is a callback away instead of blocked behind an HTTP hop.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ bundle add omnibot-ruby
13
+ rails g omnibot:install
14
+ ```
15
+
16
+ The install generator writes `config/initializers/omnibot.rb`, where you set your default model:
17
+
18
+ ```ruby
19
+ Omnibot.configure do |config|
20
+ config.default_model = "gpt-4o-mini"
21
+ # config.on_tool_error = :capture # or :raise
22
+ end
23
+ ```
24
+
25
+ Scaffold an agent with:
26
+
27
+ ```bash
28
+ rails g omnibot:agent Support
29
+ ```
30
+
31
+ This creates `app/agents/support_agent.rb` and a matching spec in `spec/agents/`.
32
+
33
+ ## Quick start
34
+
35
+ ```ruby
36
+ class SupportAgent < Omnibot::Agent
37
+ model "claude-sonnet-5" # any ruby_llm model string
38
+ instructions "You help customers of {{company}}." # {{var}} interpolates from context
39
+ max_turns 3
40
+
41
+ tool :lookup_order, "Find an order" do |order_id:|
42
+ "order #{order_id}: shipped"
43
+ end
44
+ end
45
+
46
+ result = SupportAgent.run("where is order #123?",
47
+ history: conversation.recent_messages, # app-owned, app-windowed
48
+ context: { company: "Wokku" })
49
+
50
+ result.text # => "Order 123 is shipped!"
51
+ result.tool_calls # => [#<struct Omnibot::ToolCallRecord name="lookup_order", arguments={:order_id=>123}>]
52
+ result.usage.input_tokens
53
+ ```
54
+
55
+ Semantics worth knowing:
56
+
57
+ - The loop is: send → execute tool calls → append results → repeat, up to `max_turns`. On the final turn, tools are unbound so the model is forced to answer instead of looping forever. `max_turns` bounds the number of tool executions in a run (parallel tool calls each count) — not conversation rounds.
58
+ - `instructions` support `{{var}}` interpolation from `context`; a missing variable raises `KeyError`.
59
+ - `history` is a plain array of `{ role:, content: }` hashes (or anything that responds to `#role`/`#content`) — the gem never persists conversations itself.
60
+ - A custom `Omnibot.chat_factory` lambda must accept extra kwargs (e.g. `->(model:, **) { ... }`) — Agent always calls it with `agent_class:` in addition to `model:`.
61
+ - Block-tool params are always JSON type `"string"` — models send `"123"`, not `123`. Declare param types via class-form tools (`param :n, type: "integer"`) when types matter.
62
+ - Class-form tools must declare `param` explicitly — the wrapper that adds error capture shadows `#execute`'s signature, so ruby_llm's automatic param inference doesn't see your keyword args:
63
+
64
+ ```ruby
65
+ class AddTool < Omnibot::Tool
66
+ description "Adds two numbers"
67
+ param :a, desc: "First"
68
+ param :b, desc: "Second"
69
+
70
+ def execute(a:, b:) = a + b
71
+ end
72
+
73
+ class SupportAgent < Omnibot::Agent
74
+ tool AddTool
75
+ end
76
+ ```
77
+
78
+ ## Fast paths
79
+
80
+ Fast paths run in declaration order *before* any LLM call. Call `reply(text)` to short-circuit with zero token usage; return `nil` (or don't call `reply`) to fall through to the next fast path, and eventually to the LLM. Override `tools_for(context)` to gate which tools are attached per run.
81
+
82
+ ```ruby
83
+ class SupportAgent < Omnibot::Agent
84
+ instructions "support"
85
+
86
+ fast_path do |message, _context|
87
+ reply("Halo! Ada yang bisa dibantu?") if message.match?(/\A(hi|halo|hai)\b/i)
88
+ end
89
+
90
+ fast_path do |_message, context|
91
+ reply("VIP line") if context[:vip]
92
+ end
93
+
94
+ tool(:escalate, "Escalate") { |**| "escalated" }
95
+ tool(:lookup, "Lookup") { |**| "found" }
96
+
97
+ def tools_for(context)
98
+ context[:angry] ? self.class.tools.reject { |t| t.new.name == "escalate" } : super
99
+ end
100
+ end
101
+
102
+ result = SupportAgent.run("halo kak")
103
+ result.text # => "Halo! Ada yang bisa dibantu?"
104
+ result.fast_path? # => true
105
+ result.usage.input_tokens # => 0
106
+ ```
107
+
108
+ ## Structured extraction
109
+
110
+ `Agent.extract(input, schema:)` runs a single-shot structured extraction. Pass a [`RubyLLM::Schema`](https://github.com/crmne/ruby_llm) subclass describing the shape you want; on invalid JSON, omnibot automatically retries once with a repair prompt before raising `Omnibot::ExtractionError`.
111
+
112
+ ```ruby
113
+ class DepositProof < RubyLLM::Schema
114
+ string :bank
115
+ integer :amount
116
+ end
117
+
118
+ class ProofAgent < Omnibot::Agent
119
+ instructions "Extract the bank name and amount from the customer's message."
120
+ end
121
+
122
+ result = ProofAgent.extract("transfer 50rb via BCA", schema: DepositProof)
123
+ result # => { amount: 50_000, bank: "BCA" }
124
+ ```
125
+
126
+ Note for testing: `Omnibot::Testing`'s fake chat ignores whatever you pass as `schema:` — it just parses the scripted reply as JSON (or passes a scripted Hash straight through via `then_extract`). Schema-driven provider-side structured output only kicks in against a real LLM.
127
+
128
+ ## Streaming
129
+
130
+ Pass `stream:` to `run` and it's called with each response chunk as a plain `String`, as it arrives:
131
+
132
+ ```ruby
133
+ chunks = []
134
+ result = SupportAgent.run("hi", stream: ->(chunk) { chunks << chunk })
135
+ chunks.join # => the full reply text, assembled from chunks
136
+ result.text # => the same full text once the run completes
137
+ ```
138
+
139
+ Fast-path replies are never streamed — they short-circuit before any LLM call, so `stream` is simply not invoked for that run.
140
+
141
+ Broadcasting to the browser is a plain app-side recipe, not framework code:
142
+
143
+ ```ruby
144
+ result = SupportAgent.run(message.body,
145
+ context: { company: current_account.name },
146
+ stream: ->(chunk) {
147
+ ActionCable.server.broadcast("conversation_#{conversation.id}", chunk: chunk)
148
+ })
149
+ ```
150
+
151
+ ## Testing your agents
152
+
153
+ `Omnibot::Testing.fake!` swaps in a deterministic scripted fake LLM so specs never hit a real provider. Script tool calls and replies with `stub_agent`, include `Omnibot::Testing::Helpers` for the `stub_agent` method, and reset after each example.
154
+
155
+ ```ruby
156
+ RSpec.describe SupportAgent do
157
+ include Omnibot::Testing::Helpers
158
+
159
+ before { Omnibot::Testing.fake! }
160
+ after { Omnibot::Testing.reset! }
161
+
162
+ it "looks up the order and replies" do
163
+ stub_agent(SupportAgent)
164
+ .to_call_tool(:lookup_order, order_id: 123)
165
+ .then_reply("Order 123 is shipped!")
166
+
167
+ result = SupportAgent.run("where is order 123?", context: { company: "Wokku" })
168
+
169
+ expect(result.text).to eq("Order 123 is shipped!")
170
+ expect(result.tool_calls.map(&:name)).to eq(["lookup_order"])
171
+ end
172
+ end
173
+ ```
174
+
175
+ The fake replays your script in order: `to_call_tool` asserts a tool is called and actually executes it (so bugs in your tool body still surface), `then_reply` ends the turn with a text reply, and `then_extract` ends it with a Hash for `Agent.extract` specs. If the script runs out, unscripted calls get a default `"(fake) <message>"` reply so runaway loops fail loudly instead of hanging. Class-form tools are plain Ruby objects — unit-test them directly, no fake required.
176
+
177
+ ## Instrumentation
178
+
179
+ Everything is instrumented via `ActiveSupport::Notifications`, so you can wire usage logging, cost caps, or a dashboard without touching the gem:
180
+
181
+ | Event | Payload keys |
182
+ |---|---|
183
+ | `omnibot.llm.call` | `agent:` (agent class), `model:` (String), `usage:` (`Omnibot::Usage` — `input_tokens`/`output_tokens`) |
184
+ | `omnibot.tool.call` | `tool:` (tool class), `name:` (String), `args:` (Hash), `error:` (String, present only on failure) |
185
+ | `omnibot.agent.run` | `agent:` (agent class), `fast_path:` (Boolean), `usage:` (`Omnibot::Usage`) |
186
+
187
+ `omnibot.llm.call`'s `usage:` is per-call (that one provider round trip's final response). `omnibot.agent.run`'s `usage:` is the run total, summed across every provider round trip in the run — a single `Agent.run` can be several `omnibot.llm.call`s deep when the tool-calling loop goes more than one turn.
188
+
189
+ Timing is available on the event object itself (`event.duration`) for any subscribed event — it is not a payload key. Workflow events (`omnibot.workflow.*`) arrive with Workflow in v0.2.
190
+
191
+ A minimal usage-log subscriber — the whole recipe is 4 lines:
192
+
193
+ ```ruby
194
+ usage_log = []
195
+ ActiveSupport::Notifications.subscribe("omnibot.llm.call") do |event|
196
+ usage_log << { model: event.payload[:model], tokens: event.payload[:usage].input_tokens }
197
+ end
198
+ ```
199
+
200
+ Nothing in the gem phones home, ever — instrumentation is a local seam you subscribe to yourself.
201
+
202
+ ## Roadmap
203
+
204
+ - **v0.2 — Workflow.** A durable, ActiveRecord-checkpointed graph engine for multi-step conversations: steps as nodes, transitions as edges, `wait_for_input` to checkpoint and pause for the next inbound message, `handover!` to page a human. State persists as jsonb on a gem-owned `omnibot_workflow_runs` table, so a workflow survives a deploy or a restart mid-conversation without a Redis checkpointer.
205
+ - **Later — hosted observability.** A subscriber gem plus a hosted dashboard, built entirely on the instrumentation events above. Paid, optional, and no core changes required to adopt it.
206
+
207
+ ## License
208
+
209
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,14 @@
1
+ require "rails/generators"
2
+
3
+ module Omnibot
4
+ module Generators
5
+ class AgentGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def create_agent
9
+ template "agent.rb.tt", "app/agents/#{file_name}_agent.rb"
10
+ template "agent_spec.rb.tt", "spec/agents/#{file_name}_agent_spec.rb"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,8 @@
1
+ class <%= class_name %>Agent < Omnibot::Agent
2
+ instructions "You are a helpful assistant."
3
+ max_turns 5
4
+
5
+ # tool :lookup_order, "Find an order by ID" do |order_id:|
6
+ # Order.find(order_id).summary
7
+ # end
8
+ end
@@ -0,0 +1,14 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe <%= class_name %>Agent do
4
+ include Omnibot::Testing::Helpers
5
+
6
+ before { Omnibot::Testing.fake! }
7
+ after { Omnibot::Testing.reset! }
8
+
9
+ it "replies" do
10
+ stub_agent(<%= class_name %>Agent).then_reply("hello!")
11
+ result = <%= class_name %>Agent.run("hi")
12
+ expect(result.text).to eq("hello!")
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ require "rails/generators"
2
+
3
+ module Omnibot
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def create_initializer
9
+ template "initializer.rb.tt", "config/initializers/omnibot.rb"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,4 @@
1
+ Omnibot.configure do |config|
2
+ config.default_model = "gpt-4o-mini"
3
+ # config.on_tool_error = :capture # or :raise
4
+ end
@@ -0,0 +1,246 @@
1
+ module Omnibot
2
+ class Agent
3
+ class TurnLimit < StandardError; end
4
+
5
+ FAST_REPLY = :__omnibot_fast_reply
6
+
7
+ class << self
8
+ def model(value = nil)
9
+ value ? @model = value : (@model || Omnibot.config.default_model)
10
+ end
11
+
12
+ def instructions(value = nil)
13
+ value ? @instructions = value : @instructions
14
+ end
15
+
16
+ # Bounds the number of tool executions in a run, not conversation rounds —
17
+ # the before_tool_call hook counts each call, so parallel tool calls in a
18
+ # single round each count separately.
19
+ def max_turns(value = nil)
20
+ value ? @max_turns = value : (@max_turns || 5)
21
+ end
22
+
23
+ def tool(name_or_class, description = nil, &block)
24
+ tools << (block ? Tool.from_block(name_or_class, description, &block) : name_or_class)
25
+ end
26
+
27
+ def tools = @tools ||= []
28
+ def fast_paths = @fast_paths ||= []
29
+ def fast_path(&block) = fast_paths << block
30
+
31
+ def inherited(subclass)
32
+ super
33
+ subclass.instance_variable_set(:@model, @model)
34
+ subclass.instance_variable_set(:@instructions, @instructions)
35
+ subclass.instance_variable_set(:@max_turns, @max_turns)
36
+ subclass.instance_variable_set(:@tools, tools.dup)
37
+ subclass.instance_variable_set(:@fast_paths, fast_paths.dup)
38
+ end
39
+
40
+ def run(message, history: [], context: {}, stream: nil)
41
+ new(context).run(message, history: history, stream: stream)
42
+ end
43
+
44
+ def extract(input, schema:, context: {})
45
+ new(context).extract(input, schema: schema)
46
+ end
47
+ end
48
+
49
+ attr_reader :context
50
+
51
+ def initialize(context = {})
52
+ @context = context
53
+ end
54
+
55
+ def run(message, history: [], stream: nil)
56
+ ActiveSupport::Notifications.instrument("omnibot.agent.run", agent: self.class) do |payload|
57
+ payload[:fast_path] = false
58
+ result = run_loop(message, history, stream)
59
+ payload[:fast_path] = result.fast_path?
60
+ payload[:usage] = result.usage
61
+ result
62
+ end
63
+ end
64
+
65
+ # Override in subclasses to gate tools per run (context-aware).
66
+ def tools_for(_context) = self.class.tools
67
+
68
+ # Called from within a fast_path block to short-circuit the run loop.
69
+ def reply(text) = throw(FAST_REPLY, text)
70
+
71
+ def extract(input, schema:)
72
+ chat = Omnibot.chat_factory.call(model: self.class.model, agent_class: self.class)
73
+ chat.with_instructions(interpolated_instructions) if self.class.instructions
74
+ chat.with_schema(schema)
75
+
76
+ response = instrument_llm { chat.ask(input.to_s) }
77
+ parse_extraction(response.content) do |error|
78
+ repair = instrument_llm do
79
+ chat.ask("Your previous output was not valid JSON (#{error.message}). " \
80
+ "Respond again with ONLY valid JSON matching the schema.")
81
+ end
82
+ parse_extraction(repair.content) do |_error|
83
+ raise ExtractionError, "extraction failed after repair: #{repair.content.to_s.truncate(200)}"
84
+ end
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def run_loop(message, history, stream)
91
+ if (text = try_fast_paths(message))
92
+ return Result.new(text: text, tool_calls: [], usage: Usage.new(0, 0),
93
+ messages: [], fast_path: true)
94
+ end
95
+
96
+ @history_for_build = history
97
+ chat = build_chat
98
+ tool_calls = []
99
+ turns = 0
100
+
101
+ chat.before_tool_call do |tc|
102
+ turns += 1
103
+ raise TurnLimit if turns > self.class.max_turns
104
+ tool_calls << ToolCallRecord.new(tc.name.to_s, tc.arguments)
105
+ end
106
+
107
+ response =
108
+ begin
109
+ instrument_llm { chat.ask(message, &wrap_stream(stream)) }
110
+ rescue TurnLimit
111
+ strip_tools(chat)
112
+ synthesize_pending_tool_results(chat)
113
+ instrument_llm do
114
+ chat.ask("Answer the user now with the information you already have. Do not call tools.",
115
+ &wrap_stream(stream))
116
+ end
117
+ end
118
+
119
+ Result.new(
120
+ text: response.content.to_s,
121
+ tool_calls: tool_calls,
122
+ usage: run_usage(chat, response),
123
+ messages: chat.messages.map { |m| normalize_message(m) },
124
+ fast_path: false
125
+ )
126
+ end
127
+
128
+ def try_fast_paths(message)
129
+ self.class.fast_paths.each do |block|
130
+ result = catch(FAST_REPLY) do
131
+ instance_exec(message, context, &block)
132
+ nil
133
+ end
134
+ return result if result
135
+ end
136
+ nil
137
+ end
138
+
139
+ def build_chat
140
+ chat = Omnibot.chat_factory.call(model: self.class.model, agent_class: self.class)
141
+ chat.with_instructions(interpolated_instructions) if self.class.instructions
142
+ attach_history(chat)
143
+ tools = tools_for(context).map { |t| t.is_a?(Class) ? t.new(context) : t }
144
+ chat.with_tools(*tools) if tools.any?
145
+ chat
146
+ end
147
+
148
+ def interpolated_instructions
149
+ self.class.instructions.gsub(/\{\{(\w+)\}\}/) do
150
+ context.fetch(Regexp.last_match(1).to_sym).to_s
151
+ end
152
+ end
153
+
154
+ def attach_history(chat)
155
+ # accepts hashes or role/content duck-typed objects (e.g. AR models)
156
+ Array(@history_for_build).each do |m|
157
+ role = m.respond_to?(:role) ? m.role : m[:role]
158
+ content = m.respond_to?(:content) ? m.content : m[:content]
159
+ chat.add_message(role: role.to_sym, content: content)
160
+ end
161
+ end
162
+
163
+ def wrap_stream(stream)
164
+ return nil unless stream
165
+ ->(chunk) { stream.call(chunk.content) if chunk.content && !chunk.content.empty? }
166
+ end
167
+
168
+ def instrument_llm(&)
169
+ ActiveSupport::Notifications.instrument(
170
+ "omnibot.llm.call", agent: self.class, model: self.class.model
171
+ ) do |payload|
172
+ response = yield
173
+ payload[:usage] = usage_from(response)
174
+ response
175
+ end
176
+ end
177
+
178
+ # Per-call usage (used for the omnibot.llm.call event): just the final response.
179
+ def usage_from(response)
180
+ tokens = response.respond_to?(:tokens) ? response.tokens : nil
181
+ Usage.new(tokens&.input || 0, tokens&.output || 0)
182
+ end
183
+
184
+ # Run-level usage (Result#usage / omnibot.agent.run): one real `ask` can be N
185
+ # provider round trips (tool-calling loop), so sum tokens across every message
186
+ # that carries them. FakeChat's plain-hash messages never respond to #tokens,
187
+ # so that falls back to the final response's usage — keeps fake-based specs green.
188
+ def run_usage(chat, response)
189
+ token_messages = chat.messages.select { |m| m.respond_to?(:tokens) && m.tokens }
190
+ return usage_from(response) if token_messages.empty?
191
+
192
+ Usage.new(
193
+ token_messages.sum { |m| m.tokens.input || 0 },
194
+ token_messages.sum { |m| m.tokens.output || 0 }
195
+ )
196
+ end
197
+
198
+ def strip_tools(chat)
199
+ chat.with_tools(replace: true)
200
+ end
201
+
202
+ # C1: against real ruby_llm, complete_once appends the assistant message
203
+ # (with tool_calls) BEFORE before_tool_call fires, so raising TurnLimit out of
204
+ # that hook leaves unanswered tool_call(s) in chat history. The follow-up
205
+ # "answer now" user-role ask then 400s on OpenAI/Anthropic because every
206
+ # tool_call must have a matching tool-result message. With parallel tool
207
+ # calls, some of the batch may already have results while the rest dangle.
208
+ # Duck-typed defensively: FakeChat's messages are plain hashes that don't
209
+ # respond to #tool_calls, so this is a safe no-op there.
210
+ def synthesize_pending_tool_results(chat)
211
+ last_call_message = chat.messages.reverse_each.find do |m|
212
+ m.respond_to?(:tool_calls) && m.tool_calls && !m.tool_calls.empty?
213
+ end
214
+ return unless last_call_message
215
+
216
+ answered_ids = chat.messages.filter_map { |m| m.tool_call_id if m.respond_to?(:tool_call_id) && m.tool_call_id }
217
+
218
+ last_call_message.tool_calls.each_value do |tc|
219
+ next if answered_ids.include?(tc.id)
220
+ chat.add_message(role: :tool, content: "(turn limit reached)", tool_call_id: tc.id)
221
+ end
222
+ end
223
+
224
+ def parse_extraction(content)
225
+ case content
226
+ when Hash then content.deep_symbolize_keys
227
+ when String
228
+ begin
229
+ JSON.parse(content, symbolize_names: true)
230
+ rescue JSON::ParserError => e
231
+ yield e
232
+ end
233
+ else
234
+ yield JSON::ParserError.new("expected a JSON string or Hash, got #{content.class}")
235
+ end
236
+ end
237
+
238
+ def normalize_message(m)
239
+ if m.is_a?(Hash)
240
+ { role: m[:role].to_sym, content: m[:content] }
241
+ else
242
+ { role: m.role.to_sym, content: m.content }
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,21 @@
1
+ module Omnibot
2
+ class Config
3
+ attr_accessor :default_model, :on_tool_error
4
+
5
+ def initialize
6
+ @default_model = "gpt-4o-mini"
7
+ @on_tool_error = :capture
8
+ end
9
+ end
10
+
11
+ class << self
12
+ def config = @config ||= Config.new
13
+ def configure = yield(config)
14
+ def reset_config! = @config = nil
15
+
16
+ def chat_factory
17
+ @chat_factory ||= ->(model:, **) { RubyLLM.chat(model: model) }
18
+ end
19
+ attr_writer :chat_factory
20
+ end
21
+ end
@@ -0,0 +1,7 @@
1
+ module Omnibot
2
+ class Error < StandardError; end
3
+ class LLMError < Error; end
4
+ class ToolError < Error; end
5
+ class ExtractionError < Error; end
6
+ class WorkflowError < Error; end
7
+ end
@@ -0,0 +1,8 @@
1
+ module Omnibot
2
+ Usage = Struct.new(:input_tokens, :output_tokens)
3
+ ToolCallRecord = Struct.new(:name, :arguments)
4
+
5
+ Result = Struct.new(:text, :tool_calls, :usage, :messages, :fast_path, keyword_init: true) do
6
+ def fast_path? = !!fast_path
7
+ end
8
+ end
@@ -0,0 +1,118 @@
1
+ module Omnibot
2
+ module Testing
3
+ FakeTokens = Struct.new(:input, :output)
4
+ FakeMessage = Struct.new(:content, :tokens)
5
+ FakeToolCall = Struct.new(:name, :arguments)
6
+
7
+ class << self
8
+ def fake!
9
+ @original_factory ||= Omnibot.chat_factory
10
+ Omnibot.chat_factory = ->(model:, agent_class: nil, **) {
11
+ FakeChat.new(agent_class: agent_class)
12
+ }
13
+ end
14
+
15
+ def reset!
16
+ Omnibot.chat_factory = @original_factory if @original_factory
17
+ @original_factory = nil
18
+ scripts.clear
19
+ end
20
+
21
+ def scripts = @scripts ||= {}
22
+ def script_for(agent_class) = scripts[agent_class] ||= []
23
+ end
24
+
25
+ class StubBuilder
26
+ def initialize(agent_class) = @script = Testing.script_for(agent_class)
27
+
28
+ def to_call_tool(name, **args)
29
+ @script << [:tool, name.to_s, args]
30
+ self
31
+ end
32
+
33
+ def then_reply(text)
34
+ @script << [:reply, text]
35
+ self
36
+ end
37
+
38
+ def then_extract(hash)
39
+ @script << [:reply, hash]
40
+ self
41
+ end
42
+ end
43
+
44
+ module Helpers
45
+ def stub_agent(agent_class) = StubBuilder.new(agent_class)
46
+ end
47
+
48
+ class FakeChat
49
+ attr_reader :messages, :tool_results
50
+
51
+ def initialize(agent_class: nil)
52
+ @agent_class = agent_class
53
+ @messages = []
54
+ @tools = []
55
+ @before_tool_call_hooks = []
56
+ @tool_results = []
57
+ end
58
+
59
+ def with_instructions(text, **)
60
+ @messages << { role: :system, content: text }
61
+ self
62
+ end
63
+
64
+ def with_tools(*tools, replace: false)
65
+ @tools = [] if replace
66
+ @tools.concat(tools)
67
+ self
68
+ end
69
+
70
+ def add_message(role:, content:)
71
+ @messages << { role: role, content: content }
72
+ self
73
+ end
74
+
75
+ def before_tool_call(&blk)
76
+ @before_tool_call_hooks << blk
77
+ self
78
+ end
79
+
80
+ def with_schema(schema)
81
+ @schema = schema
82
+ self
83
+ end
84
+
85
+ def ask(message, &stream_block)
86
+ @messages << { role: :user, content: message }
87
+ script = Testing.script_for(@agent_class)
88
+
89
+ while (step = script.shift)
90
+ kind, a, b = step
91
+ case kind
92
+ when :tool
93
+ tool_call = FakeToolCall.new(a, b)
94
+ @before_tool_call_hooks.each { |h| h.call(tool_call) }
95
+ tool = @tools.find { |t| t.name.to_s == a }
96
+ raise Omnibot::Error, "FakeChat: no tool #{a.inspect} attached" unless tool
97
+ @tool_results << tool.execute(**b)
98
+ when :reply
99
+ return emit_reply(a, stream_block)
100
+ end
101
+ end
102
+
103
+ emit_reply("(fake) #{message}", stream_block)
104
+ end
105
+
106
+ private
107
+
108
+ def emit_reply(text, stream_block)
109
+ if stream_block && text.is_a?(String)
110
+ text.split(/(?<= )/).each { |chunk| stream_block.call(FakeMessage.new(chunk, nil)) }
111
+ end
112
+ msg = FakeMessage.new(text, FakeTokens.new(10, 10))
113
+ @messages << { role: :assistant, content: text }
114
+ msg
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,48 @@
1
+ module Omnibot
2
+ class Tool < RubyLLM::Tool
3
+ attr_reader :context
4
+
5
+ def initialize(context = {})
6
+ super()
7
+ @context = context
8
+ end
9
+
10
+ # Wrap every subclass's #execute with error capture + instrumentation.
11
+ def self.inherited(subclass)
12
+ super
13
+ subclass.prepend(SafeExecute) unless subclass.ancestors.include?(SafeExecute)
14
+ end
15
+
16
+ def self.from_block(name_sym, description_str, &block)
17
+ Class.new(self) do
18
+ description(description_str)
19
+ define_method(:execute, &block)
20
+ define_method(:name) { name_sym.to_s }
21
+ # Declare params from the block's own signature: SafeExecute shadows
22
+ # #execute with (**kwargs), so ruby_llm's signature inference sees no
23
+ # keywords and would emit an empty schema.
24
+ block.parameters.each do |kind, pname|
25
+ param(pname) if kind == :keyreq
26
+ param(pname, required: false) if kind == :key
27
+ end
28
+ end
29
+ end
30
+
31
+ # NOTE: this wrapper shadows execute's signature (method(:execute).parameters
32
+ # becomes [[:keyrest, :kwargs]]), so ruby_llm signature inference cannot be
33
+ # relied on — class-form tools must declare `param` explicitly.
34
+ module SafeExecute
35
+ def execute(**kwargs)
36
+ ActiveSupport::Notifications.instrument(
37
+ "omnibot.tool.call", tool: self.class, name: name, args: kwargs
38
+ ) do |payload|
39
+ super
40
+ rescue StandardError => e
41
+ payload[:error] = e.message
42
+ raise Omnibot::ToolError, e.message if Omnibot.config.on_tool_error == :raise
43
+ { error: e.message }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ module Omnibot
2
+ VERSION = "0.1.0"
3
+ end
data/lib/omnibot.rb ADDED
@@ -0,0 +1,13 @@
1
+ require "json"
2
+ require "active_support"
3
+ require "active_support/core_ext"
4
+ require "active_support/notifications"
5
+ require "ruby_llm"
6
+
7
+ require_relative "omnibot/version"
8
+ require_relative "omnibot/errors"
9
+ require_relative "omnibot/config"
10
+ require_relative "omnibot/tool"
11
+ require_relative "omnibot/result"
12
+ require_relative "omnibot/agent"
13
+ require_relative "omnibot/testing"
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omnibot-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Johannes Dwicahyo
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.15'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.15'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ description: 'Agent framework for Rails: bounded tool-calling loops, fast paths, structured
41
+ extraction, and (v0.2) durable workflows on ActiveRecord. Built on ruby_llm.'
42
+ email:
43
+ - johannesdwicahyo@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE.txt
49
+ - README.md
50
+ - lib/generators/omnibot/agent/agent_generator.rb
51
+ - lib/generators/omnibot/agent/templates/agent.rb.tt
52
+ - lib/generators/omnibot/agent/templates/agent_spec.rb.tt
53
+ - lib/generators/omnibot/install/install_generator.rb
54
+ - lib/generators/omnibot/install/templates/initializer.rb.tt
55
+ - lib/omnibot.rb
56
+ - lib/omnibot/agent.rb
57
+ - lib/omnibot/config.rb
58
+ - lib/omnibot/errors.rb
59
+ - lib/omnibot/result.rb
60
+ - lib/omnibot/testing.rb
61
+ - lib/omnibot/tool.rb
62
+ - lib/omnibot/version.rb
63
+ homepage: https://github.com/johannesdwicahyo/omnibot-ruby
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ source_code_uri: https://github.com/johannesdwicahyo/omnibot-ruby
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '3.2'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 4.0.13
84
+ specification_version: 4
85
+ summary: Rails-first LLM agents for Ruby
86
+ test_files: []