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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c14f0a611e4eacd12ed77e2944f0af6e8b5f51a908ecceff832418bb7c4f6c7e
4
- data.tar.gz: 7bdeb3cef506c2eda19939a51782c24a87347158b660c83d79f2fb643720131a
3
+ metadata.gz: bdeb9ba4fb16ca770bcedd8b127b76cc925af49cfa73a9257d9e3d27e060de97
4
+ data.tar.gz: a7b72e4946ab8add9c5baac75bcd9bf5bf0419d70bfe79c54fbd843a280f78ed
5
5
  SHA512:
6
- metadata.gz: 88399ffa0b1b73c20b1983d87c70fe09ef90db58ca2b9f527a7762c1294dbde90ebbac000ea3e297c6195cbb3bbf5614951b472769bfd573abb26909068f2962
7
- data.tar.gz: 5089d67312535691c5d65407492e2fd046a0979bf42ad87f196b7579df515356de364b75050012f38da7e16be3c192912ac5fe101b20b699f29ea4f9819de8a4
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 700 lines of library. *Omakase* (お任せ): you name what you want,
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) ![gem](https://img.shields.io/gem/v/omakase-agents?color=c8452e&label=)
@@ -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 700 lines are. Everything else — providers, keys, models, streaming, tracing —
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`; any other RubyLLM chat option passes through.
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 when the class is defined and the tools are read from the server then, so a
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[:input_tokens], output: payload[:output_tokens])
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
- just threads:
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 { |id| Thread.new { WarehouseAgent.appraise(item_id: id) } }.map(&:value)
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.with_lock do # turns on one conversation stay serial
451
- reply = SupportAgent.new(conversation).reply
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, so it cannot recurse into itself. A failure comes
501
- back with the line that raised, `doc(object)` prints what an object of an unfamiliar type offers, and
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 runs generated Ruby against the agent
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 30-second timeout per execution, and 4KB of
590
- observation. What is not: what the code can reach. For real isolation, swap the executor —
591
- anything answering `call(agent, code, timeout:)` will do:
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 = MySubprocessExecutor # returns an observation String or Executor::Answer
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.** `instance_eval` runs in your process. Real isolation is a swapped executor, above.
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.