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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +209 -0
- data/lib/generators/omnibot/agent/agent_generator.rb +14 -0
- data/lib/generators/omnibot/agent/templates/agent.rb.tt +8 -0
- data/lib/generators/omnibot/agent/templates/agent_spec.rb.tt +14 -0
- data/lib/generators/omnibot/install/install_generator.rb +13 -0
- data/lib/generators/omnibot/install/templates/initializer.rb.tt +4 -0
- data/lib/omnibot/agent.rb +246 -0
- data/lib/omnibot/config.rb +21 -0
- data/lib/omnibot/errors.rb +7 -0
- data/lib/omnibot/result.rb +8 -0
- data/lib/omnibot/testing.rb +118 -0
- data/lib/omnibot/tool.rb +48 -0
- data/lib/omnibot/version.rb +3 -0
- data/lib/omnibot.rb +13 -0
- metadata +86 -0
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,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,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,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
|
data/lib/omnibot/tool.rb
ADDED
|
@@ -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
|
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: []
|