omakase-agents 0.0.1.alpha
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 +21 -0
- data/README.md +336 -0
- data/lib/omakase/agent.rb +114 -0
- data/lib/omakase/capabilities.rb +38 -0
- data/lib/omakase/doc.rb +24 -0
- data/lib/omakase/executor.rb +51 -0
- data/lib/omakase/fake_chat.rb +38 -0
- data/lib/omakase/generation.rb +7 -0
- data/lib/omakase/request.rb +19 -0
- data/lib/omakase/schema.rb +72 -0
- data/lib/omakase/strategies/code_act.rb +48 -0
- data/lib/omakase/strategies/predict.rb +28 -0
- data/lib/omakase/strategies.rb +18 -0
- data/lib/omakase/tools/ruby.rb +49 -0
- data/lib/omakase/type.rb +25 -0
- data/lib/omakase/version.rb +5 -0
- data/lib/omakase-agents.rb +4 -0
- data/lib/omakase.rb +51 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c1cd37ac842736548d6501feab0ad139df3bad22437902f19ead73ed5eae819c
|
|
4
|
+
data.tar.gz: 733def6d93d950e7bd1507240877d90447dd7359255ba4e3c5bc3d93ce3794c6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 78f54588fa331091f7e604bab6637a31a9f7f14298b0f513df78e243f851079f9181c2c71c627f0a33d5069ec541711adee81e17516916077990b5e1495a4533
|
|
7
|
+
data.tar.gz: 4c97ef7304cce538f6dd4b9d95d6c7053bc6fff9ee6cab88a02b9d44ccdc38e78edfcf95d669a3a9a20fd9f94557d28e05b714eeac3405957d47e1e615f89592
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eugeny
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# Omakase
|
|
2
|
+
|
|
3
|
+
A light agent framework — about 600 lines of library. *Omakase* (お任せ): you name what you want,
|
|
4
|
+
the rest is left to the chef.
|
|
5
|
+
|
|
6
|
+
The whole philosophy: **an agent is an object**. Its fields are state, its methods are what the
|
|
7
|
+
model can call, and the methods it *declares without a body* are written by the model at runtime —
|
|
8
|
+
the method name and prompt are the specification, the schema is the contract.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class InventoryAgent < ApplicationAgent
|
|
12
|
+
instructions "You check inventory."
|
|
13
|
+
|
|
14
|
+
describe "Units of an item on hand"
|
|
15
|
+
def stock_of(item) = STOCK.dig(item, :stock) || 0
|
|
16
|
+
|
|
17
|
+
describe "Unit price of an item"
|
|
18
|
+
def price_of(item) = STOCK.dig(item, :price) || 0.0
|
|
19
|
+
|
|
20
|
+
generates :can_fulfill_order, "Decide whether the order fits the budget and is in stock." do
|
|
21
|
+
boolean :can_fulfill
|
|
22
|
+
number :total_cost
|
|
23
|
+
array :unavailable, of: :string
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
InventoryAgent.can_fulfill_order(items: %w[apple banana orange], budget: 5.0)
|
|
28
|
+
# => {can_fulfill: false, total_cost: 2.05, unavailable: ["orange"]}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
There is no tool abstraction to keep in sync: the model writes Ruby that runs on the agent object,
|
|
32
|
+
reads what it printed and returned, and answers in the declared schema. Adding a tool is adding a
|
|
33
|
+
method; deleting one is deleting a method.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Ruby 3.2+.
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem "omakase-agents" # the library is `Omakase`
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Alpha (`0.0.1.alpha`) and not on RubyGems yet, so until it is, build it from a checkout:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone https://github.com/esshka/omakase
|
|
47
|
+
cd omakase
|
|
48
|
+
gem build omakase-agents.gemspec
|
|
49
|
+
gem install ./omakase-agents-*.gem
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### Declaring an agent
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
class ApplicationAgent < Omakase::Agent # every agent inherits the configuration
|
|
58
|
+
model "claude-sonnet-4-5"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class FeedbackAgent < ApplicationAgent
|
|
62
|
+
instructions "You analyze customer feedback." # the system prompt
|
|
63
|
+
strategy :predict # :code_act (default) or :predict
|
|
64
|
+
|
|
65
|
+
generates :summarize # no prompt given: the method name is the prompt
|
|
66
|
+
generates :score, returns: :integer
|
|
67
|
+
generates :analyze, "Analyze the feedback." do
|
|
68
|
+
string :sentiment, enum: %w[positive negative neutral mixed]
|
|
69
|
+
array :topics, of: :string
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Generation methods take keyword arguments, which are rendered into the prompt, and are callable on
|
|
75
|
+
the instance or on the class:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
FeedbackAgent.analyze(text: "Great product, but shipping was slow")
|
|
79
|
+
# => {sentiment: "mixed", topics: ["product quality", "shipping speed"]}
|
|
80
|
+
|
|
81
|
+
FeedbackAgent.new.analyze(text: "…")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`describe` above an ordinary method is the docstring Ruby does not have — it is what the model reads
|
|
85
|
+
when it decides what to call.
|
|
86
|
+
|
|
87
|
+
### Return types
|
|
88
|
+
|
|
89
|
+
The block is a [schematist](https://github.com/crmne/schematist) schema and becomes the provider's
|
|
90
|
+
structured-output contract, so a generation method returns validated data, never text to parse.
|
|
91
|
+
For a single value, name the type instead:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
generates :count_items, returns: :integer # :string (default), :integer, :number, :boolean
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Both forms are the same mechanism: a schema whose only property is `result` unwraps to that value.
|
|
98
|
+
A Ruby class works too — `returns: Ticket` — and then the method hands back the object rather than
|
|
99
|
+
data; see [`:code_act`](#strategies) for what that requires.
|
|
100
|
+
|
|
101
|
+
### Models and providers
|
|
102
|
+
|
|
103
|
+
Any provider RubyLLM supports — Anthropic, OpenAI, Gemini, Bedrock, Azure, Mistral, DeepSeek, xAI,
|
|
104
|
+
Perplexity, OpenRouter, Ollama, GPUStack, VertexAI. Give the model id, and the provider when the id
|
|
105
|
+
is not one RubyLLM has in its registry:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
class ApplicationAgent < Omakase::Agent
|
|
109
|
+
model "claude-sonnet-4-5" # resolved from the registry
|
|
110
|
+
model "meta/muse-glimmer-30b", provider: :openrouter # taken on trust
|
|
111
|
+
model "qwen3:1.7b", provider: :ollama # local
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Naming a provider implies `assume_model_exists: true`; any other RubyLLM chat option passes through.
|
|
116
|
+
Subclasses inherit the setting and can override it, so one `ApplicationAgent` configures the lot.
|
|
117
|
+
|
|
118
|
+
Credentials come from the environment — one call covers every provider:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
Omakase.configure_from_env # ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OLLAMA_API_BASE, …
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Or set them yourself; `configure` is RubyLLM's, with all of its options:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
Omakase.configure do |config|
|
|
128
|
+
config.anthropic_api_key = Rails.application.credentials.anthropic_api_key
|
|
129
|
+
config.default_model = "claude-sonnet-4-5" # used by agents that declare no model
|
|
130
|
+
config.request_timeout = 120
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Testing
|
|
135
|
+
|
|
136
|
+
`Omakase::Agent.new(chat:)` takes any object that quacks like a `RubyLLM::Chat`, and one ships with
|
|
137
|
+
the library, so agents are tested without a network:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
chat = Omakase::FakeChat.new { {"severity" => "high", "summary" => "…"} }
|
|
141
|
+
assert_equal "high", SupportAgent.new(chat:).triage(message: "broken")[:severity]
|
|
142
|
+
|
|
143
|
+
# drive the tool the way a model would
|
|
144
|
+
chat = Omakase::FakeChat.new { |fake| fake.run("finish(stock_of(:apple))") }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
It records `instructions`, `schema`, `tools` and `tasks`, so the prompt is assertable too.
|
|
148
|
+
|
|
149
|
+
## Rails
|
|
150
|
+
|
|
151
|
+
Agents live in `app/agents` — Rails autoloads it, and reloading is safe because everything a
|
|
152
|
+
generation method needs is rebuilt when the class is. [`examples/rails_app.rb`](examples/rails_app.rb)
|
|
153
|
+
is all of this as one runnable file: initializer, agent, controller, one request.
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# config/initializers/omakase.rb
|
|
157
|
+
Omakase.configure do |config|
|
|
158
|
+
config.anthropic_api_key = Rails.application.credentials.anthropic_api_key
|
|
159
|
+
config.default_model = "claude-sonnet-4-5"
|
|
160
|
+
config.request_timeout = 60 # RubyLLM's default is 300s — too long for a web request
|
|
161
|
+
config.max_retries = 3 # transient provider failures, with backoff
|
|
162
|
+
config.logger = Rails.logger
|
|
163
|
+
config.instrumenter = ActiveSupport::Notifications
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
That last line puts every call on the notification bus, so tokens, cost and latency land in your
|
|
168
|
+
logs and APM without any code of ours:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
ActiveSupport::Notifications.subscribe("chat.ruby_llm") do |*, payload|
|
|
172
|
+
Rails.logger.info(model: payload[:model], input: payload[:input_tokens], output: payload[:output_tokens])
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
A generation call takes seconds, so keep it off the request thread:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
class TriageJob < ApplicationJob
|
|
180
|
+
def perform(ticket) = ticket.update!(SupportAgent.triage(message: ticket.body))
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Jobs move data, not objects: arguments and results have to serialize, so a `returns: SomeClass`
|
|
185
|
+
answer — a live Ruby object — does not survive the trip. [`examples/support_job.rb`](examples/support_job.rb)
|
|
186
|
+
is the runnable version, three tickets triaged concurrently by the async adapter.
|
|
187
|
+
|
|
188
|
+
**Threads.** Puma is multi-threaded and so is this: printing from generated code goes to a
|
|
189
|
+
per-thread buffer, and each call gets its own chat and its own agent instance. Concurrent calls are
|
|
190
|
+
just threads:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
ids.map { |id| Thread.new { WarehouseAgent.appraise(item_id: id) } }.map(&:value)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Sharing one agent instance across threads is your business as usual — its state is yours. Do not
|
|
197
|
+
turn on RubyLLM's `tool_concurrency`: that runs generated code against the same agent in parallel.
|
|
198
|
+
|
|
199
|
+
**Errors.** Everything raised at the boundary is an `Omakase::Error`:
|
|
200
|
+
|
|
201
|
+
| | |
|
|
202
|
+
| --- | --- |
|
|
203
|
+
| `Omakase::ContractError` | the answer did not match the declared return type, twice |
|
|
204
|
+
| `Omakase::ProviderError` | the provider failed — rate limit, overload, bad key — after RubyLLM's retries |
|
|
205
|
+
|
|
206
|
+
Inside a `:code_act` loop neither reaches you: a contract miss and a raised exception both come back
|
|
207
|
+
to the model as an observation, and it tries again within its call budget.
|
|
208
|
+
|
|
209
|
+
## How it works
|
|
210
|
+
|
|
211
|
+
Calling a generation method does four things:
|
|
212
|
+
|
|
213
|
+
1. **Builds a request.** `Request` carries the agent, the generation (prompt, schema, strategy), the
|
|
214
|
+
keyword arguments, and a fresh chat — one conversation per call, no accumulated history.
|
|
215
|
+
2. **Renders the prompt.** The agent's `instructions` are the system prompt; the method's prompt and
|
|
216
|
+
its arguments are the user message.
|
|
217
|
+
3. **Runs the strategy** (below), which is where the LLM work happens.
|
|
218
|
+
4. **Holds it to the contract.** Whether the answer arrived as JSON or as a Ruby value from
|
|
219
|
+
`finish`, it is symbolized, unwrapped if it is a lone `result`, and refused if it is off-contract.
|
|
220
|
+
|
|
221
|
+
### Strategies
|
|
222
|
+
|
|
223
|
+
A strategy is *how* a generation method gets its answer — one call, a code loop, your own
|
|
224
|
+
retry-and-critique scheme. It is an execution detail, not part of the method's contract: swapping
|
|
225
|
+
one for another changes cost and capability, and no caller changes.
|
|
226
|
+
|
|
227
|
+
Formally, a strategy is anything that responds to `call(request)` and returns the cast value. Two
|
|
228
|
+
ship with the library:
|
|
229
|
+
|
|
230
|
+
**`:predict`** — one call. The chat is configured with the instructions and the schema, the task
|
|
231
|
+
goes in, structured output comes back; an answer that misses the schema gets one correction
|
|
232
|
+
turn naming what was wrong. No code runs. Right for classification, extraction, rewriting — anything the model can answer from the prompt alone.
|
|
233
|
+
|
|
234
|
+
**`:code_act`** *(default)* — the model acts by writing Ruby. It gets one tool, `ruby`, whose code
|
|
235
|
+
is `instance_eval`d on the agent, so the agent’s methods and state are the API; anything printed and
|
|
236
|
+
the value of the last expression come back as the observation, and the loop repeats until the model
|
|
237
|
+
calls `finish(value)`. `Capabilities` lists the agent’s own methods (with their `describe` text) in
|
|
238
|
+
the system prompt, minus the method being written, so it cannot recurse into itself. A failure comes
|
|
239
|
+
back with the line that raised, `doc(object)` prints what an object of an unfamiliar type offers, and
|
|
240
|
+
an answer that misses the contract is rejected into the same loop — the model corrects itself without
|
|
241
|
+
another request. Nothing in the provider bounds a tool loop, so the tool does: ten calls, then a turn
|
|
242
|
+
to answer with what it has.
|
|
243
|
+
|
|
244
|
+
Because the answer is computed rather than retyped, the return type can be a Ruby class and the
|
|
245
|
+
method hands back the object itself:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
generates :file_ticket, "File a ticket for the message.", returns: Ticket
|
|
249
|
+
# => #<struct Ticket id="A-1", severity="high">
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
That needs `:code_act` — `:predict` has no code in which to build one. If the model never calls
|
|
253
|
+
`finish`, the strategy falls back to a tool-free turn under the JSON schema.
|
|
254
|
+
|
|
255
|
+
Set the default per agent with `strategy :predict`, per method with
|
|
256
|
+
`generates :triage, strategy: :predict`, or pass your own object:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
module CriticStrategy
|
|
260
|
+
def self.call(request)
|
|
261
|
+
draft = Omakase::Strategies::CodeAct.call(request)
|
|
262
|
+
...
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
generates :plan, strategy: CriticStrategy
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Layout
|
|
270
|
+
|
|
271
|
+
lib/omakase.rb configuration
|
|
272
|
+
lib/omakase/agent.rb the DSL: model, instructions, describe, generates
|
|
273
|
+
lib/omakase/generation.rb a declared method: prompt, schema, strategy
|
|
274
|
+
lib/omakase/request.rb one invocation of one
|
|
275
|
+
lib/omakase/schema.rb return types the provider enforces
|
|
276
|
+
lib/omakase/type.rb return types that are a Ruby class
|
|
277
|
+
lib/omakase/capabilities.rb the agent’s own methods, listed for the model
|
|
278
|
+
lib/omakase/doc.rb what an unfamiliar object offers, for generated code
|
|
279
|
+
lib/omakase/executor.rb runs generated Ruby against the agent
|
|
280
|
+
lib/omakase/tools/ruby.rb that executor, as a RubyLLM tool, with a call budget
|
|
281
|
+
lib/omakase/fake_chat.rb the stand-in chat for tests
|
|
282
|
+
lib/omakase/strategies/ code_act, predict
|
|
283
|
+
|
|
284
|
+
## Examples
|
|
285
|
+
|
|
286
|
+
Copy `.env.example` to `.env` and fill in a key; `MODEL` and `PROVIDER` there pick the backend.
|
|
287
|
+
|
|
288
|
+
| File | Shows |
|
|
289
|
+
| --- | --- |
|
|
290
|
+
| [`feedback_agent.rb`](examples/feedback_agent.rb) | structured output in one call |
|
|
291
|
+
| [`inventory_agent.rb`](examples/inventory_agent.rb) | the agent's methods as the model's tools |
|
|
292
|
+
| [`warehouse_agent.rb`](examples/warehouse_agent.rb) | inspecting objects whose types are unknown |
|
|
293
|
+
| [`support_agent.rb`](examples/support_agent.rb) | plain Ruby orchestrating generated methods |
|
|
294
|
+
| [`support_job.rb`](examples/support_job.rb) | generation off the request thread, via ActiveJob |
|
|
295
|
+
| [`rails_app.rb`](examples/rails_app.rb) | a whole Rails app in one file: initializer, agent, controller |
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
bundle exec rake # tests, no network
|
|
299
|
+
ruby examples/inventory_agent.rb
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Safety
|
|
303
|
+
|
|
304
|
+
Generated code runs with `instance_eval` in your process. In a Rails app that means it can reach
|
|
305
|
+
`ActiveRecord`, `ENV`, and the filesystem — a container does not help, because your app is inside it
|
|
306
|
+
too. Two rules follow:
|
|
307
|
+
|
|
308
|
+
- **Untrusted input (anything a user typed) belongs to `:predict`.** No code runs there.
|
|
309
|
+
- **`:code_act` is for work you control** — internal tooling, workers, isolated environments.
|
|
310
|
+
|
|
311
|
+
What is bounded: ten tool calls per generation, a 30-second timeout per execution, and 4KB of
|
|
312
|
+
observation. What is not: what the code can reach. For real isolation, swap the executor —
|
|
313
|
+
anything answering `call(agent, code, timeout:)` will do:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
Omakase.executor = MySubprocessExecutor # returns an observation String or Executor::Answer
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Roadmap
|
|
320
|
+
|
|
321
|
+
What is not here yet, roughly in the order it would earn its place:
|
|
322
|
+
|
|
323
|
+
- [x] **Live objects** — the answer is computed in code and handed back as the object, not retyped
|
|
324
|
+
as JSON. Done: `finish(value)` plus `returns: SomeClass`.
|
|
325
|
+
- [x] **Tracing** — RubyLLM emits `chat.ruby_llm` and `tool_call.ruby_llm`; point `config.instrumenter`
|
|
326
|
+
at `ActiveSupport::Notifications` and subscribe. Done, by not writing it.
|
|
327
|
+
- [ ] **Conversation history** — the chat is fresh per call. Keeping one per agent would let a
|
|
328
|
+
method continue where the last one left off, at the cost of deciding what to keep.
|
|
329
|
+
- [ ] **Session storage** — persist that history and the agent state so a run can be resumed.
|
|
330
|
+
- [ ] **MCP tools** — external tools over the Model Context Protocol, via `ruby_llm-mcp`. Cheap to
|
|
331
|
+
add, since generated code can call anything the agent exposes.
|
|
332
|
+
- [ ] **Skills** — capabilities as markdown files with front matter, loaded on demand rather than
|
|
333
|
+
all sitting in the system prompt.
|
|
334
|
+
- [ ] **Memory** — recall that survives across sessions, backed by vector search.
|
|
335
|
+
- [x] **Concurrency** — parallel generation calls. Done: output is buffered per thread instead of
|
|
336
|
+
through `$stdout`, so threads no longer collide.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# Fields are state, methods are what the model can call, `generates` declares
|
|
5
|
+
# the methods the model implements.
|
|
6
|
+
class Agent
|
|
7
|
+
class << self
|
|
8
|
+
# The model and any RubyLLM chat option. Naming a provider takes the model
|
|
9
|
+
# id on trust, since providers like OpenRouter or Ollama serve ids that are
|
|
10
|
+
# not in RubyLLM's registry.
|
|
11
|
+
def model(id = nil, **options)
|
|
12
|
+
return chat_options if id.nil? && options.empty?
|
|
13
|
+
|
|
14
|
+
options = {assume_model_exists: true, **options} if options[:provider]
|
|
15
|
+
@chat_options = {model: id, **options}.compact
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def instructions(text = nil)
|
|
19
|
+
return @instructions.to_s if text.nil?
|
|
20
|
+
|
|
21
|
+
@instructions = text
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def strategy(name = nil)
|
|
25
|
+
return @strategy || :code_act if name.nil?
|
|
26
|
+
|
|
27
|
+
@strategy = name
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Documents the method defined next — the docstring Ruby does not have.
|
|
31
|
+
def describe(text)
|
|
32
|
+
@pending_description = text
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Without a prompt, the method name is the prompt.
|
|
36
|
+
def generates(name, prompt = nil, returns: nil, strategy: nil, &schema)
|
|
37
|
+
generations[name] = Generation.new(
|
|
38
|
+
name:,
|
|
39
|
+
prompt: prompt || humanize(name),
|
|
40
|
+
schema: Schema.define(returns:, &schema),
|
|
41
|
+
strategy: Strategies.fetch(strategy || self.strategy)
|
|
42
|
+
)
|
|
43
|
+
define_method(name) { |**inputs| generate(name, inputs) }
|
|
44
|
+
define_singleton_method(name) { |**inputs| new.public_send(name, **inputs) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def generations = @generations ||= {}
|
|
48
|
+
|
|
49
|
+
def descriptions = @descriptions ||= {}
|
|
50
|
+
|
|
51
|
+
def chat_options = @chat_options ||= {}
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def humanize(name)
|
|
56
|
+
text = name.to_s.tr("_", " ").capitalize
|
|
57
|
+
text.end_with?("?", "!") ? text : "#{text}."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def method_added(name)
|
|
61
|
+
super
|
|
62
|
+
descriptions[name] = @pending_description if @pending_description
|
|
63
|
+
@pending_description = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inherited(subclass)
|
|
67
|
+
super
|
|
68
|
+
subclass.instance_variable_set(:@chat_options, chat_options.dup)
|
|
69
|
+
subclass.instructions(instructions) unless instructions.empty?
|
|
70
|
+
subclass.strategy(@strategy) if @strategy
|
|
71
|
+
subclass.generations.merge!(generations)
|
|
72
|
+
subclass.descriptions.merge!(descriptions)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# `chat:` injects a prepared RubyLLM::Chat — the seam for tests.
|
|
77
|
+
def initialize(chat: nil)
|
|
78
|
+
@chat = chat
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A fresh conversation per call.
|
|
82
|
+
def chat = @chat || RubyLLM.chat(**self.class.chat_options)
|
|
83
|
+
|
|
84
|
+
# For generated code meeting an object whose type it does not know.
|
|
85
|
+
def doc(object) = puts(Doc.of(object))
|
|
86
|
+
|
|
87
|
+
# How generated code answers: with the value itself.
|
|
88
|
+
def finish(value) = throw(Executor::RESULT, value)
|
|
89
|
+
|
|
90
|
+
# Printing from generated code goes to the observation, not to the process's
|
|
91
|
+
# stdout — and the buffer is per thread, so concurrent agents stay separate.
|
|
92
|
+
def puts(*args) = omakase_output.puts(*args)
|
|
93
|
+
|
|
94
|
+
def print(*args) = omakase_output.print(*args)
|
|
95
|
+
|
|
96
|
+
def p(*args)
|
|
97
|
+
args.each { |arg| omakase_output.puts(arg.inspect) }
|
|
98
|
+
args.size <= 1 ? args.first : args
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
alias_method :pp, :p
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def omakase_output = Thread.current[Executor::OUTPUT] || $stdout
|
|
106
|
+
|
|
107
|
+
def generate(name, inputs)
|
|
108
|
+
generation = self.class.generations.fetch(name)
|
|
109
|
+
generation.strategy.call(Request.new(agent: self, generation:, inputs:))
|
|
110
|
+
rescue RubyLLM::Error, RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError => e
|
|
111
|
+
raise ProviderError, "#{self.class}##{name}: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# The agent's own methods, written out for the model. Adding a tool is adding
|
|
5
|
+
# a method.
|
|
6
|
+
module Capabilities
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def of(agent_class, except: nil)
|
|
10
|
+
(names(agent_class) - [except]).sort.map { |name| entry(agent_class, name) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def names(agent_class)
|
|
14
|
+
agent_class.ancestors
|
|
15
|
+
.take_while { |mod| mod != Agent }
|
|
16
|
+
.flat_map { |mod| mod.public_instance_methods(false) }
|
|
17
|
+
.uniq
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def entry(agent_class, name)
|
|
21
|
+
signature = "#{name}(#{parameters(agent_class.instance_method(name))})"
|
|
22
|
+
description = agent_class.descriptions[name] || agent_class.generations[name]&.prompt
|
|
23
|
+
description ? "#{signature} — #{description}" : signature
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parameters(method)
|
|
27
|
+
method.parameters.map do |kind, name|
|
|
28
|
+
case kind
|
|
29
|
+
when :key, :keyreq then "#{name}:"
|
|
30
|
+
when :keyrest then "**#{name}"
|
|
31
|
+
when :rest then "*#{name}"
|
|
32
|
+
when :opt then "#{name} = ..."
|
|
33
|
+
else name.to_s
|
|
34
|
+
end
|
|
35
|
+
end.join(", ")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/omakase/doc.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# What an object offers, for a model that has never seen its type: what the
|
|
5
|
+
# object's own classes define, not what Ruby gives everything.
|
|
6
|
+
module Doc
|
|
7
|
+
CORE = [Object, Kernel, BasicObject, Struct, Data, Enumerable, Comparable].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def of(object)
|
|
12
|
+
state = object.instance_variables.map { |name| " #{name} = #{object.instance_variable_get(name).inspect}" }
|
|
13
|
+
[object.class.to_s, *signatures(object), *state].join("\n")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def signatures(object)
|
|
17
|
+
object.class.ancestors
|
|
18
|
+
.take_while { |mod| !CORE.include?(mod) }
|
|
19
|
+
.flat_map { |mod| mod.public_instance_methods(false) }
|
|
20
|
+
.uniq.sort
|
|
21
|
+
.map { |name| " #{name}(#{Capabilities.parameters(object.method(name))})" }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# Runs model-written Ruby in the agent's own context.
|
|
5
|
+
# ponytail: instance_eval is not a sandbox — see Omakase.executor to swap it.
|
|
6
|
+
module Executor
|
|
7
|
+
SOURCE = "(generated)"
|
|
8
|
+
RESULT = :omakase_result
|
|
9
|
+
OUTPUT = :omakase_output
|
|
10
|
+
TIMEOUT = 30
|
|
11
|
+
MAX_OUTPUT = 4_000
|
|
12
|
+
|
|
13
|
+
# What `finish(value)` handed back: the answer as a Ruby value, not as text.
|
|
14
|
+
Answer = Data.define(:value)
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(agent, code, timeout: TIMEOUT)
|
|
19
|
+
printed = StringIO.new
|
|
20
|
+
answer = catch(RESULT) do
|
|
21
|
+
value = capturing(printed) { Timeout.timeout(timeout) { agent.instance_eval(code, SOURCE, 1) } }
|
|
22
|
+
return observation([printed.string.chomp, "=> #{value.inspect}"])
|
|
23
|
+
end
|
|
24
|
+
Answer.new(value: answer)
|
|
25
|
+
rescue ScriptError, StandardError => e
|
|
26
|
+
observation([printed.string.chomp, failure(e, code)])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The model can only fix what it can locate, so point at the line.
|
|
30
|
+
def failure(error, code)
|
|
31
|
+
line = error.backtrace&.grep(/\A#{Regexp.escape(SOURCE)}:\d+/)&.first&.slice(/:(\d+)/, 1)&.to_i
|
|
32
|
+
source = code.lines[line - 1]&.strip if line&.positive?
|
|
33
|
+
["#{error.class}: #{error.message}", ("line #{line}: #{source}" if source)].compact.join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def observation(parts)
|
|
37
|
+
text = parts.reject(&:empty?).join("\n")
|
|
38
|
+
text = "#{text[0, MAX_OUTPUT]}\n… (truncated)" if text.length > MAX_OUTPUT
|
|
39
|
+
text.empty? ? "(no output)" : text
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Thread-local, so concurrent agents never share a buffer. Agent#puts reads it.
|
|
43
|
+
def capturing(io)
|
|
44
|
+
previous = Thread.current[OUTPUT]
|
|
45
|
+
Thread.current[OUTPUT] = io
|
|
46
|
+
yield
|
|
47
|
+
ensure
|
|
48
|
+
Thread.current[OUTPUT] = previous
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# Stands in for a RubyLLM::Chat so agents can be tested without a network:
|
|
5
|
+
# records how the chat was configured, then runs the script you gave it.
|
|
6
|
+
#
|
|
7
|
+
# agent = SupportAgent.new(chat: Omakase::FakeChat.new { {"severity" => "high"} })
|
|
8
|
+
#
|
|
9
|
+
# The script receives the chat, so it can drive the tool the way a model would:
|
|
10
|
+
#
|
|
11
|
+
# Omakase::FakeChat.new { |chat| chat.run("finish(42)") }
|
|
12
|
+
class FakeChat
|
|
13
|
+
Response = Struct.new(:content)
|
|
14
|
+
|
|
15
|
+
attr_reader :instructions, :schema, :tools, :tasks
|
|
16
|
+
|
|
17
|
+
def initialize(&script)
|
|
18
|
+
@script = script
|
|
19
|
+
@instructions = []
|
|
20
|
+
@tools = []
|
|
21
|
+
@tasks = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_instructions(text) = tap { @instructions << text }
|
|
25
|
+
|
|
26
|
+
def with_schema(schema) = tap { @schema = schema }
|
|
27
|
+
|
|
28
|
+
def with_tool(tool, **) = tap { @tools << tool }
|
|
29
|
+
|
|
30
|
+
def ask(task)
|
|
31
|
+
@tasks << task
|
|
32
|
+
Response.new(@script.call(self))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Run code the way the model would, through the agent's one tool.
|
|
36
|
+
def run(code) = tools.fetch(0).call(code:)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# One invocation of a generation method: all a strategy may depend on.
|
|
5
|
+
Request = Data.define(:agent, :generation, :inputs) do
|
|
6
|
+
def chat = agent.chat
|
|
7
|
+
|
|
8
|
+
def schema = generation.schema
|
|
9
|
+
|
|
10
|
+
def instructions = agent.class.instructions
|
|
11
|
+
|
|
12
|
+
def task
|
|
13
|
+
return generation.prompt if inputs.empty?
|
|
14
|
+
|
|
15
|
+
arguments = inputs.map { |name, value| "- #{name}: #{value.inspect}" }
|
|
16
|
+
"#{generation.prompt}\n\nInputs:\n#{arguments.join("\n")}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# The declared return type, enforced by the provider. A schema whose only
|
|
5
|
+
# property is `result` unwraps to that value.
|
|
6
|
+
class Schema
|
|
7
|
+
RESULT = :result
|
|
8
|
+
SCALARS = %i[string integer number boolean].freeze
|
|
9
|
+
RUBY_TYPES = {
|
|
10
|
+
"string" => String, "integer" => Integer, "number" => Numeric,
|
|
11
|
+
"array" => Array, "object" => Hash
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.define(returns: nil, &block)
|
|
15
|
+
return new(Schematist::Schema.create(&block)) if block
|
|
16
|
+
return Type.new(returns) if returns.is_a?(Module)
|
|
17
|
+
|
|
18
|
+
type = returns || :string
|
|
19
|
+
raise Error, "returns: must be one of #{SCALARS.join(", ")}, a class, or a block" unless SCALARS.include?(type)
|
|
20
|
+
|
|
21
|
+
new(Schematist::Schema.create { public_send(type, RESULT) })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :definition
|
|
25
|
+
|
|
26
|
+
def initialize(definition)
|
|
27
|
+
@definition = definition
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def json = @json ||= definition.new.to_json_schema
|
|
31
|
+
|
|
32
|
+
# The shape, in the shorthand the model writes back: `{city: <string>}`.
|
|
33
|
+
def describe
|
|
34
|
+
return "<#{properties.fetch("result")["type"]}>" if wrapped?
|
|
35
|
+
|
|
36
|
+
"{#{properties.map { |name, spec| "#{name}: <#{spec["type"]}>" }.join(", ")}}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# From the provider's JSON: unwrap first, then hold it to the contract.
|
|
40
|
+
def cast(content)
|
|
41
|
+
raise ContractError, "expected JSON matching #{JSON.generate(json)}, got #{content.inspect}" unless content.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
data = RubyLLM::Utils.deep_symbolize_keys(content)
|
|
44
|
+
take(wrapped? ? data.fetch(RESULT) { raise ContractError, %(missing "result" in #{data.inspect}) } : data)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# From a Ruby value the generated code computed.
|
|
48
|
+
def take(value)
|
|
49
|
+
return demand(value, properties.fetch("result")["type"]) if wrapped?
|
|
50
|
+
raise ContractError, "expected #{describe}, got #{value.inspect}" unless value.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
data = RubyLLM::Utils.deep_symbolize_keys(value)
|
|
53
|
+
missing = json.fetch("required").map(&:to_sym) - data.keys
|
|
54
|
+
raise ContractError, "missing #{missing.join(", ")} — expected #{describe}" if missing.any?
|
|
55
|
+
|
|
56
|
+
data
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def wrapped? = definition.properties.keys == [RESULT]
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def properties = json.fetch("properties")
|
|
64
|
+
|
|
65
|
+
def demand(value, type)
|
|
66
|
+
matched = type == "boolean" ? [true, false].include?(value) : value.is_a?(RUBY_TYPES.fetch(type))
|
|
67
|
+
raise ContractError, "expected <#{type}>, got #{value.inspect}" unless matched
|
|
68
|
+
|
|
69
|
+
value
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
module Strategies
|
|
5
|
+
# The model acts by writing Ruby against the agent object and answers with
|
|
6
|
+
# `finish(value)` — the answer is computed, not retyped.
|
|
7
|
+
module CodeAct
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(request)
|
|
11
|
+
tool = Tools::Ruby.new(request.agent, request.schema)
|
|
12
|
+
notes = request.chat
|
|
13
|
+
.with_instructions(instructions(request))
|
|
14
|
+
.with_tool(tool)
|
|
15
|
+
.ask(request.task)
|
|
16
|
+
.content
|
|
17
|
+
|
|
18
|
+
return tool.answer.value if tool.answer
|
|
19
|
+
|
|
20
|
+
# It never called finish: fall back to a tool-free turn under the schema.
|
|
21
|
+
Predict.call(request, task: "#{request.task}\n\nWork done:\n#{notes}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def instructions(request)
|
|
25
|
+
<<~TEXT
|
|
26
|
+
#{request.instructions}
|
|
27
|
+
|
|
28
|
+
You act by writing Ruby: call the `ruby` tool with code that is evaluated on the
|
|
29
|
+
agent object, so its methods and state are available on self.
|
|
30
|
+
|
|
31
|
+
#{capabilities(request).join("\n")}
|
|
32
|
+
|
|
33
|
+
`doc(object)` prints what an object of an unfamiliar type offers.
|
|
34
|
+
|
|
35
|
+
Return the answer from inside the code, never as a message — the last thing you run is:
|
|
36
|
+
|
|
37
|
+
finish(#{request.schema.describe})
|
|
38
|
+
|
|
39
|
+
Work in as few tool calls as you can.
|
|
40
|
+
TEXT
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def capabilities(request)
|
|
44
|
+
Capabilities.of(request.agent.class, except: request.generation.name).map { |entry| "- #{entry}" }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
module Strategies
|
|
5
|
+
# One call, no code execution: the model answers straight into the schema.
|
|
6
|
+
module Predict
|
|
7
|
+
CORRECTION = "Answer again as JSON, and nothing else."
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(request, task: request.task)
|
|
12
|
+
chat = request.chat
|
|
13
|
+
.with_instructions(instructions(request))
|
|
14
|
+
.with_schema(request.schema.definition)
|
|
15
|
+
|
|
16
|
+
request.schema.cast(chat.ask(task).content)
|
|
17
|
+
rescue ContractError => e
|
|
18
|
+
# One correction turn, told exactly what was wrong with the last answer.
|
|
19
|
+
request.schema.cast(chat.ask("#{e.message}\n\n#{CORRECTION}").content)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Weaker providers treat the schema as a hint, so it goes in the prompt too.
|
|
23
|
+
def instructions(request)
|
|
24
|
+
"#{request.instructions}\n\nAnswer as JSON matching this schema:\n#{JSON.generate(request.schema.json)}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# How a generation method gets its answer. Named ones are looked up by symbol
|
|
5
|
+
# (`strategy :predict`); anything that responds to `call(request)` also works,
|
|
6
|
+
# which is the seam for your own.
|
|
7
|
+
module Strategies
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def fetch(strategy)
|
|
11
|
+
return strategy if strategy.respond_to?(:call)
|
|
12
|
+
|
|
13
|
+
const_get(strategy.to_s.split("_").map(&:capitalize).join)
|
|
14
|
+
rescue NameError
|
|
15
|
+
raise Error, "unknown strategy #{strategy.inspect}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
module Tools
|
|
5
|
+
# The model's one tool. Every capability of the agent reaches the model
|
|
6
|
+
# through it, so the model composes calls in code instead of one per turn.
|
|
7
|
+
class Ruby < RubyLLM::Tool
|
|
8
|
+
BUDGET = 10
|
|
9
|
+
|
|
10
|
+
description <<~TEXT
|
|
11
|
+
Evaluate Ruby in the context of the agent object: its methods and state are
|
|
12
|
+
directly available on self. Anything printed, plus the value of the last
|
|
13
|
+
expression, is returned to you. Call finish(value) to answer.
|
|
14
|
+
TEXT
|
|
15
|
+
|
|
16
|
+
param :code, desc: "Ruby source to evaluate."
|
|
17
|
+
|
|
18
|
+
attr_reader :answer
|
|
19
|
+
|
|
20
|
+
def initialize(agent, schema, budget: BUDGET, timeout: Executor::TIMEOUT, executor: Omakase.executor)
|
|
21
|
+
super()
|
|
22
|
+
@agent = agent
|
|
23
|
+
@schema = schema
|
|
24
|
+
@budget = budget
|
|
25
|
+
@timeout = timeout
|
|
26
|
+
@executor = executor
|
|
27
|
+
@calls = 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def name = "ruby"
|
|
31
|
+
|
|
32
|
+
def execute(code:)
|
|
33
|
+
# Nothing bounds the provider's tool loop, so the budget does.
|
|
34
|
+
@calls += 1
|
|
35
|
+
return "No tool calls left — answer with what you have." if @calls == @budget + 1
|
|
36
|
+
return halt("Tool budget spent.") if @calls > @budget + 1
|
|
37
|
+
|
|
38
|
+
outcome = @executor.call(@agent, code, timeout: @timeout)
|
|
39
|
+
return outcome unless outcome.is_a?(Executor::Answer)
|
|
40
|
+
|
|
41
|
+
@answer = Executor::Answer.new(value: @schema.take(outcome.value))
|
|
42
|
+
halt("Answer accepted.")
|
|
43
|
+
rescue Error => e
|
|
44
|
+
# Off-contract answers are corrected inside the same loop, not by another request.
|
|
45
|
+
"finish rejected: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/omakase/type.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# A return type that is a Ruby class: the model builds the object in code and
|
|
5
|
+
# hands the object itself back, so nothing goes through JSON.
|
|
6
|
+
class Type
|
|
7
|
+
def initialize(klass)
|
|
8
|
+
@klass = klass
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def describe = "a #{@klass}"
|
|
12
|
+
|
|
13
|
+
def take(value)
|
|
14
|
+
return value if value.is_a?(@klass)
|
|
15
|
+
|
|
16
|
+
raise ContractError, "expected #{describe}, got #{value.class}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def definition
|
|
20
|
+
raise Error, "returns: #{@klass} needs the :code_act strategy — the model has to build the object in code"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
alias_method :json, :definition
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/omakase.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Object-oriented agents: an agent is a Ruby object. Its methods are its tools,
|
|
4
|
+
# and the methods it *declares* but does not implement are written by an LLM.
|
|
5
|
+
require "json"
|
|
6
|
+
require "ruby_llm"
|
|
7
|
+
require "schematist"
|
|
8
|
+
require "stringio"
|
|
9
|
+
require "timeout"
|
|
10
|
+
require "zeitwerk"
|
|
11
|
+
|
|
12
|
+
loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
|
|
13
|
+
loader.ignore("#{__dir__}/omakase-agents.rb")
|
|
14
|
+
loader.setup
|
|
15
|
+
|
|
16
|
+
module Omakase
|
|
17
|
+
Error = Class.new(StandardError)
|
|
18
|
+
# The answer did not match the declared return type.
|
|
19
|
+
ContractError = Class.new(Error)
|
|
20
|
+
# The model or its provider failed. RubyLLM has already retried what it retries.
|
|
21
|
+
ProviderError = Class.new(Error)
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Providers, keys, default model, timeouts, logging — all of it is RubyLLM's.
|
|
25
|
+
def configure(&) = RubyLLM.configure(&)
|
|
26
|
+
|
|
27
|
+
# Reads every provider credential RubyLLM knows from the environment:
|
|
28
|
+
# ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OLLAMA_API_BASE, VERTEXAI_PROJECT_ID, …
|
|
29
|
+
def configure_from_env(env = ENV)
|
|
30
|
+
configure do |config|
|
|
31
|
+
provider_options.each do |option|
|
|
32
|
+
value = env[option.to_s.upcase]
|
|
33
|
+
config.public_send(:"#{option}=", value) if value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def provider_options
|
|
39
|
+
slugs = RubyLLM::Provider.providers.keys
|
|
40
|
+
RubyLLM::Configuration.options.select do |option|
|
|
41
|
+
slugs.any? { |slug| option.to_s.start_with?("#{slug}_") }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Where generated code runs. Anything answering `call(agent, code, timeout:)`
|
|
46
|
+
# will do — swap in a subprocess or a container to get real isolation.
|
|
47
|
+
attr_writer :executor
|
|
48
|
+
|
|
49
|
+
def executor = @executor ||= Executor
|
|
50
|
+
end
|
|
51
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: omakase-agents
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1.alpha
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- eugeny
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-12 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: ruby_llm
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.16'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.16'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: schematist
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.1'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.1'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: zeitwerk
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '2.7'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '2.7'
|
|
55
|
+
description: |
|
|
56
|
+
A light agent framework on top of RubyLLM. An agent is an object: its fields are state, its
|
|
57
|
+
methods are what the model can call, and the methods it declares without a body are written by
|
|
58
|
+
the model at runtime — the method name and prompt are the specification, the schema is the
|
|
59
|
+
contract. The model acts by writing Ruby that runs on the agent itself.
|
|
60
|
+
email:
|
|
61
|
+
- esshka@gmail.com
|
|
62
|
+
executables: []
|
|
63
|
+
extensions: []
|
|
64
|
+
extra_rdoc_files: []
|
|
65
|
+
files:
|
|
66
|
+
- LICENSE
|
|
67
|
+
- README.md
|
|
68
|
+
- lib/omakase-agents.rb
|
|
69
|
+
- lib/omakase.rb
|
|
70
|
+
- lib/omakase/agent.rb
|
|
71
|
+
- lib/omakase/capabilities.rb
|
|
72
|
+
- lib/omakase/doc.rb
|
|
73
|
+
- lib/omakase/executor.rb
|
|
74
|
+
- lib/omakase/fake_chat.rb
|
|
75
|
+
- lib/omakase/generation.rb
|
|
76
|
+
- lib/omakase/request.rb
|
|
77
|
+
- lib/omakase/schema.rb
|
|
78
|
+
- lib/omakase/strategies.rb
|
|
79
|
+
- lib/omakase/strategies/code_act.rb
|
|
80
|
+
- lib/omakase/strategies/predict.rb
|
|
81
|
+
- lib/omakase/tools/ruby.rb
|
|
82
|
+
- lib/omakase/type.rb
|
|
83
|
+
- lib/omakase/version.rb
|
|
84
|
+
homepage: https://github.com/esshka/omakase
|
|
85
|
+
licenses:
|
|
86
|
+
- MIT
|
|
87
|
+
metadata:
|
|
88
|
+
homepage_uri: https://github.com/esshka/omakase
|
|
89
|
+
source_code_uri: https://github.com/esshka/omakase
|
|
90
|
+
bug_tracker_uri: https://github.com/esshka/omakase/issues
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
92
|
+
post_install_message:
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '3.2'
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">"
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: 1.3.1
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 3.4.10
|
|
108
|
+
signing_key:
|
|
109
|
+
specification_version: 4
|
|
110
|
+
summary: Agents as plain Ruby objects.
|
|
111
|
+
test_files: []
|