omakase-agents 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -0
- data/README.md +324 -14
- data/lib/omakase/agent.rb +60 -9
- data/lib/omakase/capabilities.rb +3 -1
- data/lib/omakase/doc.rb +20 -4
- data/lib/omakase/executor.rb +9 -4
- data/lib/omakase/fake_chat.rb +4 -2
- data/lib/omakase/generation.rb +3 -2
- data/lib/omakase/request.rb +16 -4
- data/lib/omakase/schema.rb +4 -1
- data/lib/omakase/strategies/code_act.rb +2 -2
- data/lib/omakase/strategies/predict.rb +1 -1
- data/lib/omakase/tools/ruby.rb +7 -3
- data/lib/omakase/trace.rb +45 -0
- data/lib/omakase/type.rb +15 -2
- data/lib/omakase/version.rb +1 -1
- data/lib/omakase.rb +35 -2
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d0a1114c3a9af5c41d3111880844a2a74a182b23696226533c3d5076b3a3ce63
|
|
4
|
+
data.tar.gz: '049c62448ea9e6d3b96177bda1564b294cb47d08cc7aa7a514db321d0f617394'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c1fb9143de3b2b934eb183e6c396a81a06d64ac8b505f28f0a311c2411aea56c09e64207f3c5789554f88bca34bbc0a9fe7654c276775383e559769f269d4775
|
|
7
|
+
data.tar.gz: 94f16e8c77584c2a0c120e93f470acd5ba46991c7503be695d267c4dae95b8e7124360121a0186ace47c8c4dea17caf4cb147d1abc3d86d8b07e6594c4cd3321
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
One entry per released version, written when the gem is pushed. Until `1.0`, a minor version may
|
|
4
|
+
move the API — what breaks is listed first, so an upgrade is a decision rather than a surprise.
|
|
5
|
+
|
|
6
|
+
## 0.3.0
|
|
7
|
+
|
|
8
|
+
One thing changes under you: a generation may no longer call itself. The rest is additions — a real
|
|
9
|
+
signature for the inputs, a trace to read a run by, a seam for the chat, and your own validations
|
|
10
|
+
enforced on the way out.
|
|
11
|
+
|
|
12
|
+
### Breaking
|
|
13
|
+
|
|
14
|
+
- A generation may not re-enter itself. While `SupportAgent#reply` is running on an object, that
|
|
15
|
+
object's `reply` raises `Omakase::Error` instead of opening a second run. Generated code can see
|
|
16
|
+
the method and call it, and each nested call opened its own chat with its own tool budget — so the
|
|
17
|
+
budget bounded nothing. A *fresh* agent may still recurse: that is the sub-agents pattern, one
|
|
18
|
+
object per node of a tree, and the tree is the thing that ends.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `takes:` names the keyword arguments, and then Ruby checks them:
|
|
23
|
+
`generates :translate, takes: %i[text language]`. A missing or misspelled argument is an
|
|
24
|
+
`ArgumentError` at the call rather than noise in a prompt, and the model reads the names instead
|
|
25
|
+
of `**inputs`. `with:` stays available for attachments. Anything that is not a plain keyword name
|
|
26
|
+
is refused where it is declared.
|
|
27
|
+
- `Omakase::Trace` — the listener printed for a human: `Omakase.listener = Omakase::Trace.new`. A run
|
|
28
|
+
reads top to bottom: the call, the code the model wrote, the answer. Colour when the stream is a
|
|
29
|
+
terminal, plain when it is a log.
|
|
30
|
+
- `Omakase.chat_factory` — how an agent gets a chat when none was injected. One line in
|
|
31
|
+
`test_helper.rb` keeps a whole suite off the network, including the class-level calls a job makes,
|
|
32
|
+
which have no seam to inject through. Anything answering `call(**options)` will do, and an
|
|
33
|
+
injected `chat:` still wins.
|
|
34
|
+
- A `returns:` class that answers `valid?` and `errors` — which is every ActiveModel — is asked
|
|
35
|
+
before the answer is handed back, and an invalid one is refused. Under `:code_act` the refusal
|
|
36
|
+
reaches the model as `finish rejected: Post is invalid: …`, and it corrects itself inside the same
|
|
37
|
+
loop. Your validations are the contract, and they stay where you wrote them.
|
|
38
|
+
- `doc(object)` takes a class as well as an instance: what an object of that type would offer, plus
|
|
39
|
+
the column names when it is a record. The model asks before it builds a type it has only been told
|
|
40
|
+
the name of.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- What generated code printed before `finish` is no longer lost — `Executor::Answer` carries it, so a
|
|
45
|
+
trace shows the working and not only the answer. `printed:` defaults, so a replacement executor
|
|
46
|
+
that knows the value alone still satisfies the seam.
|
|
47
|
+
|
|
48
|
+
## 0.2.0
|
|
49
|
+
|
|
50
|
+
Nothing breaks. Four additions, each one a keyword or a seam that costs nothing when unused.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- A prompt can be a block: `generates :translate, -> { "Translate to #{@language}." }`. It is read
|
|
55
|
+
at call time, on the agent, so one declaration serves an object however it is configured. A prompt
|
|
56
|
+
that is neither a String nor a block is refused where it is declared. In the capability list a
|
|
57
|
+
block-prompted method shows its signature alone — `describe` it to say more.
|
|
58
|
+
- `generates :classify, model: "claude-haiku-4-5"` — a generation method can name its own model, a
|
|
59
|
+
cheap one beside a strong one. The id lands on top of the class's chat options, so the provider
|
|
60
|
+
stays the class's; an injected `chat:` still wins.
|
|
61
|
+
- `Omakase.listener` — one callback for every step as it happens: `:generation` (`agent:, name:,
|
|
62
|
+
inputs:`), `:ruby` (`agent:, code:, outcome:`), `:answer` (`agent:, name:, value:`). Anything
|
|
63
|
+
answering `call(event, **payload)` will do; nil, the default, costs nothing.
|
|
64
|
+
- `with:` is a reserved argument: it is not rendered into the prompt but passed to RubyLLM's
|
|
65
|
+
`ask(with:)` as attachments — images, audio, PDFs, as paths, URLs or IO. `FakeChat` records them.
|
|
66
|
+
|
|
67
|
+
## 0.1.0
|
|
68
|
+
|
|
69
|
+
First stable release. `0.0.x` was a prerelease and needed `gem install --pre`; this does not.
|
|
70
|
+
|
|
71
|
+
### Breaking
|
|
72
|
+
|
|
73
|
+
- `:code_act` with a Ruby class return type (`returns: SomeClass`) no longer falls back to
|
|
74
|
+
`:predict` when the model does not call `finish`. It could not have worked — a class cannot be
|
|
75
|
+
expressed as a JSON schema — and the fallback raised `returns: X needs the :code_act strategy`
|
|
76
|
+
from inside the wrong strategy. It now raises `ContractError` naming the method and the shape it
|
|
77
|
+
never returned. Schemas declared with a block or a scalar still fall back as before.
|
|
78
|
+
- `doc(object)` stops at `ActiveRecord::Base` and prints a record's columns as state rather than as
|
|
79
|
+
methods. Output for an `ActiveRecord` object goes from several hundred lines of framework
|
|
80
|
+
internals to its associations, your methods, and the values it holds.
|
|
81
|
+
- The prompt asks for `finish(Refund.new(order_id:, amount:, reason:))` where it used to say
|
|
82
|
+
`finish(a Refund)`, for any return type that answers `members` (a `Struct` or a `Data`).
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
|
|
86
|
+
- `mcp` — an MCP server's tools become methods on the agent, via `ruby_llm-mcp` (an optional
|
|
87
|
+
dependency; `require` it and options pass through verbatim).
|
|
88
|
+
- `skill` — a `SKILL.md` directory becomes one described method: the front matter's description
|
|
89
|
+
joins the agent's capabilities, the body arrives only when the model calls it.
|
|
90
|
+
- `memory` — `remember(text)` and `recall(query)` over RubyLLM embeddings, with the store as a
|
|
91
|
+
field, so it marshals with the agent.
|
|
92
|
+
- `context` — an instance method to override; whatever it returns is appended to the class's
|
|
93
|
+
instructions on every call. The chat stays fresh per call, deliberately.
|
|
94
|
+
- `Marshal.dump(agent)` is the session: an agent marshals like any object, minus its live chat.
|
|
95
|
+
|
|
96
|
+
## 0.0.2.alpha, 0.0.1.alpha
|
|
97
|
+
|
|
98
|
+
Prereleases. `Agent`, `generates`, `describe`, the `:predict` and `:code_act` strategies,
|
|
99
|
+
`finish(value)`, `returns: SomeClass`, `doc`, per-thread output, `FakeChat`, and the Rails notes.
|
data/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Omakase
|
|
2
2
|
|
|
3
|
-
A light agent framework — about
|
|
3
|
+
A light agent framework — about 800 lines of library. *Omakase* (お任せ): you name what you want,
|
|
4
4
|
the rest is left to the chef.
|
|
5
5
|
|
|
6
|
-
**[esshka.github.io/omakase](https://esshka.github.io/omakase/)** · [rubygems](https://rubygems.org/gems/omakase-agents) 
|
|
6
|
+
**[esshka.github.io/omakase](https://esshka.github.io/omakase/)** · [rubygems](https://rubygems.org/gems/omakase-agents) · [changelog](CHANGELOG.md) 
|
|
7
7
|
|
|
8
8
|
The whole philosophy: **an agent is an object**. Its fields are state, its methods are what the
|
|
9
9
|
model can call, and the methods it *declares without a body* are written by the model at runtime —
|
|
@@ -54,7 +54,7 @@ method; deleting one is deleting a method.
|
|
|
54
54
|
## Why this
|
|
55
55
|
|
|
56
56
|
Against **RubyLLM alone**: the tool loop, the schema plumbing, and the correction turn after a bad
|
|
57
|
-
answer are what these
|
|
57
|
+
answer are what these 800 lines are. Everything else — providers, keys, models, streaming, tracing —
|
|
58
58
|
is still RubyLLM's, and stays reachable.
|
|
59
59
|
|
|
60
60
|
Against **a framework with a tool registry**: there is nothing to register and nothing to keep in
|
|
@@ -79,6 +79,53 @@ gem "omakase-agents" # the library is `Omakase`
|
|
|
79
79
|
gem install omakase-agents
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
## Quickstart
|
|
83
|
+
|
|
84
|
+
One file, one working agent. Five minutes.
|
|
85
|
+
|
|
86
|
+
**1.** Install the gem and set a key:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
gem install omakase-agents
|
|
90
|
+
export OPENROUTER_API_KEY=sk-or-...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**2.** Save this as `triage.rb`:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
require "omakase"
|
|
97
|
+
|
|
98
|
+
Omakase.configure_from_env
|
|
99
|
+
|
|
100
|
+
class TriageAgent < Omakase::Agent
|
|
101
|
+
model "meta/muse-glimmer-30b", provider: :openrouter
|
|
102
|
+
instructions "You triage customer support messages."
|
|
103
|
+
strategy :predict
|
|
104
|
+
|
|
105
|
+
generates :triage do
|
|
106
|
+
string :severity, enum: %w[low medium high]
|
|
107
|
+
string :summary
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
pp TriageAgent.triage(message: "The app crashes every time I open my invoices")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**3.** Run it:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
ruby triage.rb
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A Ruby Hash comes back, matching the schema you declared:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
{severity: "high", summary: "App crashes on opening invoices"}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
You wrote no JSON parsing and registered no tool. Next: [Usage](#usage) for the rest of the
|
|
127
|
+
API, or [How it works](#how-it-works) for why it is built this way.
|
|
128
|
+
|
|
82
129
|
## Usage
|
|
83
130
|
|
|
84
131
|
The whole API, in one class:
|
|
@@ -136,9 +183,37 @@ FeedbackAgent.analyze(text: "Great product, but shipping was slow")
|
|
|
136
183
|
FeedbackAgent.new.analyze(text: "…")
|
|
137
184
|
```
|
|
138
185
|
|
|
186
|
+
By default they take whatever you pass. Name them with `takes:` and they become a real Ruby
|
|
187
|
+
signature, so a missing or misspelled argument is an `ArgumentError` at the call rather than noise
|
|
188
|
+
in a prompt — and the model reads the names instead of `**inputs`:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
generates :decide, "Decide this refund.", takes: %i[email complaint], returns: Refund
|
|
192
|
+
|
|
193
|
+
RefundAgent.decide(email: "ada@example.com") # => ArgumentError: missing keyword: :complaint
|
|
194
|
+
RefundAgent.decide(emial: "…", complaint: "…") # => ArgumentError: unknown keyword: :emial
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`with:` stays available on a named signature, since attachments are not part of the prompt.
|
|
139
198
|
`describe` above an ordinary method is the docstring Ruby does not have — it is what the model reads
|
|
140
199
|
when it decides what to call.
|
|
141
200
|
|
|
201
|
+
A prompt given as a block is read at call time, on the agent, so one declaration serves an object
|
|
202
|
+
however it happens to be configured:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
class TranslatorAgent < ApplicationAgent
|
|
206
|
+
def initialize(language, **options)
|
|
207
|
+
super(**options)
|
|
208
|
+
@language = language
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
generates :translate, -> { "Translate to #{@language}, naturally and idiomatically." }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
TranslatorAgent.new("Spanish").translate(text: "good morning") # => "buenos días"
|
|
215
|
+
```
|
|
216
|
+
|
|
142
217
|
### Return types
|
|
143
218
|
|
|
144
219
|
The block is a [schematist](https://github.com/crmne/schematist) schema and becomes the provider's
|
|
@@ -151,7 +226,23 @@ generates :count_items, returns: :integer # :string (default), :integer, :n
|
|
|
151
226
|
|
|
152
227
|
Both forms are the same mechanism: a schema whose only property is `result` unwraps to that value.
|
|
153
228
|
A Ruby class works too — `returns: Ticket` — and then the method hands back the object rather than
|
|
154
|
-
data; see [`:code_act`](#strategies) for what that requires.
|
|
229
|
+
data; see [`:code_act`](#strategies) for what that requires. If that object can say whether it is
|
|
230
|
+
well-formed — anything answering `valid?` and `errors`, which is every ActiveModel — it is asked, and
|
|
231
|
+
an invalid one is refused.
|
|
232
|
+
|
|
233
|
+
### Attachments
|
|
234
|
+
|
|
235
|
+
`with:` is a reserved argument: it is not rendered into the prompt but sent as attachments —
|
|
236
|
+
images, audio, PDFs — exactly as RubyLLM's `ask(with:)` takes them (paths, URLs, IO objects):
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
class VisionAgent < ApplicationAgent
|
|
240
|
+
generates :caption, "Describe the photo.", strategy: :predict
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
VisionAgent.caption(with: "photo.jpg")
|
|
244
|
+
VisionAgent.caption(question: "what breed?", with: ["a.png", "b.png"])
|
|
245
|
+
```
|
|
155
246
|
|
|
156
247
|
### Models and providers
|
|
157
248
|
|
|
@@ -170,6 +261,13 @@ end
|
|
|
170
261
|
Naming a provider implies `assume_model_exists: true`; any other RubyLLM chat option passes through.
|
|
171
262
|
Subclasses inherit the setting and can override it, so one `ApplicationAgent` configures the lot.
|
|
172
263
|
|
|
264
|
+
A generation method can name its own model — a cheap one for classification beside a strong one
|
|
265
|
+
for reasoning. The id lands on top of the class's options, so the provider stays the class's:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
generates :classify, "Sort this ticket into a queue.", model: "claude-haiku-4-5"
|
|
269
|
+
```
|
|
270
|
+
|
|
173
271
|
Credentials come from the environment — one call covers every provider:
|
|
174
272
|
|
|
175
273
|
```ruby
|
|
@@ -206,6 +304,10 @@ The connection opens when the class is defined and the tools are read from the s
|
|
|
206
304
|
tool's arguments reach the model as documentation. A failed call raises, which the model sees and
|
|
207
305
|
can correct. Only text comes back: an image or audio result is dropped.
|
|
208
306
|
|
|
307
|
+
That it happens at class-definition time has a cost worth knowing: under `config.eager_load = true`
|
|
308
|
+
an unreachable server fails the boot, and every reload in development reconnects. If a deploy must
|
|
309
|
+
not wait on a sidecar, keep MCP agents out of the eager-loaded paths.
|
|
310
|
+
|
|
209
311
|
### Skills
|
|
210
312
|
|
|
211
313
|
A skill is a directory with a `SKILL.md` — the same YAML front matter Claude Code and friends use.
|
|
@@ -220,6 +322,10 @@ class CommitAgent < ApplicationAgent
|
|
|
220
322
|
end
|
|
221
323
|
```
|
|
222
324
|
|
|
325
|
+
The path is expanded against the working directory, which is `Rails.root` until something — a job
|
|
326
|
+
runner, a systemd unit — decides otherwise, so `Rails.root.join("app/agents/skills/commit_style")`
|
|
327
|
+
is the spelling that keeps working.
|
|
328
|
+
|
|
223
329
|
That is the whole of “loaded on demand”: the one-line description is in the prompt, the body only
|
|
224
330
|
reaches the model if the generated code calls `commit_style`. Anything else the skill ships —
|
|
225
331
|
scripts, templates — sits in the same directory, and the body ends with its path, so generated Ruby
|
|
@@ -283,13 +389,61 @@ them — `remember("Shipping to Canada takes three weeks")` on the way out, `rec
|
|
|
283
389
|
on the way in. The store is a field, so what the agent learned marshals with it and is there on the
|
|
284
390
|
next run.
|
|
285
391
|
|
|
286
|
-
Embeddings come from RubyLLM
|
|
287
|
-
tests offline
|
|
392
|
+
Embeddings come from RubyLLM, and `Omakase.embedder` is the seam if you want another source — a
|
|
393
|
+
fake one keeps tests offline, and any provider fits through it:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
# RubyLLM defaults to an OpenAI embedding model; OpenRouter serves embeddings too,
|
|
397
|
+
# it just does not list them, so the model is named on trust.
|
|
398
|
+
Omakase.embedder = ->(text) do
|
|
399
|
+
RubyLLM.embed(text, model: "qwen/qwen3-embedding-4b", provider: :openrouter,
|
|
400
|
+
assume_model_exists: true).vectors
|
|
401
|
+
end
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The search is a dot product over unit vectors. That holds for the few hundred
|
|
288
405
|
things one agent learns about its work; past that it is your database's job — pgvector and the
|
|
289
406
|
[`neighbor`](https://github.com/ankane/neighbor) gem — and `Omakase::Memory` is the interface to
|
|
290
407
|
reimplement against it. And for a few dozen facts, `@notes.grep(/shipping/)` beats every word of
|
|
291
408
|
this.
|
|
292
409
|
|
|
410
|
+
### Sub-agents
|
|
411
|
+
|
|
412
|
+
Some work is a tree, and the tree is usually already in your database — a comment thread, a category
|
|
413
|
+
tree, a bill of materials. One agent per node folds it from the leaves up, and the recursion belongs
|
|
414
|
+
to the data: a node with no children is the base case, so nothing has to invent how deep to go.
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
class ThreadAgent < ApplicationAgent
|
|
418
|
+
instructions "You sum up a discussion for someone who has not read it."
|
|
419
|
+
strategy :predict
|
|
420
|
+
|
|
421
|
+
def initialize(comment, **options)
|
|
422
|
+
super(**options)
|
|
423
|
+
@comment = comment
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def roll_up
|
|
427
|
+
return said if @comment.replies.empty?
|
|
428
|
+
|
|
429
|
+
summarise(comment: said, replies: @comment.replies.map { |reply| self.class.new(reply).roll_up })
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
generates :summarise, "Sum up this comment together with the replies it drew.", returns: :string
|
|
433
|
+
|
|
434
|
+
private
|
|
435
|
+
|
|
436
|
+
def said = "#{@comment.author}: #{@comment.body}"
|
|
437
|
+
end
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
A leaf is its own summary, so the model is asked only where there is something to fold. A fresh
|
|
441
|
+
agent per branch is not ceremony either: siblings then share no state, and one object may not
|
|
442
|
+
re-enter a generation it is already inside. That is refused, because a nested run opens its own chat
|
|
443
|
+
with its own tool budget — nothing would bound the spend. Generated code can start a sub-agent the
|
|
444
|
+
same way. [`examples/recursive_agent.rb`](examples/recursive_agent.rb) is the runnable version: four
|
|
445
|
+
comments, two of them leaves, two generations.
|
|
446
|
+
|
|
293
447
|
### Testing
|
|
294
448
|
|
|
295
449
|
`Omakase::Agent.new(chat:)` takes any object that quacks like a `RubyLLM::Chat`, and one ships with
|
|
@@ -305,6 +459,58 @@ chat = Omakase::FakeChat.new { |fake| fake.run("finish(stock_of(:apple))") }
|
|
|
305
459
|
|
|
306
460
|
It records `instructions`, `schema`, `tools` and `tasks`, so the prompt is assertable too.
|
|
307
461
|
|
|
462
|
+
Injecting a chat covers the agent you are testing. A suite covers everything else, including the
|
|
463
|
+
class-level calls a job makes — `SupportAgent.triage(message:)` builds its own agent and has no seam
|
|
464
|
+
to inject through. `Omakase.chat_factory` is that seam, and one line in `test_helper.rb` puts the
|
|
465
|
+
whole suite off the network:
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
# test/test_helper.rb
|
|
469
|
+
Omakase.chat_factory = ->(**) { Omakase::FakeChat.new { raise "an agent asked for a model" } }
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Make it raise, and any generation you forgot to stub fails loudly instead of quietly calling a
|
|
473
|
+
provider from CI. An injected `chat:` still wins, so the tests that mean to run an agent keep
|
|
474
|
+
working. Anything answering `call(**options)` will do; the options are the class's chat options, so
|
|
475
|
+
a factory can assert the model too.
|
|
476
|
+
|
|
477
|
+
### Listening in
|
|
478
|
+
|
|
479
|
+
One callback hears every step as it happens: a generation starting, model-written code running,
|
|
480
|
+
an answer landing. Wire it to a logger or a tracer; nil, the default, costs nothing.
|
|
481
|
+
|
|
482
|
+
```ruby
|
|
483
|
+
Omakase.listener = ->(event, **payload) { Rails.logger.info("#{event} #{payload.except(:agent)}") }
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
`:generation` carries `agent:, name:, inputs:` · `:ruby` carries `agent:, code:, outcome:` ·
|
|
487
|
+
`:answer` carries `agent:, name:, value:`.
|
|
488
|
+
|
|
489
|
+
One listener is included, for reading a run rather than storing it: it prints each step to stderr,
|
|
490
|
+
in colour when stderr is a terminal.
|
|
491
|
+
|
|
492
|
+
```ruby
|
|
493
|
+
Omakase.listener = Omakase::Trace.new
|
|
494
|
+
|
|
495
|
+
# → SupportAgent#triage
|
|
496
|
+
# message: "my mug arrived cracked"
|
|
497
|
+
# · ruby
|
|
498
|
+
# order = order_db.find(1)
|
|
499
|
+
# puts "eligible: #{refund_eligible?(order)}"
|
|
500
|
+
# finish(Ticket.new("A-1", :high))
|
|
501
|
+
# eligible: true
|
|
502
|
+
# finish #<struct Ticket id="A-1", severity=:high>
|
|
503
|
+
# ← SupportAgent#triage
|
|
504
|
+
# #<struct Ticket id="A-1", severity=:high>
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
In Rails there is already a bus for this, and one line puts the events on it — subscribers and your
|
|
508
|
+
APM pick them up with nothing further:
|
|
509
|
+
|
|
510
|
+
```ruby
|
|
511
|
+
Omakase.listener = ->(event, **payload) { ActiveSupport::Notifications.instrument("#{event}.omakase", payload) }
|
|
512
|
+
```
|
|
513
|
+
|
|
308
514
|
## Rails
|
|
309
515
|
|
|
310
516
|
Agents live in `app/agents` — Rails autoloads it, and reloading is safe because everything a
|
|
@@ -344,17 +550,107 @@ Jobs move data, not objects: arguments and results have to serialize, so a `retu
|
|
|
344
550
|
answer — a live Ruby object — does not survive the trip. [`examples/support_job.rb`](examples/support_job.rb)
|
|
345
551
|
is the runnable version, three tickets triaged concurrently by the async adapter.
|
|
346
552
|
|
|
553
|
+
**Validations are the contract.** A `returns:` class that answers `valid?` and `errors` gets asked
|
|
554
|
+
before the answer is handed back, so a generation cannot return a record your own validations
|
|
555
|
+
reject:
|
|
556
|
+
|
|
557
|
+
```ruby
|
|
558
|
+
class Post < ApplicationRecord
|
|
559
|
+
validates :slug, format: {with: /\A[a-z0-9-]+\z/}, length: {maximum: 12}
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
class BlogAgent < ApplicationAgent
|
|
563
|
+
generates :write, "Write a post about the topic.", returns: Post
|
|
564
|
+
end
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Nothing about the slug is in the prompt. Under `:code_act` the refusal goes back to the model as
|
|
568
|
+
`finish rejected: Post is invalid: Slug is too long (maximum is 12 characters)`, and it corrects
|
|
569
|
+
itself inside the same loop, within its call budget. Your rules stay in the model, where the rest of
|
|
570
|
+
the application already reads them.
|
|
571
|
+
|
|
347
572
|
**Threads.** Puma is multi-threaded and so is this: printing from generated code goes to a
|
|
348
573
|
per-thread buffer, and each call gets its own chat and its own agent instance. Concurrent calls are
|
|
349
|
-
|
|
574
|
+
threads — wrapped in the Rails executor, which is what returns the connection to the pool and makes
|
|
575
|
+
autoloading safe off the request thread:
|
|
350
576
|
|
|
351
577
|
```ruby
|
|
352
|
-
ids.map
|
|
578
|
+
ids.map do |id|
|
|
579
|
+
Thread.new { Rails.application.executor.wrap { WarehouseAgent.appraise(item_id: id) } }
|
|
580
|
+
end.map(&:value)
|
|
353
581
|
```
|
|
354
582
|
|
|
355
583
|
Sharing one agent instance across threads is your business as usual — its state is yours. Do not
|
|
356
584
|
turn on RubyLLM's `tool_concurrency`: that runs generated code against the same agent in parallel.
|
|
357
585
|
|
|
586
|
+
**The pool.** A generation holds its thread for the whole run, and the moment generated code touches
|
|
587
|
+
`ActiveRecord` it holds a database connection with it — through every provider round-trip of a
|
|
588
|
+
`:code_act` loop, not just the queries. Size `pool:` by concurrent agent runs, not by request rate.
|
|
589
|
+
|
|
590
|
+
**Multi-turn, many pods.** Identity is a row, state is your tables, and the agent is a value —
|
|
591
|
+
rebuilt from them for one turn and thrown away. The chat is fresh per call anyway, so nothing
|
|
592
|
+
sticks to a process: any pod serves any turn, and multi-turn is nothing more than `context`
|
|
593
|
+
reading the history back.
|
|
594
|
+
|
|
595
|
+
```ruby
|
|
596
|
+
class SupportAgent < ApplicationAgent
|
|
597
|
+
instructions "You are the support desk. Decide from the customer's own data."
|
|
598
|
+
|
|
599
|
+
def initialize(conversation, **options)
|
|
600
|
+
super(**options)
|
|
601
|
+
@conversation = conversation
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# the whole of multi-turn: history is context, rebuilt every turn
|
|
605
|
+
def context
|
|
606
|
+
@conversation.messages.order(:created_at).last(30)
|
|
607
|
+
.map { |message| "#{message.role}: #{message.content}" }.join("\n")
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
describe "Refund an order; refuses anything above the paid total"
|
|
611
|
+
def refund!(order_id, amount) # invariants live here, not in the prompt
|
|
612
|
+
order = @conversation.user.orders.find(order_id) # scoping is authorization
|
|
613
|
+
raise ArgumentError, "over paid total" if amount > order.total
|
|
614
|
+
Refunds.issue!(order, amount)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
generates :reply, "Answer the customer's last message.", returns: :string
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
class TurnJob < ApplicationJob
|
|
621
|
+
limits_concurrency key: ->(conversation) { conversation } # one turn per conversation at a time
|
|
622
|
+
|
|
623
|
+
def perform(conversation)
|
|
624
|
+
reply = SupportAgent.new(conversation).reply # no transaction open across this
|
|
625
|
+
conversation.messages.create!(role: "assistant", content: reply)
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
`limits_concurrency` is Solid Queue's; Sidekiq and GoodJob have their own. What matters is that the
|
|
631
|
+
lock lives in the queue: `with_lock` around a generation would hold a transaction open for the whole
|
|
632
|
+
provider round-trip — a pinned connection, a long-running transaction, and every other turn on that
|
|
633
|
+
row waiting behind it.
|
|
634
|
+
|
|
635
|
+
[`examples/conversation_agent.rb`](examples/conversation_agent.rb) is all of this running: two turns
|
|
636
|
+
of one conversation, each crossing the queue, with nothing but an id and a String travelling between
|
|
637
|
+
them. The second turn answers from the first because the history is a table, not a process.
|
|
638
|
+
|
|
639
|
+
Marshal-into-a-column is the escape hatch for resuming a run mid-flight, not the default: rows can
|
|
640
|
+
be queried and migrated, blobs cannot.
|
|
641
|
+
|
|
642
|
+
**The console.** An agent is a plain object, so `rails console` is already the harness:
|
|
643
|
+
|
|
644
|
+
```ruby
|
|
645
|
+
agent = SupportAgent.new(Conversation.find(42))
|
|
646
|
+
agent.context # exactly what the model will read
|
|
647
|
+
agent.reply # one real generation, right here
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
The same value the job builds, built by hand — and every tool is an ordinary method, so
|
|
651
|
+
`agent.refund!(order_id, 20)` runs with no model in the room. Nothing to boot, nothing to mock: the
|
|
652
|
+
console session that debugs your models debugs your agents.
|
|
653
|
+
|
|
358
654
|
**Errors.** Everything raised at the boundary is an `Omakase::Error`:
|
|
359
655
|
|
|
360
656
|
| | |
|
|
@@ -394,12 +690,20 @@ turn naming what was wrong. No code runs. Right for classification, extraction,
|
|
|
394
690
|
is `instance_eval`d on the agent, so the agent’s methods and state are the API; anything printed and
|
|
395
691
|
the value of the last expression come back as the observation, and the loop repeats until the model
|
|
396
692
|
calls `finish(value)`. `Capabilities` lists the agent’s own methods (with their `describe` text) in
|
|
397
|
-
the system prompt, minus the method being written
|
|
398
|
-
|
|
693
|
+
the system prompt, minus the method being written. Leaving it out is not enough on its own, since
|
|
694
|
+
generated code can still find the method, so a generation already running refuses to start again — a
|
|
695
|
+
nested one would open its own chat with its own budget, and nothing would bound the spend. A failure
|
|
696
|
+
comes back with the line that raised, `doc(object)` prints what an object — or a class — offers, and
|
|
399
697
|
an answer that misses the contract is rejected into the same loop — the model corrects itself without
|
|
400
698
|
another request. Nothing in the provider bounds a tool loop, so the tool does: ten calls, then a turn
|
|
401
699
|
to answer with what it has.
|
|
402
700
|
|
|
701
|
+
Generation methods are public methods like any other, so generated code can call them — a
|
|
702
|
+
`:code_act` loop handing a classification to a `:predict` method, or an agent calling another agent
|
|
703
|
+
it holds in a field. And because the code runs on the object, the model can leave state for
|
|
704
|
+
`context` to read on the next call, or define itself a helper method — self-extension is just
|
|
705
|
+
`instance_eval`.
|
|
706
|
+
|
|
403
707
|
Because the answer is computed rather than retyped, the return type can be a Ruby class and the
|
|
404
708
|
method hands back the object itself:
|
|
405
709
|
|
|
@@ -441,6 +745,7 @@ generates :plan, strategy: CriticStrategy
|
|
|
441
745
|
lib/omakase/skills.rb a SKILL.md directory, as one described method
|
|
442
746
|
lib/omakase/memory.rb remember and recall, by meaning
|
|
443
747
|
lib/omakase/fake_chat.rb the stand-in chat for tests
|
|
748
|
+
lib/omakase/trace.rb those events, printed for a human
|
|
444
749
|
lib/omakase/strategies/ code_act, predict
|
|
445
750
|
|
|
446
751
|
## Examples
|
|
@@ -456,13 +761,15 @@ Copy `.env.example` to `.env` and fill in a key; `MODEL` and `PROVIDER` there pi
|
|
|
456
761
|
| [`support_agent.rb`](examples/support_agent.rb) | plain Ruby orchestrating generated methods |
|
|
457
762
|
| [`support_job.rb`](examples/support_job.rb) | generation off the request thread, via ActiveJob |
|
|
458
763
|
| [`rails_app.rb`](examples/rails_app.rb) | a whole Rails app in one file: initializer, agent, controller |
|
|
764
|
+
| [`conversation_agent.rb`](examples/conversation_agent.rb) | multi-turn over ActiveRecord, one turn per job, no process state |
|
|
459
765
|
| [`mcp_agent.rb`](examples/mcp_agent.rb) | an MCP server's tools as methods on the agent |
|
|
460
766
|
| [`skill_agent.rb`](examples/skill_agent.rb) | a SKILL.md directory the model loads when it needs it |
|
|
461
767
|
| [`interview_agent.rb`](examples/interview_agent.rb) | remembering across calls, without a shared chat |
|
|
462
768
|
| [`memory_agent.rb`](examples/memory_agent.rb) | recall by meaning, kept across a marshalled run |
|
|
769
|
+
| [`recursive_agent.rb`](examples/recursive_agent.rb) | a comment thread folded from the leaves up, one agent per node |
|
|
463
770
|
|
|
464
771
|
```bash
|
|
465
|
-
bundle exec rake # tests, no network
|
|
772
|
+
bundle exec rake # tests and Standard, no network
|
|
466
773
|
ruby examples/inventory_agent.rb
|
|
467
774
|
```
|
|
468
775
|
|
|
@@ -477,9 +784,12 @@ too. Two rules follow:
|
|
|
477
784
|
- **A marshalled agent is your data, never user input.** `Marshal.load` on bytes someone else can
|
|
478
785
|
write is remote code execution, resumed run or not.
|
|
479
786
|
|
|
480
|
-
What is bounded: ten tool calls per generation, a
|
|
481
|
-
|
|
482
|
-
|
|
787
|
+
What is bounded: ten tool calls per generation, one run of a generation at a time, a 30-second
|
|
788
|
+
timeout per execution, and 4KB of observation. That timeout is Ruby's `Timeout`, which raises
|
|
789
|
+
wherever the code has got to — inside a database driver it can leave the connection unusable — one
|
|
790
|
+
more reason anything long-running belongs in an executor of your own. What is not bounded: what the
|
|
791
|
+
code can reach. For real isolation, swap the executor — anything answering `call(agent, code,
|
|
792
|
+
timeout:)` will do:
|
|
483
793
|
|
|
484
794
|
```ruby
|
|
485
795
|
Omakase.executor = MySubprocessExecutor # returns an observation String or Executor::Answer
|
data/lib/omakase/agent.rb
CHANGED
|
@@ -4,6 +4,8 @@ module Omakase
|
|
|
4
4
|
# Fields are state, methods are what the model can call, `generates` declares
|
|
5
5
|
# the methods the model implements.
|
|
6
6
|
class Agent
|
|
7
|
+
# The generations this thread is inside, so one cannot re-enter itself.
|
|
8
|
+
RUNNING = :omakase_running
|
|
7
9
|
class << self
|
|
8
10
|
# The model and any RubyLLM chat option. Naming a provider takes the model
|
|
9
11
|
# id on trust, since providers like OpenRouter or Ollama serve ids that are
|
|
@@ -46,20 +48,34 @@ module Omakase
|
|
|
46
48
|
describe "Search what you remember, by meaning; the closest few come back"
|
|
47
49
|
define_method(:recall) { |query, limit: 5| (@memory ||= Memory.new).recall(query, limit:) }
|
|
48
50
|
end
|
|
51
|
+
|
|
49
52
|
# Documents the method defined next — the docstring Ruby does not have.
|
|
50
53
|
def describe(text)
|
|
51
54
|
@pending_description = text
|
|
52
55
|
end
|
|
53
56
|
|
|
54
|
-
# Without a prompt, the method name is the prompt.
|
|
55
|
-
|
|
57
|
+
# Without a prompt, the method name is the prompt. A block instead of a
|
|
58
|
+
# string is a prompt read at call time, on the agent. `takes:` names the
|
|
59
|
+
# keyword arguments, and then Ruby checks them.
|
|
60
|
+
def generates(name, prompt = nil, takes: nil, returns: nil, strategy: nil, model: nil, &schema)
|
|
61
|
+
# Redeclaring an inherited generation is how a subclass specialises one.
|
|
62
|
+
# Landing on a method you wrote is not that, and would replace it unseen.
|
|
63
|
+
if Capabilities.names(self).include?(name) && !generations.key?(name)
|
|
64
|
+
raise Error, "#{self}##{name} is already a method — generates would replace it"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless prompt.nil? || prompt.is_a?(String) || prompt.is_a?(Proc)
|
|
68
|
+
raise Error, "#{self}##{name}: a prompt is a String or a block returning one, got #{prompt.class}"
|
|
69
|
+
end
|
|
70
|
+
|
|
56
71
|
generations[name] = Generation.new(
|
|
57
72
|
name:,
|
|
58
73
|
prompt: prompt || humanize(name),
|
|
59
74
|
schema: Schema.define(returns:, &schema),
|
|
60
|
-
strategy: Strategies.fetch(strategy || self.strategy)
|
|
75
|
+
strategy: Strategies.fetch(strategy || self.strategy),
|
|
76
|
+
model:
|
|
61
77
|
)
|
|
62
|
-
|
|
78
|
+
define_generation_method(name, takes)
|
|
63
79
|
define_singleton_method(name) { |**inputs| new.public_send(name, **inputs) }
|
|
64
80
|
end
|
|
65
81
|
|
|
@@ -71,6 +87,25 @@ module Omakase
|
|
|
71
87
|
|
|
72
88
|
private
|
|
73
89
|
|
|
90
|
+
# Named inputs become a real signature, so a missing or misspelled argument
|
|
91
|
+
# is an ArgumentError at the call rather than noise in a prompt — and the
|
|
92
|
+
# model reads the names too, instead of `**inputs`.
|
|
93
|
+
def define_generation_method(name, takes)
|
|
94
|
+
return define_method(name) { |**inputs| generate(name, inputs) } if takes.nil?
|
|
95
|
+
|
|
96
|
+
keywords = Array(takes)
|
|
97
|
+
bad = [name.to_s.chomp("?").chomp("!"), *keywords].reject { |word| /\A[a-z_]\w*\z/.match?(word.to_s) }
|
|
98
|
+
raise Error, "#{self}##{name}: takes: needs plain keyword names, got #{bad.inspect}" if bad.any?
|
|
99
|
+
|
|
100
|
+
class_eval <<~RUBY, __FILE__, __LINE__ + 1
|
|
101
|
+
def #{name}(#{keywords.map { |key| "#{key}:" }.join(", ")}, with: nil)
|
|
102
|
+
inputs = {#{keywords.map { |key| "#{key}: #{key}" }.join(", ")}}
|
|
103
|
+
inputs[:with] = with unless with.nil?
|
|
104
|
+
generate(:#{name}, inputs)
|
|
105
|
+
end
|
|
106
|
+
RUBY
|
|
107
|
+
end
|
|
108
|
+
|
|
74
109
|
def humanize(name)
|
|
75
110
|
text = name.to_s.tr("_", " ").capitalize
|
|
76
111
|
text.end_with?("?", "!") ? text : "#{text}."
|
|
@@ -99,7 +134,8 @@ module Omakase
|
|
|
99
134
|
|
|
100
135
|
# A fresh conversation per call — two threads calling one agent must not
|
|
101
136
|
# share a mutable chat. What carries between calls is the object's own state.
|
|
102
|
-
|
|
137
|
+
# Overrides land on top of the class's options; an injected chat ignores them.
|
|
138
|
+
def chat(**overrides) = @chat || Omakase.chat_factory.call(**self.class.chat_options.merge(overrides))
|
|
103
139
|
|
|
104
140
|
# That state, as the model should read it: rebuilt on every call, and added
|
|
105
141
|
# to the class's instructions. Override it to remember anything.
|
|
@@ -125,7 +161,7 @@ module Omakase
|
|
|
125
161
|
|
|
126
162
|
def p(*args)
|
|
127
163
|
args.each { |arg| omakase_output.puts(arg.inspect) }
|
|
128
|
-
args.size <= 1 ? args.first : args
|
|
164
|
+
(args.size <= 1) ? args.first : args
|
|
129
165
|
end
|
|
130
166
|
|
|
131
167
|
alias_method :pp, :p
|
|
@@ -136,9 +172,24 @@ module Omakase
|
|
|
136
172
|
|
|
137
173
|
def generate(name, inputs)
|
|
138
174
|
generation = self.class.generations.fetch(name)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
175
|
+
running = (Thread.current[RUNNING] ||= [])
|
|
176
|
+
key = [object_id, name]
|
|
177
|
+
|
|
178
|
+
# Generated code can see this method and call it. Each nested call opens its
|
|
179
|
+
# own chat with its own tool budget, so the budget would bound nothing.
|
|
180
|
+
raise Error, "#{self.class}##{name} is already running — it cannot call itself" if running.include?(key)
|
|
181
|
+
|
|
182
|
+
running.push(key)
|
|
183
|
+
begin
|
|
184
|
+
Omakase.emit(:generation, agent: self, name:, inputs:)
|
|
185
|
+
value = generation.strategy.call(Request.new(agent: self, generation:, inputs:))
|
|
186
|
+
Omakase.emit(:answer, agent: self, name:, value:)
|
|
187
|
+
value
|
|
188
|
+
rescue RubyLLM::Error, RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError => e
|
|
189
|
+
raise ProviderError, "#{self.class}##{name}: #{e.message}"
|
|
190
|
+
ensure
|
|
191
|
+
running.delete(key)
|
|
192
|
+
end
|
|
142
193
|
end
|
|
143
194
|
end
|
|
144
195
|
end
|
data/lib/omakase/capabilities.rb
CHANGED
|
@@ -19,7 +19,9 @@ module Omakase
|
|
|
19
19
|
|
|
20
20
|
def entry(agent_class, name)
|
|
21
21
|
signature = "#{name}(#{parameters(agent_class.instance_method(name))})"
|
|
22
|
-
|
|
22
|
+
# A prompt written as a block needs an instance to read; `describe` it instead.
|
|
23
|
+
prompt = agent_class.generations[name]&.prompt
|
|
24
|
+
description = agent_class.descriptions[name] || (prompt unless prompt.is_a?(Proc))
|
|
23
25
|
description ? "#{signature} — #{description}" : signature
|
|
24
26
|
end
|
|
25
27
|
|
data/lib/omakase/doc.rb
CHANGED
|
@@ -13,7 +13,23 @@ module Omakase
|
|
|
13
13
|
# and the methods you wrote.
|
|
14
14
|
def boundary = defined?(ActiveRecord::Base) ? [*CORE, ActiveRecord::Base] : CORE
|
|
15
15
|
|
|
16
|
-
def of(object)
|
|
16
|
+
def of(object)
|
|
17
|
+
return of_class(object) if object.is_a?(Module)
|
|
18
|
+
|
|
19
|
+
[object.class.to_s, *signatures(object.class) { |name| object.method(name) }, *state(object)].join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A class, not an instance: what one would have. The model asks this before it
|
|
23
|
+
# builds an object of a type it has only been told the name of.
|
|
24
|
+
def of_class(klass)
|
|
25
|
+
[klass.to_s, *signatures(klass) { |name| klass.instance_method(name) }, *columns(klass)].join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def columns(klass)
|
|
29
|
+
return [] unless klass.respond_to?(:column_names)
|
|
30
|
+
|
|
31
|
+
klass.column_names.map { |name| " #{name}" }
|
|
32
|
+
end
|
|
17
33
|
|
|
18
34
|
# An object that answers `attributes` says what it holds better than its
|
|
19
35
|
# instance variables do — and a record's columns are state, not API.
|
|
@@ -24,14 +40,14 @@ module Omakase
|
|
|
24
40
|
|
|
25
41
|
def ivars(object) = object.instance_variables.to_h { |name| [name, object.instance_variable_get(name)] }
|
|
26
42
|
|
|
27
|
-
def signatures(
|
|
28
|
-
|
|
43
|
+
def signatures(klass, &getter)
|
|
44
|
+
klass.ancestors
|
|
29
45
|
.take_while { |mod| !boundary.include?(mod) }
|
|
30
46
|
.reject { |mod| mod.to_s.end_with?("GeneratedAttributeMethods") }
|
|
31
47
|
.flat_map { |mod| mod.public_instance_methods(false) }
|
|
32
48
|
.uniq.sort
|
|
33
49
|
.reject { |name| name.match?(/\A_|_associated_records_for_/) }
|
|
34
|
-
.map { |name| " #{name}(#{Capabilities.parameters(
|
|
50
|
+
.map { |name| " #{name}(#{Capabilities.parameters(getter.call(name))})" }
|
|
35
51
|
end
|
|
36
52
|
end
|
|
37
53
|
end
|
data/lib/omakase/executor.rb
CHANGED
|
@@ -8,10 +8,15 @@ module Omakase
|
|
|
8
8
|
RESULT = :omakase_result
|
|
9
9
|
OUTPUT = :omakase_output
|
|
10
10
|
TIMEOUT = 30
|
|
11
|
+
TRACE = /\A#{Regexp.escape(SOURCE)}:\d+/
|
|
11
12
|
MAX_OUTPUT = 4_000
|
|
12
13
|
|
|
13
|
-
# What `finish(value)` handed back: the answer as a Ruby value, not as text
|
|
14
|
-
|
|
14
|
+
# What `finish(value)` handed back: the answer as a Ruby value, not as text,
|
|
15
|
+
# and whatever the code printed on the way there. `printed` defaults, so a
|
|
16
|
+
# replacement executor that only knows the value still satisfies the seam.
|
|
17
|
+
Answer = Data.define(:value, :printed) do
|
|
18
|
+
def initialize(value:, printed: "") = super
|
|
19
|
+
end
|
|
15
20
|
|
|
16
21
|
module_function
|
|
17
22
|
|
|
@@ -21,14 +26,14 @@ module Omakase
|
|
|
21
26
|
value = capturing(printed) { Timeout.timeout(timeout) { agent.instance_eval(code, SOURCE, 1) } }
|
|
22
27
|
return observation([printed.string.chomp, "=> #{value.inspect}"])
|
|
23
28
|
end
|
|
24
|
-
Answer.new(value: answer)
|
|
29
|
+
Answer.new(value: answer, printed: printed.string.chomp)
|
|
25
30
|
rescue ScriptError, StandardError => e
|
|
26
31
|
observation([printed.string.chomp, failure(e, code)])
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
# The model can only fix what it can locate, so point at the line.
|
|
30
35
|
def failure(error, code)
|
|
31
|
-
line = error.backtrace&.grep(
|
|
36
|
+
line = error.backtrace&.grep(TRACE)&.first&.slice(/:(\d+)/, 1)&.to_i
|
|
32
37
|
source = code.lines[line - 1]&.strip if line&.positive?
|
|
33
38
|
["#{error.class}: #{error.message}", ("line #{line}: #{source}" if source)].compact.join("\n")
|
|
34
39
|
end
|
data/lib/omakase/fake_chat.rb
CHANGED
|
@@ -12,13 +12,14 @@ module Omakase
|
|
|
12
12
|
class FakeChat
|
|
13
13
|
Response = Struct.new(:content)
|
|
14
14
|
|
|
15
|
-
attr_reader :instructions, :schema, :tools, :tasks
|
|
15
|
+
attr_reader :instructions, :schema, :tools, :tasks, :attachments
|
|
16
16
|
|
|
17
17
|
def initialize(&script)
|
|
18
18
|
@script = script
|
|
19
19
|
@instructions = []
|
|
20
20
|
@tools = []
|
|
21
21
|
@tasks = []
|
|
22
|
+
@attachments = []
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def with_instructions(text) = tap { @instructions << text }
|
|
@@ -27,8 +28,9 @@ module Omakase
|
|
|
27
28
|
|
|
28
29
|
def with_tool(tool, **) = tap { @tools << tool }
|
|
29
30
|
|
|
30
|
-
def ask(task)
|
|
31
|
+
def ask(task, with: nil)
|
|
31
32
|
@tasks << task
|
|
33
|
+
@attachments << with if with
|
|
32
34
|
Response.new(@script.call(self))
|
|
33
35
|
end
|
|
34
36
|
|
data/lib/omakase/generation.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Omakase
|
|
4
4
|
# A method the agent declares but does not implement: the prompt is its body,
|
|
5
|
-
# the schema is its return type, the strategy is how it gets there.
|
|
6
|
-
|
|
5
|
+
# the schema is its return type, the strategy is how it gets there. A model
|
|
6
|
+
# named here overrides the class's, for this method alone.
|
|
7
|
+
Generation = Data.define(:name, :prompt, :schema, :strategy, :model)
|
|
7
8
|
end
|
data/lib/omakase/request.rb
CHANGED
|
@@ -3,17 +3,29 @@
|
|
|
3
3
|
module Omakase
|
|
4
4
|
# One invocation of a generation method: all a strategy may depend on.
|
|
5
5
|
Request = Data.define(:agent, :generation, :inputs) do
|
|
6
|
-
def chat = agent.chat
|
|
6
|
+
def chat = agent.chat(**{model: generation.model}.compact)
|
|
7
7
|
|
|
8
8
|
def schema = generation.schema
|
|
9
9
|
|
|
10
10
|
def instructions = [agent.class.instructions, agent.context].reject { |text| text.to_s.empty? }.join("\n\n")
|
|
11
11
|
|
|
12
|
+
# `with:` is reserved: files for the model to look at, passed through to
|
|
13
|
+
# RubyLLM's `ask(with:)` as attachments rather than rendered into the text.
|
|
14
|
+
def attachments = inputs[:with]
|
|
15
|
+
|
|
12
16
|
def task
|
|
13
|
-
|
|
17
|
+
arguments = inputs.except(:with)
|
|
18
|
+
return prompt if arguments.empty?
|
|
19
|
+
|
|
20
|
+
lines = arguments.map { |name, value| "- #{name}: #{value.inspect}" }
|
|
21
|
+
"#{prompt}\n\nInputs:\n#{lines.join("\n")}"
|
|
22
|
+
end
|
|
14
23
|
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
# A prompt written as a block is read at call time, on the agent — so one
|
|
25
|
+
# declaration serves an object however it happens to be configured.
|
|
26
|
+
def prompt
|
|
27
|
+
text = generation.prompt
|
|
28
|
+
text.is_a?(Proc) ? agent.instance_exec(&text) : text
|
|
17
29
|
end
|
|
18
30
|
end
|
|
19
31
|
end
|
data/lib/omakase/schema.rb
CHANGED
|
@@ -12,6 +12,9 @@ module Omakase
|
|
|
12
12
|
}.freeze
|
|
13
13
|
|
|
14
14
|
def self.define(returns: nil, &block)
|
|
15
|
+
# Two contracts in one declaration: one of them would be dropped, silently.
|
|
16
|
+
raise Error, "returns: and a schema block are two different contracts — declare one" if returns && block
|
|
17
|
+
|
|
15
18
|
return new(Schematist::Schema.create(&block)) if block
|
|
16
19
|
return Type.new(returns) if returns.is_a?(Module)
|
|
17
20
|
|
|
@@ -65,7 +68,7 @@ module Omakase
|
|
|
65
68
|
def properties = json.fetch("properties")
|
|
66
69
|
|
|
67
70
|
def demand(value, type)
|
|
68
|
-
matched = type == "boolean" ? [true, false].include?(value) : value.is_a?(RUBY_TYPES.fetch(type))
|
|
71
|
+
matched = (type == "boolean") ? [true, false].include?(value) : value.is_a?(RUBY_TYPES.fetch(type))
|
|
69
72
|
raise ContractError, "expected <#{type}>, got #{value.inspect}" unless matched
|
|
70
73
|
|
|
71
74
|
value
|
|
@@ -12,7 +12,7 @@ module Omakase
|
|
|
12
12
|
notes = request.chat
|
|
13
13
|
.with_instructions(instructions(request))
|
|
14
14
|
.with_tool(tool)
|
|
15
|
-
.ask(request.task)
|
|
15
|
+
.ask(request.task, with: request.attachments)
|
|
16
16
|
.content
|
|
17
17
|
|
|
18
18
|
return tool.answer.value if tool.answer
|
|
@@ -33,7 +33,7 @@ module Omakase
|
|
|
33
33
|
|
|
34
34
|
#{capabilities(request).join("\n")}
|
|
35
35
|
|
|
36
|
-
`doc(object)` prints what an object of an unfamiliar type offers.
|
|
36
|
+
`doc(object)` prints what an object of an unfamiliar type offers; a class works too.
|
|
37
37
|
|
|
38
38
|
Return the answer from inside the code, never as a message — the last thing you run is:
|
|
39
39
|
|
|
@@ -13,7 +13,7 @@ module Omakase
|
|
|
13
13
|
.with_instructions(instructions(request))
|
|
14
14
|
.with_schema(request.schema.definition)
|
|
15
15
|
|
|
16
|
-
request.schema.cast(chat.ask(task).content)
|
|
16
|
+
request.schema.cast(chat.ask(task, with: request.attachments).content)
|
|
17
17
|
rescue ContractError => e
|
|
18
18
|
# One correction turn, told exactly what was wrong with the last answer.
|
|
19
19
|
request.schema.cast(chat.ask("#{e.message}\n\n#{CORRECTION}").content)
|
data/lib/omakase/tools/ruby.rb
CHANGED
|
@@ -36,12 +36,16 @@ module Omakase
|
|
|
36
36
|
return halt("Tool budget spent.") if @calls > @budget + 1
|
|
37
37
|
|
|
38
38
|
outcome = @executor.call(@agent, code, timeout: @timeout)
|
|
39
|
-
|
|
39
|
+
Omakase.emit(:ruby, agent: @agent, code:, outcome:)
|
|
40
|
+
return outcome if outcome.is_a?(String)
|
|
41
|
+
# The seam's contract, checked here so a wrong executor cannot reach the model.
|
|
42
|
+
raise Error, "executor must return a String or Executor::Answer, got #{outcome.class}" unless outcome.is_a?(Executor::Answer)
|
|
40
43
|
|
|
41
|
-
@answer = Executor::Answer.new(value: @schema.take(outcome.value))
|
|
44
|
+
@answer = Executor::Answer.new(value: @schema.take(outcome.value), printed: outcome.printed)
|
|
42
45
|
halt("Answer accepted.")
|
|
43
|
-
rescue
|
|
46
|
+
rescue ContractError => e
|
|
44
47
|
# Off-contract answers are corrected inside the same loop, not by another request.
|
|
48
|
+
# Anything else — a broken executor, a bad configuration — is not the model's to fix.
|
|
45
49
|
"finish rejected: #{e.message}"
|
|
46
50
|
end
|
|
47
51
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Omakase
|
|
4
|
+
# The listener, printed for a human: `Omakase.listener = Omakase::Trace.new`.
|
|
5
|
+
# A run reads top to bottom — the call, the code the model wrote, the answer.
|
|
6
|
+
# Colour when the stream is a terminal, plain when it is a log.
|
|
7
|
+
class Trace
|
|
8
|
+
COLOURS = {generation: 36, ruby: 33, answer: 32}.freeze
|
|
9
|
+
LIMIT = 800
|
|
10
|
+
|
|
11
|
+
def initialize(io: $stderr)
|
|
12
|
+
@io = io
|
|
13
|
+
@colour = io.respond_to?(:tty?) && io.tty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(event, agent:, **payload)
|
|
17
|
+
head, body = case event
|
|
18
|
+
when :generation then ["→ #{agent.class}##{payload[:name]}", inputs(payload[:inputs])]
|
|
19
|
+
when :ruby then ["· ruby", "#{payload[:code].strip}\n#{outcome(payload[:outcome])}"]
|
|
20
|
+
when :answer then ["← #{agent.class}##{payload[:name]}", truncate(payload[:value].inspect)]
|
|
21
|
+
else return # a listener that raises takes the run down with it
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@io.puts(paint(event, head))
|
|
25
|
+
@io.puts(body.gsub(/^/, " ")) unless body.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def inputs(inputs) = inputs.map { |name, value| "#{name}: #{truncate(value.inspect)}" }.join("\n")
|
|
31
|
+
|
|
32
|
+
# An Answer is `finish(value)` ending the run — with anything printed before it,
|
|
33
|
+
# which the tool result never carries because the run is over. Otherwise the
|
|
34
|
+
# outcome is already the text the model reads.
|
|
35
|
+
def outcome(outcome)
|
|
36
|
+
return truncate(outcome.to_s) unless outcome.is_a?(Executor::Answer)
|
|
37
|
+
|
|
38
|
+
truncate([outcome.printed, "finish #{outcome.value.inspect}"].reject(&:empty?).join("\n"))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def truncate(text) = (text.length > LIMIT) ? "#{text[0, LIMIT]}…" : text
|
|
42
|
+
|
|
43
|
+
def paint(event, text) = @colour ? "\e[#{COLOURS.fetch(event)}m#{text}\e[0m" : text
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/omakase/type.rb
CHANGED
|
@@ -20,9 +20,9 @@ module Omakase
|
|
|
20
20
|
def code_only? = true
|
|
21
21
|
|
|
22
22
|
def take(value)
|
|
23
|
-
|
|
23
|
+
raise ContractError, "expected #{describe}, got #{value.class}" unless value.is_a?(@klass)
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
well_formed(value)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def definition
|
|
@@ -30,5 +30,18 @@ module Omakase
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
alias_method :json, :definition
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# An object that can say whether it is well-formed gets asked — ActiveModel,
|
|
37
|
+
# ActiveRecord, anything of that shape. The refusal reaches the model as an
|
|
38
|
+
# observation, so your own validations are what it has to satisfy, and they
|
|
39
|
+
# stay where you wrote them instead of being retyped into a prompt.
|
|
40
|
+
def well_formed(value)
|
|
41
|
+
return value unless value.respond_to?(:valid?) && value.respond_to?(:errors)
|
|
42
|
+
return value if value.valid?
|
|
43
|
+
|
|
44
|
+
raise ContractError, "#{@klass} is invalid: #{value.errors.full_messages.join("; ")}"
|
|
45
|
+
end
|
|
33
46
|
end
|
|
34
47
|
end
|
data/lib/omakase/version.rb
CHANGED
data/lib/omakase.rb
CHANGED
|
@@ -46,14 +46,47 @@ module Omakase
|
|
|
46
46
|
|
|
47
47
|
# Where generated code runs. Anything answering `call(agent, code, timeout:)`
|
|
48
48
|
# will do — swap in a subprocess or a container to get real isolation.
|
|
49
|
-
|
|
49
|
+
def executor=(executor)
|
|
50
|
+
@executor = callable!(executor, "executor")
|
|
51
|
+
end
|
|
50
52
|
|
|
51
53
|
def executor = @executor ||= Executor
|
|
52
54
|
|
|
53
55
|
# How text becomes a vector, for Memory. Anything answering `call(text)`
|
|
54
56
|
# will do; the model and its provider are RubyLLM's to configure.
|
|
55
|
-
|
|
57
|
+
def embedder=(embedder)
|
|
58
|
+
@embedder = callable!(embedder, "embedder")
|
|
59
|
+
end
|
|
56
60
|
|
|
57
61
|
def embedder = @embedder ||= ->(text) { RubyLLM.embed(text).vectors }
|
|
62
|
+
|
|
63
|
+
# How an agent gets a chat when none was injected. Anything answering
|
|
64
|
+
# `call(**options)` will do — one line in test_helper.rb keeps a whole suite
|
|
65
|
+
# off the network, including the class-level calls a job makes.
|
|
66
|
+
def chat_factory=(factory)
|
|
67
|
+
@chat_factory = callable!(factory, "chat_factory")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def chat_factory = @chat_factory ||= ->(**options) { RubyLLM.chat(**options) }
|
|
71
|
+
|
|
72
|
+
# Every step, as it happens: a generation starts, model-written code runs,
|
|
73
|
+
# an answer lands. Anything answering `call(event, **payload)` will do —
|
|
74
|
+
# a logger, a tracer, a test. Nil, the default, costs nothing.
|
|
75
|
+
def listener=(listener)
|
|
76
|
+
@listener = callable!(listener, "listener")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
attr_reader :listener
|
|
80
|
+
|
|
81
|
+
def emit(event, **payload) = @listener&.call(event, **payload)
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Fail where the swap is made, not deep inside a generation.
|
|
86
|
+
def callable!(object, name)
|
|
87
|
+
return object if object.nil? || object.respond_to?(:call)
|
|
88
|
+
|
|
89
|
+
raise Error, "#{name} must answer call, got #{object.class}"
|
|
90
|
+
end
|
|
58
91
|
end
|
|
59
92
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omakase-agents
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- eugeny
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ruby_llm
|
|
@@ -63,6 +63,7 @@ executables: []
|
|
|
63
63
|
extensions: []
|
|
64
64
|
extra_rdoc_files: []
|
|
65
65
|
files:
|
|
66
|
+
- CHANGELOG.md
|
|
66
67
|
- LICENSE
|
|
67
68
|
- README.md
|
|
68
69
|
- lib/omakase-agents.rb
|
|
@@ -82,6 +83,7 @@ files:
|
|
|
82
83
|
- lib/omakase/strategies/code_act.rb
|
|
83
84
|
- lib/omakase/strategies/predict.rb
|
|
84
85
|
- lib/omakase/tools/ruby.rb
|
|
86
|
+
- lib/omakase/trace.rb
|
|
85
87
|
- lib/omakase/type.rb
|
|
86
88
|
- lib/omakase/version.rb
|
|
87
89
|
homepage: https://github.com/esshka/omakase
|
|
@@ -91,6 +93,7 @@ metadata:
|
|
|
91
93
|
homepage_uri: https://github.com/esshka/omakase
|
|
92
94
|
source_code_uri: https://github.com/esshka/omakase
|
|
93
95
|
bug_tracker_uri: https://github.com/esshka/omakase/issues
|
|
96
|
+
changelog_uri: https://github.com/esshka/omakase/blob/main/CHANGELOG.md
|
|
94
97
|
rubygems_mfa_required: 'true'
|
|
95
98
|
post_install_message:
|
|
96
99
|
rdoc_options: []
|