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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c14f0a611e4eacd12ed77e2944f0af6e8b5f51a908ecceff832418bb7c4f6c7e
4
- data.tar.gz: 7bdeb3cef506c2eda19939a51782c24a87347158b660c83d79f2fb643720131a
3
+ metadata.gz: d0a1114c3a9af5c41d3111880844a2a74a182b23696226533c3d5076b3a3ce63
4
+ data.tar.gz: '049c62448ea9e6d3b96177bda1564b294cb47d08cc7aa7a514db321d0f617394'
5
5
  SHA512:
6
- metadata.gz: 88399ffa0b1b73c20b1983d87c70fe09ef90db58ca2b9f527a7762c1294dbde90ebbac000ea3e297c6195cbb3bbf5614951b472769bfd573abb26909068f2962
7
- data.tar.gz: 5089d67312535691c5d65407492e2fd046a0979bf42ad87f196b7579df515356de364b75050012f38da7e16be3c192912ac5fe101b20b699f29ea4f9819de8a4
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 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,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,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
- 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:
410
576
 
411
577
  ```ruby
412
- 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)
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.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
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, 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
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 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:
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
- def generates(name, prompt = nil, returns: nil, strategy: nil, model: nil, &schema)
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
- define_method(name) { |**inputs| generate(name, inputs) }
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 || RubyLLM.chat(**self.class.chat_options.merge(overrides))
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
- Omakase.emit(:generation, agent: self, name:, inputs:)
154
- value = generation.strategy.call(Request.new(agent: self, generation:, inputs:))
155
- Omakase.emit(:answer, agent: self, name:, value:)
156
- value
157
- rescue RubyLLM::Error, RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError => e
158
- 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
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) = [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
@@ -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
- 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
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
 
@@ -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
- 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.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.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
@@ -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