omakase-agents 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/README.md +252 -23
- data/lib/omakase/agent.rb +80 -13
- data/lib/omakase/capabilities.rb +7 -2
- data/lib/omakase/doc.rb +20 -4
- data/lib/omakase/executor.rb +152 -11
- data/lib/omakase/fake_chat.rb +28 -3
- data/lib/omakase/mcp.rb +90 -1
- data/lib/omakase/request.rb +15 -3
- data/lib/omakase/schema.rb +56 -6
- data/lib/omakase/skills/how_to_act/SKILL.md +40 -0
- data/lib/omakase/skills.rb +18 -4
- data/lib/omakase/strategies/code_act.rb +26 -9
- data/lib/omakase/strategies/predict.rb +8 -2
- data/lib/omakase/tools/ruby.rb +12 -4
- data/lib/omakase/trace.rb +50 -0
- data/lib/omakase/type.rb +15 -2
- data/lib/omakase/version.rb +1 -1
- data/lib/omakase.rb +33 -1
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bdeb9ba4fb16ca770bcedd8b127b76cc925af49cfa73a9257d9e3d27e060de97
|
|
4
|
+
data.tar.gz: a7b72e4946ab8add9c5baac75bcd9bf5bf0419d70bfe79c54fbd843a280f78ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: baa837f849d8a646edd9a9777660d236defb2c676f274cb9c2f5cade9c74e70145d8dcde3ab6c17b260f622bed61fa025489e465e8e854e45b0cf679c1099a02
|
|
7
|
+
data.tar.gz: b0dfbe030b3f6668a8ad2faea141a6c7fc0754475371bb6e3630584770b3b91e0e5e9e33e839d9de46d4dd2222b17b17ec4665b00ad7e93899c32d4cca78af08
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,89 @@
|
|
|
3
3
|
One entry per released version, written when the gem is pushed. Until `1.0`, a minor version may
|
|
4
4
|
move the API — what breaks is listed first, so an upgrade is a decision rather than a surprise.
|
|
5
5
|
|
|
6
|
+
## 0.4.0
|
|
7
|
+
|
|
8
|
+
RubyLLM 2.0 underneath, and MCP waits for it. Generated code gets its inputs as locals, can crash in
|
|
9
|
+
a child process instead of yours, and its answer is checked all the way down.
|
|
10
|
+
|
|
11
|
+
### Breaking
|
|
12
|
+
|
|
13
|
+
- RubyLLM `~> 2.0`. `ruby_llm-mcp` has no release for it yet, so `mcp` servers do not connect until
|
|
14
|
+
it does; the `mcp` declaration itself still loads.
|
|
15
|
+
- Prompt caching is on for every chat Omakase builds. `model "…", caching: false` turns it off.
|
|
16
|
+
- A text reply under `:code_act` gets one more turn in the same chat — "call finish" — before the
|
|
17
|
+
fallback to `:predict`. One more model call when a model forgets how to answer.
|
|
18
|
+
- `finish` is held to the whole schema: nested objects, array items, enums. An answer 0.3 took can
|
|
19
|
+
now be refused, with the path of the mistake (`tags[0]: expected <string>, got 1`).
|
|
20
|
+
- Chat options other than the model are the chat's own `with_*` calls — `temperature: 0.2`,
|
|
21
|
+
`thinking: {effort: :high}` — and one RubyLLM::Chat has no `with_*` for raises. 0.3 passed them
|
|
22
|
+
to `RubyLLM.chat`, which refused them anyway.
|
|
23
|
+
- Generations nest ten deep at most.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `Omakase::Executor::Subprocess`: generated code in a forked child, so a timeout or a crash takes
|
|
28
|
+
the child and not you. Ivars come back; the child still reaches what this process reaches.
|
|
29
|
+
- Under `:code_act` the inputs are local variables in the generated code, and locals last for the
|
|
30
|
+
rest of the generation. The prompt shows only the first 500 characters of each input.
|
|
31
|
+
- Every agent has `how_to_act`, a built-in skill: how to write the Ruby — `finish`, prints, `doc`,
|
|
32
|
+
locals — with examples.
|
|
33
|
+
- The instructions go first and are marked as a cache boundary; `context` follows them.
|
|
34
|
+
- `FakeChat.replies(a, b)`: one reply per model turn, and a turn past the last one raises.
|
|
35
|
+
- A `:error` event; `Omakase::Trace` prints it and indents nested generations.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- `exit` in generated code no longer ends your process; the model is told to `finish` instead.
|
|
40
|
+
- An observation past 4KB keeps its end as well as its start, so the error is not the part cut off.
|
|
41
|
+
- MCP servers connect on the first instance, not at class load: an unreachable sidecar does not
|
|
42
|
+
fail boot, and a reload does not reconnect.
|
|
43
|
+
- A ```ruby fence around the whole code is stripped before it runs.
|
|
44
|
+
- SKILL.md is read as UTF-8, CRLF front matter is read, and front matter that is not YAML falls back
|
|
45
|
+
to plain `key: value` lines.
|
|
46
|
+
|
|
47
|
+
## 0.3.0
|
|
48
|
+
|
|
49
|
+
One thing changes under you: a generation may no longer call itself. The rest is additions — a real
|
|
50
|
+
signature for the inputs, a trace to read a run by, a seam for the chat, and your own validations
|
|
51
|
+
enforced on the way out.
|
|
52
|
+
|
|
53
|
+
### Breaking
|
|
54
|
+
|
|
55
|
+
- A generation may not re-enter itself. While `SupportAgent#reply` is running on an object, that
|
|
56
|
+
object's `reply` raises `Omakase::Error` instead of opening a second run. Generated code can see
|
|
57
|
+
the method and call it, and each nested call opened its own chat with its own tool budget — so the
|
|
58
|
+
budget bounded nothing. A *fresh* agent may still recurse: that is the sub-agents pattern, one
|
|
59
|
+
object per node of a tree, and the tree is the thing that ends.
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- `takes:` names the keyword arguments, and then Ruby checks them:
|
|
64
|
+
`generates :translate, takes: %i[text language]`. A missing or misspelled argument is an
|
|
65
|
+
`ArgumentError` at the call rather than noise in a prompt, and the model reads the names instead
|
|
66
|
+
of `**inputs`. `with:` stays available for attachments. Anything that is not a plain keyword name
|
|
67
|
+
is refused where it is declared.
|
|
68
|
+
- `Omakase::Trace` — the listener printed for a human: `Omakase.listener = Omakase::Trace.new`. A run
|
|
69
|
+
reads top to bottom: the call, the code the model wrote, the answer. Colour when the stream is a
|
|
70
|
+
terminal, plain when it is a log.
|
|
71
|
+
- `Omakase.chat_factory` — how an agent gets a chat when none was injected. One line in
|
|
72
|
+
`test_helper.rb` keeps a whole suite off the network, including the class-level calls a job makes,
|
|
73
|
+
which have no seam to inject through. Anything answering `call(**options)` will do, and an
|
|
74
|
+
injected `chat:` still wins.
|
|
75
|
+
- A `returns:` class that answers `valid?` and `errors` — which is every ActiveModel — is asked
|
|
76
|
+
before the answer is handed back, and an invalid one is refused. Under `:code_act` the refusal
|
|
77
|
+
reaches the model as `finish rejected: Post is invalid: …`, and it corrects itself inside the same
|
|
78
|
+
loop. Your validations are the contract, and they stay where you wrote them.
|
|
79
|
+
- `doc(object)` takes a class as well as an instance: what an object of that type would offer, plus
|
|
80
|
+
the column names when it is a record. The model asks before it builds a type it has only been told
|
|
81
|
+
the name of.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- What generated code printed before `finish` is no longer lost — `Executor::Answer` carries it, so a
|
|
86
|
+
trace shows the working and not only the answer. `printed:` defaults, so a replacement executor
|
|
87
|
+
that knows the value alone still satisfies the seam.
|
|
88
|
+
|
|
6
89
|
## 0.2.0
|
|
7
90
|
|
|
8
91
|
Nothing breaks. Four additions, each one a keyword or a seam that costs nothing when unused.
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Omakase
|
|
1
|
+
# Omakase Agents
|
|
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
6
|
**[esshka.github.io/omakase](https://esshka.github.io/omakase/)** · [rubygems](https://rubygems.org/gems/omakase-agents) · [changelog](CHANGELOG.md) 
|
|
@@ -54,9 +54,15 @@ 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
|
+
Against **`RubyLLM::Agent`**: that is a chat preset — model, tools, instructions and a schema kept
|
|
61
|
+
on a class, handing back a `RubyLLM::Chat`, with Rails persistence of the conversation. Reach for it
|
|
62
|
+
when the product is the chat. Here the product is a method: `generates` defines one the model
|
|
63
|
+
implements, the agent's own methods and fields are its tools and state, and the call returns a typed
|
|
64
|
+
value. Both sit on RubyLLM and do not overlap.
|
|
65
|
+
|
|
60
66
|
Against **a framework with a tool registry**: there is nothing to register and nothing to keep in
|
|
61
67
|
sync. The model gets one tool, `ruby`, and reaches the rest through the object. A tool's description
|
|
62
68
|
is `describe`, a line above the method, instead of a JSON schema that drifts from the code it
|
|
@@ -79,6 +85,53 @@ gem "omakase-agents" # the library is `Omakase`
|
|
|
79
85
|
gem install omakase-agents
|
|
80
86
|
```
|
|
81
87
|
|
|
88
|
+
## Quickstart
|
|
89
|
+
|
|
90
|
+
One file, one working agent. Five minutes.
|
|
91
|
+
|
|
92
|
+
**1.** Install the gem and set a key:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
gem install omakase-agents
|
|
96
|
+
export OPENROUTER_API_KEY=sk-or-...
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**2.** Save this as `triage.rb`:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
require "omakase"
|
|
103
|
+
|
|
104
|
+
Omakase.configure_from_env
|
|
105
|
+
|
|
106
|
+
class TriageAgent < Omakase::Agent
|
|
107
|
+
model "meta/muse-glimmer-30b", provider: :openrouter
|
|
108
|
+
instructions "You triage customer support messages."
|
|
109
|
+
strategy :predict
|
|
110
|
+
|
|
111
|
+
generates :triage do
|
|
112
|
+
string :severity, enum: %w[low medium high]
|
|
113
|
+
string :summary
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
pp TriageAgent.triage(message: "The app crashes every time I open my invoices")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**3.** Run it:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
ruby triage.rb
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A Ruby Hash comes back, matching the schema you declared:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
{severity: "high", summary: "App crashes on opening invoices"}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
You wrote no JSON parsing and registered no tool. Next: [Usage](#usage) for the rest of the
|
|
133
|
+
API, or [How it works](#how-it-works) for why it is built this way.
|
|
134
|
+
|
|
82
135
|
## Usage
|
|
83
136
|
|
|
84
137
|
The whole API, in one class:
|
|
@@ -136,6 +189,20 @@ FeedbackAgent.analyze(text: "Great product, but shipping was slow")
|
|
|
136
189
|
FeedbackAgent.new.analyze(text: "…")
|
|
137
190
|
```
|
|
138
191
|
|
|
192
|
+
By default they take whatever you pass. Name them with `takes:` and they become a real Ruby
|
|
193
|
+
signature, so a missing or misspelled argument is an `ArgumentError` at the call rather than noise
|
|
194
|
+
in a prompt — and the model reads the names instead of `**inputs`. Under `:code_act` the inputs are
|
|
195
|
+
also local variables in the generated code, so a long list or a record is used, not retyped; the
|
|
196
|
+
prompt shows only the first 500 characters of each:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
generates :decide, "Decide this refund.", takes: %i[email complaint], returns: Refund
|
|
200
|
+
|
|
201
|
+
RefundAgent.decide(email: "ada@example.com") # => ArgumentError: missing keyword: :complaint
|
|
202
|
+
RefundAgent.decide(emial: "…", complaint: "…") # => ArgumentError: unknown keyword: :emial
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`with:` stays available on a named signature, since attachments are not part of the prompt.
|
|
139
206
|
`describe` above an ordinary method is the docstring Ruby does not have — it is what the model reads
|
|
140
207
|
when it decides what to call.
|
|
141
208
|
|
|
@@ -167,7 +234,9 @@ generates :count_items, returns: :integer # :string (default), :integer, :n
|
|
|
167
234
|
|
|
168
235
|
Both forms are the same mechanism: a schema whose only property is `result` unwraps to that value.
|
|
169
236
|
A Ruby class works too — `returns: Ticket` — and then the method hands back the object rather than
|
|
170
|
-
data; see [`:code_act`](#strategies) for what that requires.
|
|
237
|
+
data; see [`:code_act`](#strategies) for what that requires. If that object can say whether it is
|
|
238
|
+
well-formed — anything answering `valid?` and `errors`, which is every ActiveModel — it is asked, and
|
|
239
|
+
an invalid one is refused.
|
|
171
240
|
|
|
172
241
|
### Attachments
|
|
173
242
|
|
|
@@ -197,7 +266,10 @@ class ApplicationAgent < Omakase::Agent
|
|
|
197
266
|
end
|
|
198
267
|
```
|
|
199
268
|
|
|
200
|
-
Naming a provider implies `assume_model_exists: true
|
|
269
|
+
Naming a provider implies `assume_model_exists: true`. Any other option is the chat's own `with_*`
|
|
270
|
+
call — `temperature: 0.2`, `thinking: {effort: :high}`, `max_output_tokens: 2_000` — and an option
|
|
271
|
+
RubyLLM::Chat has no `with_*` for raises. Prompt caching is on unless you say `caching: false`: the
|
|
272
|
+
tool loop resends the whole chat on every step, and the instructions come first so they cache.
|
|
201
273
|
Subclasses inherit the setting and can override it, so one `ApplicationAgent` configures the lot.
|
|
202
274
|
|
|
203
275
|
A generation method can name its own model — a cheap one for classification beside a strong one
|
|
@@ -227,7 +299,8 @@ end
|
|
|
227
299
|
|
|
228
300
|
An MCP server's tools become methods on the agent, listed among its capabilities like any other —
|
|
229
301
|
so generated code calls a remote tool and the agent's own methods in the same expression. Add the
|
|
230
|
-
`ruby_llm-mcp` gem; options are passed to it verbatim.
|
|
302
|
+
`ruby_llm-mcp` gem; options are passed to it verbatim. Its 1.0 release still pins RubyLLM 1.x, so
|
|
303
|
+
this waits on a `ruby_llm-mcp` that supports RubyLLM 2.
|
|
231
304
|
|
|
232
305
|
```ruby
|
|
233
306
|
class DocsAgent < ApplicationAgent
|
|
@@ -239,10 +312,14 @@ class DocsAgent < ApplicationAgent
|
|
|
239
312
|
end
|
|
240
313
|
```
|
|
241
314
|
|
|
242
|
-
The connection opens
|
|
315
|
+
The connection opens on the first instance and the tools are read from the server then, so a
|
|
243
316
|
tool's arguments reach the model as documentation. A failed call raises, which the model sees and
|
|
244
317
|
can correct. Only text comes back: an image or audio result is dropped.
|
|
245
318
|
|
|
319
|
+
Class load does not talk to the server, so an unreachable sidecar does not fail boot, and a reload
|
|
320
|
+
does not reconnect. The first instance pays that cost; if the server is down, its tools are absent
|
|
321
|
+
and the generate still runs. The next instance tries that server again.
|
|
322
|
+
|
|
246
323
|
### Skills
|
|
247
324
|
|
|
248
325
|
A skill is a directory with a `SKILL.md` — the same YAML front matter Claude Code and friends use.
|
|
@@ -257,11 +334,19 @@ class CommitAgent < ApplicationAgent
|
|
|
257
334
|
end
|
|
258
335
|
```
|
|
259
336
|
|
|
337
|
+
The path is expanded against the working directory, which is `Rails.root` until something — a job
|
|
338
|
+
runner, a systemd unit — decides otherwise, so `Rails.root.join("app/agents/skills/commit_style")`
|
|
339
|
+
is the spelling that keeps working.
|
|
340
|
+
|
|
260
341
|
That is the whole of “loaded on demand”: the one-line description is in the prompt, the body only
|
|
261
342
|
reaches the model if the generated code calls `commit_style`. Anything else the skill ships —
|
|
262
343
|
scripts, templates — sits in the same directory, and the body ends with its path, so generated Ruby
|
|
263
344
|
can read or run it.
|
|
264
345
|
|
|
346
|
+
Every agent already has one: `how_to_act`. It is how to write the Ruby that implements a
|
|
347
|
+
generation — `finish`, prints, `doc`, ivars — with examples. The description sits in the capability
|
|
348
|
+
list; the body arrives when the model calls it.
|
|
349
|
+
|
|
265
350
|
### Remembering
|
|
266
351
|
|
|
267
352
|
The chat is fresh on every call — two threads calling one agent must not share a mutable
|
|
@@ -338,6 +423,44 @@ things one agent learns about its work; past that it is your database's job —
|
|
|
338
423
|
reimplement against it. And for a few dozen facts, `@notes.grep(/shipping/)` beats every word of
|
|
339
424
|
this.
|
|
340
425
|
|
|
426
|
+
### Sub-agents
|
|
427
|
+
|
|
428
|
+
Some work is a tree, and the tree is usually already in your database — a comment thread, a category
|
|
429
|
+
tree, a bill of materials. One agent per node folds it from the leaves up, and the recursion belongs
|
|
430
|
+
to the data: a node with no children is the base case, so nothing has to invent how deep to go.
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
class ThreadAgent < ApplicationAgent
|
|
434
|
+
instructions "You sum up a discussion for someone who has not read it."
|
|
435
|
+
strategy :predict
|
|
436
|
+
|
|
437
|
+
def initialize(comment, **options)
|
|
438
|
+
super(**options)
|
|
439
|
+
@comment = comment
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def roll_up
|
|
443
|
+
return said if @comment.replies.empty?
|
|
444
|
+
|
|
445
|
+
summarise(comment: said, replies: @comment.replies.map { |reply| self.class.new(reply).roll_up })
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
generates :summarise, "Sum up this comment together with the replies it drew.", returns: :string
|
|
449
|
+
|
|
450
|
+
private
|
|
451
|
+
|
|
452
|
+
def said = "#{@comment.author}: #{@comment.body}"
|
|
453
|
+
end
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
A leaf is its own summary, so the model is asked only where there is something to fold. A fresh
|
|
457
|
+
agent per branch is not ceremony either: siblings then share no state, and one object may not
|
|
458
|
+
re-enter a generation it is already inside. That is refused, because a nested run opens its own chat
|
|
459
|
+
with its own tool budget — nothing would bound the spend. For the same reason generations nest ten
|
|
460
|
+
deep at most. Generated code can start a sub-agent the
|
|
461
|
+
same way. [`examples/recursive_agent.rb`](examples/recursive_agent.rb) is the runnable version: four
|
|
462
|
+
comments, two of them leaves, two generations.
|
|
463
|
+
|
|
341
464
|
### Testing
|
|
342
465
|
|
|
343
466
|
`Omakase::Agent.new(chat:)` takes any object that quacks like a `RubyLLM::Chat`, and one ships with
|
|
@@ -349,10 +472,28 @@ assert_equal "high", SupportAgent.new(chat:).triage(message: "broken")[:severity
|
|
|
349
472
|
|
|
350
473
|
# drive the tool the way a model would
|
|
351
474
|
chat = Omakase::FakeChat.new { |fake| fake.run("finish(stock_of(:apple))") }
|
|
475
|
+
|
|
476
|
+
# one reply per model turn, in order; one turn too many raises
|
|
477
|
+
chat = Omakase::FakeChat.replies("prose, not JSON", {"severity" => "high", "summary" => "…"})
|
|
352
478
|
```
|
|
353
479
|
|
|
354
480
|
It records `instructions`, `schema`, `tools` and `tasks`, so the prompt is assertable too.
|
|
355
481
|
|
|
482
|
+
Injecting a chat covers the agent you are testing. A suite covers everything else, including the
|
|
483
|
+
class-level calls a job makes — `SupportAgent.triage(message:)` builds its own agent and has no seam
|
|
484
|
+
to inject through. `Omakase.chat_factory` is that seam, and one line in `test_helper.rb` puts the
|
|
485
|
+
whole suite off the network:
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
# test/test_helper.rb
|
|
489
|
+
Omakase.chat_factory = ->(**) { Omakase::FakeChat.new { raise "an agent asked for a model" } }
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Make it raise, and any generation you forgot to stub fails loudly instead of quietly calling a
|
|
493
|
+
provider from CI. An injected `chat:` still wins, so the tests that mean to run an agent keep
|
|
494
|
+
working. Anything answering `call(**options)` will do; the options are the class's chat options, so
|
|
495
|
+
a factory can assert the model too.
|
|
496
|
+
|
|
356
497
|
### Listening in
|
|
357
498
|
|
|
358
499
|
One callback hears every step as it happens: a generation starting, model-written code running,
|
|
@@ -363,7 +504,32 @@ Omakase.listener = ->(event, **payload) { Rails.logger.info("#{event} #{payload.
|
|
|
363
504
|
```
|
|
364
505
|
|
|
365
506
|
`:generation` carries `agent:, name:, inputs:` · `:ruby` carries `agent:, code:, outcome:` ·
|
|
366
|
-
`:answer` carries `agent:, name:, value:`.
|
|
507
|
+
`:answer` carries `agent:, name:, value:` · `:error` carries `agent:, name:, error:`.
|
|
508
|
+
|
|
509
|
+
One listener is included, for reading a run rather than storing it: it prints each step to stderr,
|
|
510
|
+
in colour when stderr is a terminal.
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
Omakase.listener = Omakase::Trace.new
|
|
514
|
+
|
|
515
|
+
# → SupportAgent#triage
|
|
516
|
+
# message: "my mug arrived cracked"
|
|
517
|
+
# · ruby
|
|
518
|
+
# order = order_db.find(1)
|
|
519
|
+
# puts "eligible: #{refund_eligible?(order)}"
|
|
520
|
+
# finish(Ticket.new("A-1", :high))
|
|
521
|
+
# eligible: true
|
|
522
|
+
# finish #<struct Ticket id="A-1", severity=:high>
|
|
523
|
+
# ← SupportAgent#triage
|
|
524
|
+
# #<struct Ticket id="A-1", severity=:high>
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
In Rails there is already a bus for this, and one line puts the events on it — subscribers and your
|
|
528
|
+
APM pick them up with nothing further:
|
|
529
|
+
|
|
530
|
+
```ruby
|
|
531
|
+
Omakase.listener = ->(event, **payload) { ActiveSupport::Notifications.instrument("#{event}.omakase", payload) }
|
|
532
|
+
```
|
|
367
533
|
|
|
368
534
|
## Rails
|
|
369
535
|
|
|
@@ -388,7 +554,7 @@ logs and APM without any code of ours:
|
|
|
388
554
|
|
|
389
555
|
```ruby
|
|
390
556
|
ActiveSupport::Notifications.subscribe("chat.ruby_llm") do |*, payload|
|
|
391
|
-
Rails.logger.info(model: payload[:model], input: payload[:
|
|
557
|
+
Rails.logger.info(model: payload[:model], input: payload[:tokens].input, output: payload[:tokens].output)
|
|
392
558
|
end
|
|
393
559
|
```
|
|
394
560
|
|
|
@@ -404,17 +570,43 @@ Jobs move data, not objects: arguments and results have to serialize, so a `retu
|
|
|
404
570
|
answer — a live Ruby object — does not survive the trip. [`examples/support_job.rb`](examples/support_job.rb)
|
|
405
571
|
is the runnable version, three tickets triaged concurrently by the async adapter.
|
|
406
572
|
|
|
573
|
+
**Validations are the contract.** A `returns:` class that answers `valid?` and `errors` gets asked
|
|
574
|
+
before the answer is handed back, so a generation cannot return a record your own validations
|
|
575
|
+
reject:
|
|
576
|
+
|
|
577
|
+
```ruby
|
|
578
|
+
class Post < ApplicationRecord
|
|
579
|
+
validates :slug, format: {with: /\A[a-z0-9-]+\z/}, length: {maximum: 12}
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
class BlogAgent < ApplicationAgent
|
|
583
|
+
generates :write, "Write a post about the topic.", returns: Post
|
|
584
|
+
end
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
Nothing about the slug is in the prompt. Under `:code_act` the refusal goes back to the model as
|
|
588
|
+
`finish rejected: Post is invalid: Slug is too long (maximum is 12 characters)`, and it corrects
|
|
589
|
+
itself inside the same loop, within its call budget. Your rules stay in the model, where the rest of
|
|
590
|
+
the application already reads them.
|
|
591
|
+
|
|
407
592
|
**Threads.** Puma is multi-threaded and so is this: printing from generated code goes to a
|
|
408
593
|
per-thread buffer, and each call gets its own chat and its own agent instance. Concurrent calls are
|
|
409
|
-
|
|
594
|
+
threads — wrapped in the Rails executor, which is what returns the connection to the pool and makes
|
|
595
|
+
autoloading safe off the request thread:
|
|
410
596
|
|
|
411
597
|
```ruby
|
|
412
|
-
ids.map
|
|
598
|
+
ids.map do |id|
|
|
599
|
+
Thread.new { Rails.application.executor.wrap { WarehouseAgent.appraise(item_id: id) } }
|
|
600
|
+
end.map(&:value)
|
|
413
601
|
```
|
|
414
602
|
|
|
415
603
|
Sharing one agent instance across threads is your business as usual — its state is yours. Do not
|
|
416
604
|
turn on RubyLLM's `tool_concurrency`: that runs generated code against the same agent in parallel.
|
|
417
605
|
|
|
606
|
+
**The pool.** A generation holds its thread for the whole run, and the moment generated code touches
|
|
607
|
+
`ActiveRecord` it holds a database connection with it — through every provider round-trip of a
|
|
608
|
+
`:code_act` loop, not just the queries. Size `pool:` by concurrent agent runs, not by request rate.
|
|
609
|
+
|
|
418
610
|
**Multi-turn, many pods.** Identity is a row, state is your tables, and the agent is a value —
|
|
419
611
|
rebuilt from them for one turn and thrown away. The chat is fresh per call anyway, so nothing
|
|
420
612
|
sticks to a process: any pod serves any turn, and multi-turn is nothing more than `context`
|
|
@@ -446,18 +638,39 @@ class SupportAgent < ApplicationAgent
|
|
|
446
638
|
end
|
|
447
639
|
|
|
448
640
|
class TurnJob < ApplicationJob
|
|
641
|
+
limits_concurrency key: ->(conversation) { conversation } # one turn per conversation at a time
|
|
642
|
+
|
|
449
643
|
def perform(conversation)
|
|
450
|
-
conversation.
|
|
451
|
-
|
|
452
|
-
conversation.messages.create!(role: "assistant", content: reply)
|
|
453
|
-
end
|
|
644
|
+
reply = SupportAgent.new(conversation).reply # no transaction open across this
|
|
645
|
+
conversation.messages.create!(role: "assistant", content: reply)
|
|
454
646
|
end
|
|
455
647
|
end
|
|
456
648
|
```
|
|
457
649
|
|
|
650
|
+
`limits_concurrency` is Solid Queue's; Sidekiq and GoodJob have their own. What matters is that the
|
|
651
|
+
lock lives in the queue: `with_lock` around a generation would hold a transaction open for the whole
|
|
652
|
+
provider round-trip — a pinned connection, a long-running transaction, and every other turn on that
|
|
653
|
+
row waiting behind it.
|
|
654
|
+
|
|
655
|
+
[`examples/conversation_agent.rb`](examples/conversation_agent.rb) is all of this running: two turns
|
|
656
|
+
of one conversation, each crossing the queue, with nothing but an id and a String travelling between
|
|
657
|
+
them. The second turn answers from the first because the history is a table, not a process.
|
|
658
|
+
|
|
458
659
|
Marshal-into-a-column is the escape hatch for resuming a run mid-flight, not the default: rows can
|
|
459
660
|
be queried and migrated, blobs cannot.
|
|
460
661
|
|
|
662
|
+
**The console.** An agent is a plain object, so `rails console` is already the harness:
|
|
663
|
+
|
|
664
|
+
```ruby
|
|
665
|
+
agent = SupportAgent.new(Conversation.find(42))
|
|
666
|
+
agent.context # exactly what the model will read
|
|
667
|
+
agent.reply # one real generation, right here
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
The same value the job builds, built by hand — and every tool is an ordinary method, so
|
|
671
|
+
`agent.refund!(order_id, 20)` runs with no model in the room. Nothing to boot, nothing to mock: the
|
|
672
|
+
console session that debugs your models debugs your agents.
|
|
673
|
+
|
|
461
674
|
**Errors.** Everything raised at the boundary is an `Omakase::Error`:
|
|
462
675
|
|
|
463
676
|
| | |
|
|
@@ -497,8 +710,10 @@ turn naming what was wrong. No code runs. Right for classification, extraction,
|
|
|
497
710
|
is `instance_eval`d on the agent, so the agent’s methods and state are the API; anything printed and
|
|
498
711
|
the value of the last expression come back as the observation, and the loop repeats until the model
|
|
499
712
|
calls `finish(value)`. `Capabilities` lists the agent’s own methods (with their `describe` text) in
|
|
500
|
-
the system prompt, minus the method being written
|
|
501
|
-
|
|
713
|
+
the system prompt, minus the method being written. Leaving it out is not enough on its own, since
|
|
714
|
+
generated code can still find the method, so a generation already running refuses to start again — a
|
|
715
|
+
nested one would open its own chat with its own budget, and nothing would bound the spend. A failure
|
|
716
|
+
comes back with the line that raised, `doc(object)` prints what an object — or a class — offers, and
|
|
502
717
|
an answer that misses the contract is rejected into the same loop — the model corrects itself without
|
|
503
718
|
another request. Nothing in the provider bounds a tool loop, so the tool does: ten calls, then a turn
|
|
504
719
|
to answer with what it has.
|
|
@@ -544,12 +759,13 @@ generates :plan, strategy: CriticStrategy
|
|
|
544
759
|
lib/omakase/type.rb return types that are a Ruby class
|
|
545
760
|
lib/omakase/capabilities.rb the agent’s own methods, listed for the model
|
|
546
761
|
lib/omakase/doc.rb what an unfamiliar object offers, for generated code
|
|
547
|
-
lib/omakase/executor.rb
|
|
762
|
+
lib/omakase/executor.rb in-process; Executor::Subprocess isolates a crash
|
|
548
763
|
lib/omakase/tools/ruby.rb that executor, as a RubyLLM tool, with a call budget
|
|
549
764
|
lib/omakase/mcp.rb an MCP server’s tools, as methods on the agent
|
|
550
765
|
lib/omakase/skills.rb a SKILL.md directory, as one described method
|
|
551
766
|
lib/omakase/memory.rb remember and recall, by meaning
|
|
552
767
|
lib/omakase/fake_chat.rb the stand-in chat for tests
|
|
768
|
+
lib/omakase/trace.rb those events, printed for a human
|
|
553
769
|
lib/omakase/strategies/ code_act, predict
|
|
554
770
|
|
|
555
771
|
## Examples
|
|
@@ -565,10 +781,13 @@ Copy `.env.example` to `.env` and fill in a key; `MODEL` and `PROVIDER` there pi
|
|
|
565
781
|
| [`support_agent.rb`](examples/support_agent.rb) | plain Ruby orchestrating generated methods |
|
|
566
782
|
| [`support_job.rb`](examples/support_job.rb) | generation off the request thread, via ActiveJob |
|
|
567
783
|
| [`rails_app.rb`](examples/rails_app.rb) | a whole Rails app in one file: initializer, agent, controller |
|
|
784
|
+
| [`conversation_agent.rb`](examples/conversation_agent.rb) | multi-turn over ActiveRecord, one turn per job, no process state |
|
|
568
785
|
| [`mcp_agent.rb`](examples/mcp_agent.rb) | an MCP server's tools as methods on the agent |
|
|
569
786
|
| [`skill_agent.rb`](examples/skill_agent.rb) | a SKILL.md directory the model loads when it needs it |
|
|
570
787
|
| [`interview_agent.rb`](examples/interview_agent.rb) | remembering across calls, without a shared chat |
|
|
571
788
|
| [`memory_agent.rb`](examples/memory_agent.rb) | recall by meaning, kept across a marshalled run |
|
|
789
|
+
| [`recursive_agent.rb`](examples/recursive_agent.rb) | a comment thread folded from the leaves up, one agent per node |
|
|
790
|
+
| [`subprocess_agent.rb`](examples/subprocess_agent.rb) | generated code in a child process: a crash there is an observation here |
|
|
572
791
|
|
|
573
792
|
```bash
|
|
574
793
|
bundle exec rake # tests and Standard, no network
|
|
@@ -586,12 +805,21 @@ too. Two rules follow:
|
|
|
586
805
|
- **A marshalled agent is your data, never user input.** `Marshal.load` on bytes someone else can
|
|
587
806
|
write is remote code execution, resumed run or not.
|
|
588
807
|
|
|
589
|
-
What is bounded: ten tool calls per generation, a
|
|
590
|
-
|
|
591
|
-
|
|
808
|
+
What is bounded: ten tool calls per generation, one run of a generation at a time, a 30-second
|
|
809
|
+
timeout per execution, and 4KB of observation — its start and its end, where the error is. `exit` in
|
|
810
|
+
generated code is refused rather than ending your process; `exit!` cannot be caught, so only
|
|
811
|
+
Subprocess survives it. The default executor uses Ruby's `Timeout` in this
|
|
812
|
+
process — inside a database driver it can leave the connection unusable.
|
|
813
|
+
`Omakase::Executor::Subprocess` is the reference swap: the same `instance_eval`, in a child process,
|
|
814
|
+
so a timeout or a crash takes the child and not you. Ivars come back one at a time, so one Proc
|
|
815
|
+
does not drop the rest; methods the model defined on the object die with the child. The child is a
|
|
816
|
+
copy of this process, so it can still reach ActiveRecord, ENV, and the disk — that is isolation of
|
|
817
|
+
fate, not of capability. Untrusted input still belongs to `:predict`. Do not use it from a threaded
|
|
818
|
+
server; run the generation in a job. Do not call MCP tools under it: the child would share the
|
|
819
|
+
parent's stdio client.
|
|
592
820
|
|
|
593
821
|
```ruby
|
|
594
|
-
Omakase.executor =
|
|
822
|
+
Omakase.executor = Omakase::Executor::Subprocess
|
|
595
823
|
```
|
|
596
824
|
|
|
597
825
|
## Not here, on purpose
|
|
@@ -600,7 +828,8 @@ Omakase.executor = MySubprocessExecutor # returns an observation String or Ex
|
|
|
600
828
|
RubyLLM, and making it resumable would be a different library.
|
|
601
829
|
- **Reflection and forgetting in memory.** No decay, no consolidation pass: `Memory` grows until you
|
|
602
830
|
prune it, and past a few hundred entries the answer is pgvector, not more code here.
|
|
603
|
-
- **A sandbox.**
|
|
831
|
+
- **A sandbox.** The child can still reach what this process can. Subprocess isolates a crash and a
|
|
832
|
+
timeout, not `File` or ActiveRecord.
|
|
604
833
|
- **Multi-agent orchestration.** An agent is an object, so one agent calling another is a method
|
|
605
834
|
call. There is nothing to add.
|
|
606
835
|
- **Streaming.** A generation method returns a value, not tokens. RubyLLM streams if you need that.
|