omakase-agents 0.2.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 +42 -0
- data/README.md +215 -14
- data/lib/omakase/agent.rb +44 -10
- data/lib/omakase/doc.rb +20 -4
- data/lib/omakase/executor.rb +7 -3
- data/lib/omakase/strategies/code_act.rb +1 -1
- data/lib/omakase/tools/ruby.rb +1 -1
- 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 +9 -0
- metadata +3 -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
CHANGED
|
@@ -3,6 +3,48 @@
|
|
|
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.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
|
+
|
|
6
48
|
## 0.2.0
|
|
7
49
|
|
|
8
50
|
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
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
6
|
**[esshka.github.io/omakase](https://esshka.github.io/omakase/)** · [rubygems](https://rubygems.org/gems/omakase-agents) · [changelog](CHANGELOG.md) 
|
|
@@ -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,6 +183,18 @@ 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
|
|
|
@@ -167,7 +226,9 @@ generates :count_items, returns: :integer # :string (default), :integer, :n
|
|
|
167
226
|
|
|
168
227
|
Both forms are the same mechanism: a schema whose only property is `result` unwraps to that value.
|
|
169
228
|
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.
|
|
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.
|
|
171
232
|
|
|
172
233
|
### Attachments
|
|
173
234
|
|
|
@@ -243,6 +304,10 @@ The connection opens when the class is defined and the tools are read from the s
|
|
|
243
304
|
tool's arguments reach the model as documentation. A failed call raises, which the model sees and
|
|
244
305
|
can correct. Only text comes back: an image or audio result is dropped.
|
|
245
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
|
+
|
|
246
311
|
### Skills
|
|
247
312
|
|
|
248
313
|
A skill is a directory with a `SKILL.md` — the same YAML front matter Claude Code and friends use.
|
|
@@ -257,6 +322,10 @@ class CommitAgent < ApplicationAgent
|
|
|
257
322
|
end
|
|
258
323
|
```
|
|
259
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
|
+
|
|
260
329
|
That is the whole of “loaded on demand”: the one-line description is in the prompt, the body only
|
|
261
330
|
reaches the model if the generated code calls `commit_style`. Anything else the skill ships —
|
|
262
331
|
scripts, templates — sits in the same directory, and the body ends with its path, so generated Ruby
|
|
@@ -338,6 +407,43 @@ things one agent learns about its work; past that it is your database's job —
|
|
|
338
407
|
reimplement against it. And for a few dozen facts, `@notes.grep(/shipping/)` beats every word of
|
|
339
408
|
this.
|
|
340
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
|
+
|
|
341
447
|
### Testing
|
|
342
448
|
|
|
343
449
|
`Omakase::Agent.new(chat:)` takes any object that quacks like a `RubyLLM::Chat`, and one ships with
|
|
@@ -353,6 +459,21 @@ chat = Omakase::FakeChat.new { |fake| fake.run("finish(stock_of(:apple))") }
|
|
|
353
459
|
|
|
354
460
|
It records `instructions`, `schema`, `tools` and `tasks`, so the prompt is assertable too.
|
|
355
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
|
+
|
|
356
477
|
### Listening in
|
|
357
478
|
|
|
358
479
|
One callback hears every step as it happens: a generation starting, model-written code running,
|
|
@@ -365,6 +486,31 @@ Omakase.listener = ->(event, **payload) { Rails.logger.info("#{event} #{payload.
|
|
|
365
486
|
`:generation` carries `agent:, name:, inputs:` · `:ruby` carries `agent:, code:, outcome:` ·
|
|
366
487
|
`:answer` carries `agent:, name:, value:`.
|
|
367
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
|
+
|
|
368
514
|
## Rails
|
|
369
515
|
|
|
370
516
|
Agents live in `app/agents` — Rails autoloads it, and reloading is safe because everything a
|
|
@@ -404,17 +550,43 @@ Jobs move data, not objects: arguments and results have to serialize, so a `retu
|
|
|
404
550
|
answer — a live Ruby object — does not survive the trip. [`examples/support_job.rb`](examples/support_job.rb)
|
|
405
551
|
is the runnable version, three tickets triaged concurrently by the async adapter.
|
|
406
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
|
+
|
|
407
572
|
**Threads.** Puma is multi-threaded and so is this: printing from generated code goes to a
|
|
408
573
|
per-thread buffer, and each call gets its own chat and its own agent instance. Concurrent calls are
|
|
409
|
-
|
|
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:
|
|
410
576
|
|
|
411
577
|
```ruby
|
|
412
|
-
ids.map
|
|
578
|
+
ids.map do |id|
|
|
579
|
+
Thread.new { Rails.application.executor.wrap { WarehouseAgent.appraise(item_id: id) } }
|
|
580
|
+
end.map(&:value)
|
|
413
581
|
```
|
|
414
582
|
|
|
415
583
|
Sharing one agent instance across threads is your business as usual — its state is yours. Do not
|
|
416
584
|
turn on RubyLLM's `tool_concurrency`: that runs generated code against the same agent in parallel.
|
|
417
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
|
+
|
|
418
590
|
**Multi-turn, many pods.** Identity is a row, state is your tables, and the agent is a value —
|
|
419
591
|
rebuilt from them for one turn and thrown away. The chat is fresh per call anyway, so nothing
|
|
420
592
|
sticks to a process: any pod serves any turn, and multi-turn is nothing more than `context`
|
|
@@ -446,18 +618,39 @@ class SupportAgent < ApplicationAgent
|
|
|
446
618
|
end
|
|
447
619
|
|
|
448
620
|
class TurnJob < ApplicationJob
|
|
621
|
+
limits_concurrency key: ->(conversation) { conversation } # one turn per conversation at a time
|
|
622
|
+
|
|
449
623
|
def perform(conversation)
|
|
450
|
-
conversation.
|
|
451
|
-
|
|
452
|
-
conversation.messages.create!(role: "assistant", content: reply)
|
|
453
|
-
end
|
|
624
|
+
reply = SupportAgent.new(conversation).reply # no transaction open across this
|
|
625
|
+
conversation.messages.create!(role: "assistant", content: reply)
|
|
454
626
|
end
|
|
455
627
|
end
|
|
456
628
|
```
|
|
457
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
|
+
|
|
458
639
|
Marshal-into-a-column is the escape hatch for resuming a run mid-flight, not the default: rows can
|
|
459
640
|
be queried and migrated, blobs cannot.
|
|
460
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
|
+
|
|
461
654
|
**Errors.** Everything raised at the boundary is an `Omakase::Error`:
|
|
462
655
|
|
|
463
656
|
| | |
|
|
@@ -497,8 +690,10 @@ turn naming what was wrong. No code runs. Right for classification, extraction,
|
|
|
497
690
|
is `instance_eval`d on the agent, so the agent’s methods and state are the API; anything printed and
|
|
498
691
|
the value of the last expression come back as the observation, and the loop repeats until the model
|
|
499
692
|
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
|
-
|
|
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
|
|
502
697
|
an answer that misses the contract is rejected into the same loop — the model corrects itself without
|
|
503
698
|
another request. Nothing in the provider bounds a tool loop, so the tool does: ten calls, then a turn
|
|
504
699
|
to answer with what it has.
|
|
@@ -550,6 +745,7 @@ generates :plan, strategy: CriticStrategy
|
|
|
550
745
|
lib/omakase/skills.rb a SKILL.md directory, as one described method
|
|
551
746
|
lib/omakase/memory.rb remember and recall, by meaning
|
|
552
747
|
lib/omakase/fake_chat.rb the stand-in chat for tests
|
|
748
|
+
lib/omakase/trace.rb those events, printed for a human
|
|
553
749
|
lib/omakase/strategies/ code_act, predict
|
|
554
750
|
|
|
555
751
|
## Examples
|
|
@@ -565,10 +761,12 @@ Copy `.env.example` to `.env` and fill in a key; `MODEL` and `PROVIDER` there pi
|
|
|
565
761
|
| [`support_agent.rb`](examples/support_agent.rb) | plain Ruby orchestrating generated methods |
|
|
566
762
|
| [`support_job.rb`](examples/support_job.rb) | generation off the request thread, via ActiveJob |
|
|
567
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 |
|
|
568
765
|
| [`mcp_agent.rb`](examples/mcp_agent.rb) | an MCP server's tools as methods on the agent |
|
|
569
766
|
| [`skill_agent.rb`](examples/skill_agent.rb) | a SKILL.md directory the model loads when it needs it |
|
|
570
767
|
| [`interview_agent.rb`](examples/interview_agent.rb) | remembering across calls, without a shared chat |
|
|
571
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 |
|
|
572
770
|
|
|
573
771
|
```bash
|
|
574
772
|
bundle exec rake # tests and Standard, no network
|
|
@@ -586,9 +784,12 @@ too. Two rules follow:
|
|
|
586
784
|
- **A marshalled agent is your data, never user input.** `Marshal.load` on bytes someone else can
|
|
587
785
|
write is remote code execution, resumed run or not.
|
|
588
786
|
|
|
589
|
-
What is bounded: ten tool calls per generation, a
|
|
590
|
-
|
|
591
|
-
|
|
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:
|
|
592
793
|
|
|
593
794
|
```ruby
|
|
594
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
|
|
@@ -53,8 +55,9 @@ module Omakase
|
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
# Without a prompt, the method name is the prompt. A block instead of a
|
|
56
|
-
# string is a prompt read at call time, on the agent.
|
|
57
|
-
|
|
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)
|
|
58
61
|
# Redeclaring an inherited generation is how a subclass specialises one.
|
|
59
62
|
# Landing on a method you wrote is not that, and would replace it unseen.
|
|
60
63
|
if Capabilities.names(self).include?(name) && !generations.key?(name)
|
|
@@ -72,7 +75,7 @@ module Omakase
|
|
|
72
75
|
strategy: Strategies.fetch(strategy || self.strategy),
|
|
73
76
|
model:
|
|
74
77
|
)
|
|
75
|
-
|
|
78
|
+
define_generation_method(name, takes)
|
|
76
79
|
define_singleton_method(name) { |**inputs| new.public_send(name, **inputs) }
|
|
77
80
|
end
|
|
78
81
|
|
|
@@ -84,6 +87,25 @@ module Omakase
|
|
|
84
87
|
|
|
85
88
|
private
|
|
86
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
|
+
|
|
87
109
|
def humanize(name)
|
|
88
110
|
text = name.to_s.tr("_", " ").capitalize
|
|
89
111
|
text.end_with?("?", "!") ? text : "#{text}."
|
|
@@ -113,7 +135,7 @@ module Omakase
|
|
|
113
135
|
# A fresh conversation per call — two threads calling one agent must not
|
|
114
136
|
# share a mutable chat. What carries between calls is the object's own state.
|
|
115
137
|
# Overrides land on top of the class's options; an injected chat ignores them.
|
|
116
|
-
def chat(**overrides) = @chat ||
|
|
138
|
+
def chat(**overrides) = @chat || Omakase.chat_factory.call(**self.class.chat_options.merge(overrides))
|
|
117
139
|
|
|
118
140
|
# That state, as the model should read it: rebuilt on every call, and added
|
|
119
141
|
# to the class's instructions. Override it to remember anything.
|
|
@@ -150,12 +172,24 @@ module Omakase
|
|
|
150
172
|
|
|
151
173
|
def generate(name, inputs)
|
|
152
174
|
generation = self.class.generations.fetch(name)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
raise
|
|
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
|
|
159
193
|
end
|
|
160
194
|
end
|
|
161
195
|
end
|
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
|
@@ -11,8 +11,12 @@ module Omakase
|
|
|
11
11
|
TRACE = /\A#{Regexp.escape(SOURCE)}:\d+/
|
|
12
12
|
MAX_OUTPUT = 4_000
|
|
13
13
|
|
|
14
|
-
# What `finish(value)` handed back: the answer as a Ruby value, not as text
|
|
15
|
-
|
|
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
|
|
16
20
|
|
|
17
21
|
module_function
|
|
18
22
|
|
|
@@ -22,7 +26,7 @@ module Omakase
|
|
|
22
26
|
value = capturing(printed) { Timeout.timeout(timeout) { agent.instance_eval(code, SOURCE, 1) } }
|
|
23
27
|
return observation([printed.string.chomp, "=> #{value.inspect}"])
|
|
24
28
|
end
|
|
25
|
-
Answer.new(value: answer)
|
|
29
|
+
Answer.new(value: answer, printed: printed.string.chomp)
|
|
26
30
|
rescue ScriptError, StandardError => e
|
|
27
31
|
observation([printed.string.chomp, failure(e, code)])
|
|
28
32
|
end
|
|
@@ -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
|
|
data/lib/omakase/tools/ruby.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Omakase
|
|
|
41
41
|
# The seam's contract, checked here so a wrong executor cannot reach the model.
|
|
42
42
|
raise Error, "executor must return a String or Executor::Answer, got #{outcome.class}" unless outcome.is_a?(Executor::Answer)
|
|
43
43
|
|
|
44
|
-
@answer = Executor::Answer.new(value: @schema.take(outcome.value))
|
|
44
|
+
@answer = Executor::Answer.new(value: @schema.take(outcome.value), printed: outcome.printed)
|
|
45
45
|
halt("Answer accepted.")
|
|
46
46
|
rescue ContractError => e
|
|
47
47
|
# Off-contract answers are corrected inside the same loop, not by another request.
|
|
@@ -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
|
@@ -60,6 +60,15 @@ module Omakase
|
|
|
60
60
|
|
|
61
61
|
def embedder = @embedder ||= ->(text) { RubyLLM.embed(text).vectors }
|
|
62
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
|
+
|
|
63
72
|
# Every step, as it happens: a generation starts, model-written code runs,
|
|
64
73
|
# an answer lands. Anything answering `call(event, **payload)` will do —
|
|
65
74
|
# a logger, a tracer, a test. Nil, the default, costs nothing.
|
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
|
|
@@ -83,6 +83,7 @@ files:
|
|
|
83
83
|
- lib/omakase/strategies/code_act.rb
|
|
84
84
|
- lib/omakase/strategies/predict.rb
|
|
85
85
|
- lib/omakase/tools/ruby.rb
|
|
86
|
+
- lib/omakase/trace.rb
|
|
86
87
|
- lib/omakase/type.rb
|
|
87
88
|
- lib/omakase/version.rb
|
|
88
89
|
homepage: https://github.com/esshka/omakase
|