omakase-agents 0.1.0 → 0.3.0

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