omakase-agents 0.0.2.alpha → 0.2.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: 96b04f8669c6e6bf4f176d19b779ca495fb1b2fde034952c48175eb2999647b3
4
- data.tar.gz: 32d2d58018dd35cf63590c889f6c9e5a8bca214e1ccfcd9989c0f90c157bec70
3
+ metadata.gz: c14f0a611e4eacd12ed77e2944f0af6e8b5f51a908ecceff832418bb7c4f6c7e
4
+ data.tar.gz: 7bdeb3cef506c2eda19939a51782c24a87347158b660c83d79f2fb643720131a
5
5
  SHA512:
6
- metadata.gz: e596c18452218540f3a92c7f2542cbf1ea7e5b4fe0314c359e784d14fe331defdaf40f642cd4679667ed0e6ca5c8e42bd171cb940b2b2bd6b3fd47faba41bf97
7
- data.tar.gz: 18e626683d93029d7d29da409b9f456800cf4b0579e9e478636d4b8ff2902f628ef34460cf3dd935b164e76d057bc3b01bb22c3a4b58fe9c15c354befbdc9cf9
6
+ metadata.gz: 88399ffa0b1b73c20b1983d87c70fe09ef90db58ca2b9f527a7762c1294dbde90ebbac000ea3e297c6195cbb3bbf5614951b472769bfd573abb26909068f2962
7
+ data.tar.gz: 5089d67312535691c5d65407492e2fd046a0979bf42ad87f196b7579df515356de364b75050012f38da7e16be3c192912ac5fe101b20b699f29ea4f9819de8a4
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
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.2.0
7
+
8
+ Nothing breaks. Four additions, each one a keyword or a seam that costs nothing when unused.
9
+
10
+ ### Added
11
+
12
+ - A prompt can be a block: `generates :translate, -> { "Translate to #{@language}." }`. It is read
13
+ at call time, on the agent, so one declaration serves an object however it is configured. A prompt
14
+ that is neither a String nor a block is refused where it is declared. In the capability list a
15
+ block-prompted method shows its signature alone — `describe` it to say more.
16
+ - `generates :classify, model: "claude-haiku-4-5"` — a generation method can name its own model, a
17
+ cheap one beside a strong one. The id lands on top of the class's chat options, so the provider
18
+ stays the class's; an injected `chat:` still wins.
19
+ - `Omakase.listener` — one callback for every step as it happens: `:generation` (`agent:, name:,
20
+ inputs:`), `:ruby` (`agent:, code:, outcome:`), `:answer` (`agent:, name:, value:`). Anything
21
+ answering `call(event, **payload)` will do; nil, the default, costs nothing.
22
+ - `with:` is a reserved argument: it is not rendered into the prompt but passed to RubyLLM's
23
+ `ask(with:)` as attachments — images, audio, PDFs, as paths, URLs or IO. `FakeChat` records them.
24
+
25
+ ## 0.1.0
26
+
27
+ First stable release. `0.0.x` was a prerelease and needed `gem install --pre`; this does not.
28
+
29
+ ### Breaking
30
+
31
+ - `:code_act` with a Ruby class return type (`returns: SomeClass`) no longer falls back to
32
+ `:predict` when the model does not call `finish`. It could not have worked — a class cannot be
33
+ expressed as a JSON schema — and the fallback raised `returns: X needs the :code_act strategy`
34
+ from inside the wrong strategy. It now raises `ContractError` naming the method and the shape it
35
+ never returned. Schemas declared with a block or a scalar still fall back as before.
36
+ - `doc(object)` stops at `ActiveRecord::Base` and prints a record's columns as state rather than as
37
+ methods. Output for an `ActiveRecord` object goes from several hundred lines of framework
38
+ internals to its associations, your methods, and the values it holds.
39
+ - The prompt asks for `finish(Refund.new(order_id:, amount:, reason:))` where it used to say
40
+ `finish(a Refund)`, for any return type that answers `members` (a `Struct` or a `Data`).
41
+
42
+ ### Added
43
+
44
+ - `mcp` — an MCP server's tools become methods on the agent, via `ruby_llm-mcp` (an optional
45
+ dependency; `require` it and options pass through verbatim).
46
+ - `skill` — a `SKILL.md` directory becomes one described method: the front matter's description
47
+ joins the agent's capabilities, the body arrives only when the model calls it.
48
+ - `memory` — `remember(text)` and `recall(query)` over RubyLLM embeddings, with the store as a
49
+ field, so it marshals with the agent.
50
+ - `context` — an instance method to override; whatever it returns is appended to the class's
51
+ instructions on every call. The chat stays fresh per call, deliberately.
52
+ - `Marshal.dump(agent)` is the session: an agent marshals like any object, minus its live chat.
53
+
54
+ ## 0.0.2.alpha, 0.0.1.alpha
55
+
56
+ Prereleases. `Agent`, `generates`, `describe`, the `:predict` and `:code_act` strategies,
57
+ `finish(value)`, `returns: SomeClass`, `doc`, per-thread output, `FakeChat`, and the Rails notes.
data/README.md CHANGED
@@ -3,37 +3,70 @@
3
3
  A light agent framework — about 700 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?include_prereleases&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 —
10
10
  the method name and prompt are the specification, the schema is the contract.
11
11
 
12
12
  ```ruby
13
- class InventoryAgent < ApplicationAgent
14
- instructions "You check inventory."
13
+ class RefundAgent < ApplicationAgent
14
+ instructions "You are the refund desk of an online shop. Decide from the customer’s own orders."
15
15
 
16
- describe "Units of an item on hand"
17
- def stock_of(item) = STOCK.dig(item, :stock) || 0
16
+ describe "Every order this customer placed, newest first. An Order has placed_on, items and total"
17
+ def orders_for(email) = Order.where(email:).order(placed_on: :desc)
18
18
 
19
- describe "Unit price of an item"
20
- def price_of(item) = STOCK.dig(item, :price) || 0.0
19
+ describe "What the policy says about a topic, such as :damage or :late"
20
+ def policy_on(topic) = POLICY.fetch(topic, "Refunds are allowed within 30 days.")
21
21
 
22
- generates :can_fulfill_order, "Decide whether the order fits the budget and is in stock." do
23
- boolean :can_fulfill
24
- number :total_cost
25
- array :unavailable, of: :string
26
- end
22
+ generates :decide, "Decide this refund, and name the policy you applied.", returns: Refund
27
23
  end
28
24
 
29
- InventoryAgent.can_fulfill_order(items: %w[apple banana orange], budget: 5.0)
30
- # => {can_fulfill: false, total_cost: 2.05, unavailable: ["orange"]}
25
+ RefundAgent.decide(email: "ada@example.com", complaint: "the mug arrived cracked")
26
+ # => #<struct Refund order_id=1, amount=39.9,
27
+ # reason="Mug arrived cracked; damaged goods refunded in full including shipping per policy :damage">
31
28
  ```
32
29
 
30
+ `Order` is your ActiveRecord model and `Refund` is your Struct. Nothing was registered anywhere, and
31
+ nothing came back as JSON to parse — the model wrote Ruby against your objects and handed one back.
32
+ Here is the run above, abridged:
33
+
34
+ ```
35
+ ruby orders = orders_for("ada@example.com")
36
+ orders.each { |o| puts "total: #{o.total}", "items: #{o.items.inspect}" }
37
+ out total: 39.9
38
+ items: [#<Item id: 1, name: "Stoneware mug", price: 34.0>, #<Item id: 2, name: "Shipping", price: 5.9>]
39
+ ruby puts policy_on(:damage)
40
+ out Damaged goods are refunded in full, including shipping, within 90 days.
41
+ ruby finish(Refund.new(order_id: 1, amount: 39.9, reason: "Mug arrived cracked; damaged goods …"))
42
+ out Answer accepted.
43
+ ```
44
+
45
+ That transcript is real, and it is from `meta/muse-glimmer-30b` on OpenRouter — `:code_act` is developed
46
+ and tested against a 30B model, because a strategy that only works on a frontier model is a demo, not
47
+ a library. [`examples/refund_agent.rb`](examples/refund_agent.rb) is the whole thing, runnable, with
48
+ an in-memory SQLite database.
49
+
33
50
  There is no tool abstraction to keep in sync: the model writes Ruby that runs on the agent object,
34
51
  reads what it printed and returned, and answers in the declared schema. Adding a tool is adding a
35
52
  method; deleting one is deleting a method.
36
53
 
54
+ ## Why this
55
+
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 —
58
+ is still RubyLLM's, and stays reachable.
59
+
60
+ Against **a framework with a tool registry**: there is nothing to register and nothing to keep in
61
+ sync. The model gets one tool, `ruby`, and reaches the rest through the object. A tool's description
62
+ is `describe`, a line above the method, instead of a JSON schema that drifts from the code it
63
+ describes. The answer comes back as the object the code built, not as JSON you parse again.
64
+
65
+ Reach for something else when the input is untrusted and you want code execution (see
66
+ [Safety](#safety)), when a run is hundreds of steps and must resume mid-flight, when memory means a
67
+ large corpus rather than what one agent learned, or when you want a token stream rather than a
68
+ value.
69
+
37
70
  ## Installation
38
71
 
39
72
  Ruby 3.2+.
@@ -42,15 +75,37 @@ Ruby 3.2+.
42
75
  gem "omakase-agents" # the library is `Omakase`
43
76
  ```
44
77
 
45
- From the command line the flag is needed — `0.0.2.alpha` is a prerelease, and RubyGems skips those
46
- unless asked. Bundler resolves it without one, since no stable version exists yet.
47
-
48
78
  ```bash
49
- gem install omakase-agents --pre
79
+ gem install omakase-agents
50
80
  ```
51
81
 
52
82
  ## Usage
53
83
 
84
+ The whole API, in one class:
85
+
86
+ ```ruby
87
+ class MyAgent < Omakase::Agent
88
+ model "claude-sonnet-4-5" # any RubyLLM model and chat option
89
+ instructions "Who the agent is." # the system prompt
90
+ strategy :code_act # or :predict, or anything answering call(request)
91
+
92
+ mcp :files, transport_type: :stdio, config: {} # an MCP server's tools, as methods
93
+ skill "skills/commit-style" # a SKILL.md directory, as one described method
94
+ memory # remember(text) and recall(query)
95
+
96
+ describe "Units of an item on hand" # the docstring Ruby does not have
97
+ def stock_of(item) = ... # any method of yours is a tool
98
+
99
+ generates :answer, "What to produce.", returns: :string # written by the model at runtime
100
+
101
+ def context = "..." # live state, folded into every prompt
102
+ end
103
+ ```
104
+
105
+ Inside generated code the agent also answers `finish(value)` to return, `doc(object)` to inspect an
106
+ unfamiliar type, and `puts` to say something the model will read back. Outside it, `Marshal.dump`
107
+ is the session.
108
+
54
109
  ### Declaring an agent
55
110
 
56
111
  ```ruby
@@ -84,6 +139,22 @@ FeedbackAgent.new.analyze(text: "…")
84
139
  `describe` above an ordinary method is the docstring Ruby does not have — it is what the model reads
85
140
  when it decides what to call.
86
141
 
142
+ A prompt given as a block is read at call time, on the agent, so one declaration serves an object
143
+ however it happens to be configured:
144
+
145
+ ```ruby
146
+ class TranslatorAgent < ApplicationAgent
147
+ def initialize(language, **options)
148
+ super(**options)
149
+ @language = language
150
+ end
151
+
152
+ generates :translate, -> { "Translate to #{@language}, naturally and idiomatically." }
153
+ end
154
+
155
+ TranslatorAgent.new("Spanish").translate(text: "good morning") # => "buenos días"
156
+ ```
157
+
87
158
  ### Return types
88
159
 
89
160
  The block is a [schematist](https://github.com/crmne/schematist) schema and becomes the provider's
@@ -98,6 +169,20 @@ Both forms are the same mechanism: a schema whose only property is `result` unwr
98
169
  A Ruby class works too — `returns: Ticket` — and then the method hands back the object rather than
99
170
  data; see [`:code_act`](#strategies) for what that requires.
100
171
 
172
+ ### Attachments
173
+
174
+ `with:` is a reserved argument: it is not rendered into the prompt but sent as attachments —
175
+ images, audio, PDFs — exactly as RubyLLM's `ask(with:)` takes them (paths, URLs, IO objects):
176
+
177
+ ```ruby
178
+ class VisionAgent < ApplicationAgent
179
+ generates :caption, "Describe the photo.", strategy: :predict
180
+ end
181
+
182
+ VisionAgent.caption(with: "photo.jpg")
183
+ VisionAgent.caption(question: "what breed?", with: ["a.png", "b.png"])
184
+ ```
185
+
101
186
  ### Models and providers
102
187
 
103
188
  Any provider RubyLLM supports — Anthropic, OpenAI, Gemini, Bedrock, Azure, Mistral, DeepSeek, xAI,
@@ -115,6 +200,13 @@ end
115
200
  Naming a provider implies `assume_model_exists: true`; any other RubyLLM chat option passes through.
116
201
  Subclasses inherit the setting and can override it, so one `ApplicationAgent` configures the lot.
117
202
 
203
+ A generation method can name its own model — a cheap one for classification beside a strong one
204
+ for reasoning. The id lands on top of the class's options, so the provider stays the class's:
205
+
206
+ ```ruby
207
+ generates :classify, "Sort this ticket into a queue.", model: "claude-haiku-4-5"
208
+ ```
209
+
118
210
  Credentials come from the environment — one call covers every provider:
119
211
 
120
212
  ```ruby
@@ -150,7 +242,6 @@ end
150
242
  The connection opens when the class is defined and the tools are read from the server then, so a
151
243
  tool's arguments reach the model as documentation. A failed call raises, which the model sees and
152
244
  can correct. Only text comes back: an image or audio result is dropped.
153
- ### Testing
154
245
 
155
246
  ### Skills
156
247
 
@@ -170,6 +261,85 @@ That is the whole of “loaded on demand”: the one-line description is in the
170
261
  reaches the model if the generated code calls `commit_style`. Anything else the skill ships —
171
262
  scripts, templates — sits in the same directory, and the body ends with its path, so generated Ruby
172
263
  can read or run it.
264
+
265
+ ### Remembering
266
+
267
+ The chat is fresh on every call — two threads calling one agent must not share a mutable
268
+ conversation. What carries between calls is the object itself: override `context`, and whatever it
269
+ returns is appended to the instructions of the next call.
270
+
271
+ ```ruby
272
+ class InterviewAgent < ApplicationAgent
273
+ instructions "You interview a Ruby candidate. One question at a time."
274
+
275
+ def context = @asked.empty? ? nil : "Questions you already asked:\n- #{@asked.join("\n- ")}"
276
+
277
+ generates :question_after, "Ask the next question, on a topic you have not covered yet."
278
+
279
+ def ask(answer) = @asked << question_after(answer:)
280
+ end
281
+ ```
282
+
283
+ So “what to keep” is a decision you write in Ruby rather than a policy the library guesses: keep the
284
+ last ten, keep a summary, keep the rows you touched. State is the memory, and it is already typed,
285
+ testable, and yours.
286
+
287
+ ### Resuming
288
+
289
+ Because the state is the object, persisting a run is persisting the object — nothing to configure:
290
+
291
+ ```ruby
292
+ Redis.current.set("interview:#{id}", Marshal.dump(agent))
293
+
294
+ agent = Marshal.load(Redis.current.get("interview:#{id}"))
295
+ agent.ask("...") # picks up with everything it kept
296
+ ```
297
+
298
+ The live chat is left out of the dump and rebuilt on the next call, so a resumed agent holds no
299
+ stale connection. In Rails you usually need none of this: the state came from your models, and the
300
+ agent is rebuilt from those rows per request.
301
+
302
+ A generation in flight is not resumable — the tool loop is RubyLLM's, and a crashed one is retried
303
+ whole, which is what `ActiveJob` does anyway.
304
+
305
+ ### Memory
306
+
307
+ `memory` adds two more methods to the agent — one to save something, one to search it by meaning:
308
+
309
+ ```ruby
310
+ class SupportAgent < ApplicationAgent
311
+ instructions "You help customers."
312
+ memory
313
+
314
+ generates :answer, "Answer the customer, using what you remember.", returns: :string
315
+ end
316
+ ```
317
+
318
+ They are listed with everything else the agent can do, so generated code decides when to reach for
319
+ them — `remember("Shipping to Canada takes three weeks")` on the way out, `recall("delivery time")`
320
+ on the way in. The store is a field, so what the agent learned marshals with it and is there on the
321
+ next run.
322
+
323
+ Embeddings come from RubyLLM, and `Omakase.embedder` is the seam if you want another source — a
324
+ fake one keeps tests offline, and any provider fits through it:
325
+
326
+ ```ruby
327
+ # RubyLLM defaults to an OpenAI embedding model; OpenRouter serves embeddings too,
328
+ # it just does not list them, so the model is named on trust.
329
+ Omakase.embedder = ->(text) do
330
+ RubyLLM.embed(text, model: "qwen/qwen3-embedding-4b", provider: :openrouter,
331
+ assume_model_exists: true).vectors
332
+ end
333
+ ```
334
+
335
+ The search is a dot product over unit vectors. That holds for the few hundred
336
+ things one agent learns about its work; past that it is your database's job — pgvector and the
337
+ [`neighbor`](https://github.com/ankane/neighbor) gem — and `Omakase::Memory` is the interface to
338
+ reimplement against it. And for a few dozen facts, `@notes.grep(/shipping/)` beats every word of
339
+ this.
340
+
341
+ ### Testing
342
+
173
343
  `Omakase::Agent.new(chat:)` takes any object that quacks like a `RubyLLM::Chat`, and one ships with
174
344
  the library, so agents are tested without a network:
175
345
 
@@ -183,6 +353,18 @@ chat = Omakase::FakeChat.new { |fake| fake.run("finish(stock_of(:apple))") }
183
353
 
184
354
  It records `instructions`, `schema`, `tools` and `tasks`, so the prompt is assertable too.
185
355
 
356
+ ### Listening in
357
+
358
+ One callback hears every step as it happens: a generation starting, model-written code running,
359
+ an answer landing. Wire it to a logger or a tracer; nil, the default, costs nothing.
360
+
361
+ ```ruby
362
+ Omakase.listener = ->(event, **payload) { Rails.logger.info("#{event} #{payload.except(:agent)}") }
363
+ ```
364
+
365
+ `:generation` carries `agent:, name:, inputs:` · `:ruby` carries `agent:, code:, outcome:` ·
366
+ `:answer` carries `agent:, name:, value:`.
367
+
186
368
  ## Rails
187
369
 
188
370
  Agents live in `app/agents` — Rails autoloads it, and reloading is safe because everything a
@@ -233,6 +415,49 @@ ids.map { |id| Thread.new { WarehouseAgent.appraise(item_id: id) } }.map(&:value
233
415
  Sharing one agent instance across threads is your business as usual — its state is yours. Do not
234
416
  turn on RubyLLM's `tool_concurrency`: that runs generated code against the same agent in parallel.
235
417
 
418
+ **Multi-turn, many pods.** Identity is a row, state is your tables, and the agent is a value —
419
+ rebuilt from them for one turn and thrown away. The chat is fresh per call anyway, so nothing
420
+ sticks to a process: any pod serves any turn, and multi-turn is nothing more than `context`
421
+ reading the history back.
422
+
423
+ ```ruby
424
+ class SupportAgent < ApplicationAgent
425
+ instructions "You are the support desk. Decide from the customer's own data."
426
+
427
+ def initialize(conversation, **options)
428
+ super(**options)
429
+ @conversation = conversation
430
+ end
431
+
432
+ # the whole of multi-turn: history is context, rebuilt every turn
433
+ def context
434
+ @conversation.messages.order(:created_at).last(30)
435
+ .map { |message| "#{message.role}: #{message.content}" }.join("\n")
436
+ end
437
+
438
+ describe "Refund an order; refuses anything above the paid total"
439
+ def refund!(order_id, amount) # invariants live here, not in the prompt
440
+ order = @conversation.user.orders.find(order_id) # scoping is authorization
441
+ raise ArgumentError, "over paid total" if amount > order.total
442
+ Refunds.issue!(order, amount)
443
+ end
444
+
445
+ generates :reply, "Answer the customer's last message.", returns: :string
446
+ end
447
+
448
+ class TurnJob < ApplicationJob
449
+ 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
454
+ end
455
+ end
456
+ ```
457
+
458
+ Marshal-into-a-column is the escape hatch for resuming a run mid-flight, not the default: rows can
459
+ be queried and migrated, blobs cannot.
460
+
236
461
  **Errors.** Everything raised at the boundary is an `Omakase::Error`:
237
462
 
238
463
  | | |
@@ -278,6 +503,12 @@ an answer that misses the contract is rejected into the same loop — the model
278
503
  another request. Nothing in the provider bounds a tool loop, so the tool does: ten calls, then a turn
279
504
  to answer with what it has.
280
505
 
506
+ Generation methods are public methods like any other, so generated code can call them — a
507
+ `:code_act` loop handing a classification to a `:predict` method, or an agent calling another agent
508
+ it holds in a field. And because the code runs on the object, the model can leave state for
509
+ `context` to read on the next call, or define itself a helper method — self-extension is just
510
+ `instance_eval`.
511
+
281
512
  Because the answer is computed rather than retyped, the return type can be a Ruby class and the
282
513
  method hands back the object itself:
283
514
 
@@ -317,6 +548,7 @@ generates :plan, strategy: CriticStrategy
317
548
  lib/omakase/tools/ruby.rb that executor, as a RubyLLM tool, with a call budget
318
549
  lib/omakase/mcp.rb an MCP server’s tools, as methods on the agent
319
550
  lib/omakase/skills.rb a SKILL.md directory, as one described method
551
+ lib/omakase/memory.rb remember and recall, by meaning
320
552
  lib/omakase/fake_chat.rb the stand-in chat for tests
321
553
  lib/omakase/strategies/ code_act, predict
322
554
 
@@ -328,15 +560,18 @@ Copy `.env.example` to `.env` and fill in a key; `MODEL` and `PROVIDER` there pi
328
560
  | --- | --- |
329
561
  | [`feedback_agent.rb`](examples/feedback_agent.rb) | structured output in one call |
330
562
  | [`inventory_agent.rb`](examples/inventory_agent.rb) | the agent's methods as the model's tools |
563
+ | [`refund_agent.rb`](examples/refund_agent.rb) | ActiveRecord objects in, a Ruby object out |
331
564
  | [`warehouse_agent.rb`](examples/warehouse_agent.rb) | inspecting objects whose types are unknown |
332
565
  | [`support_agent.rb`](examples/support_agent.rb) | plain Ruby orchestrating generated methods |
333
566
  | [`support_job.rb`](examples/support_job.rb) | generation off the request thread, via ActiveJob |
334
567
  | [`rails_app.rb`](examples/rails_app.rb) | a whole Rails app in one file: initializer, agent, controller |
335
568
  | [`mcp_agent.rb`](examples/mcp_agent.rb) | an MCP server's tools as methods on the agent |
336
569
  | [`skill_agent.rb`](examples/skill_agent.rb) | a SKILL.md directory the model loads when it needs it |
570
+ | [`interview_agent.rb`](examples/interview_agent.rb) | remembering across calls, without a shared chat |
571
+ | [`memory_agent.rb`](examples/memory_agent.rb) | recall by meaning, kept across a marshalled run |
337
572
 
338
573
  ```bash
339
- bundle exec rake # tests, no network
574
+ bundle exec rake # tests and Standard, no network
340
575
  ruby examples/inventory_agent.rb
341
576
  ```
342
577
 
@@ -348,6 +583,8 @@ too. Two rules follow:
348
583
 
349
584
  - **Untrusted input (anything a user typed) belongs to `:predict`.** No code runs there.
350
585
  - **`:code_act` is for work you control** — internal tooling, workers, isolated environments.
586
+ - **A marshalled agent is your data, never user input.** `Marshal.load` on bytes someone else can
587
+ write is remote code execution, resumed run or not.
351
588
 
352
589
  What is bounded: ten tool calls per generation, a 30-second timeout per execution, and 4KB of
353
590
  observation. What is not: what the code can reach. For real isolation, swap the executor —
@@ -357,21 +594,16 @@ anything answering `call(agent, code, timeout:)` will do:
357
594
  Omakase.executor = MySubprocessExecutor # returns an observation String or Executor::Answer
358
595
  ```
359
596
 
360
- ## Roadmap
361
-
362
- What is not here yet, roughly in the order it would earn its place:
363
-
364
- - [x] **Live objects** the answer is computed in code and handed back as the object, not retyped
365
- as JSON. Done: `finish(value)` plus `returns: SomeClass`.
366
- - [x] **Tracing** RubyLLM emits `chat.ruby_llm` and `tool_call.ruby_llm`; point `config.instrumenter`
367
- at `ActiveSupport::Notifications` and subscribe. Done, by not writing it.
368
- - [ ] **Conversation history** — the chat is fresh per call. Keeping one per agent would let a
369
- method continue where the last one left off, at the cost of deciding what to keep.
370
- - [ ] **Session storage** — persist that history and the agent state so a run can be resumed.
371
- - [x] **MCP tools** external tools over the Model Context Protocol, via `ruby_llm-mcp`. Done:
372
- `mcp :files, …` puts the server's tools on the agent, and generated code calls them.
373
- - [x] **Skills** — capabilities as markdown files with front matter, loaded on demand rather than
374
- all sitting in the system prompt. Done: `skill "path/to/dir"`, one described method.
375
- - [ ] **Memory** — recall that survives across sessions, backed by vector search.
376
- - [x] **Concurrency** — parallel generation calls. Done: output is buffered per thread instead of
377
- through `$stdout`, so threads no longer collide.
597
+ ## Not here, on purpose
598
+
599
+ - **A checkpoint inside a generation.** A crashed run is retried whole. The tool loop belongs to
600
+ RubyLLM, and making it resumable would be a different library.
601
+ - **Reflection and forgetting in memory.** No decay, no consolidation pass: `Memory` grows until you
602
+ 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.
604
+ - **Multi-agent orchestration.** An agent is an object, so one agent calling another is a method
605
+ call. There is nothing to add.
606
+ - **Streaming.** A generation method returns a value, not tokens. RubyLLM streams if you need that.
607
+
608
+ `1.0` lands when the API stops moving. Until then a minor version may move it, and `0.1.0` means
609
+ the library is usable, not that it is finished.
data/lib/omakase/agent.rb CHANGED
@@ -37,18 +37,40 @@ module Omakase
37
37
  # A skill directory — a SKILL.md with YAML front matter. Its description
38
38
  # joins the agent's capabilities; its body arrives when the model asks.
39
39
  def skill(path) = Skills.attach(self, path)
40
+
41
+ # Two more methods: one to save something, one to search it by meaning.
42
+ # The store is a field, so it marshals with the agent and outlives the run.
43
+ def memory
44
+ describe "Save something worth remembering after this run"
45
+ define_method(:remember) { |text| (@memory ||= Memory.new).remember(text) }
46
+ describe "Search what you remember, by meaning; the closest few come back"
47
+ define_method(:recall) { |query, limit: 5| (@memory ||= Memory.new).recall(query, limit:) }
48
+ end
49
+
40
50
  # Documents the method defined next — the docstring Ruby does not have.
41
51
  def describe(text)
42
52
  @pending_description = text
43
53
  end
44
54
 
45
- # Without a prompt, the method name is the prompt.
46
- def generates(name, prompt = nil, returns: nil, strategy: nil, &schema)
55
+ # 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
+ # Redeclaring an inherited generation is how a subclass specialises one.
59
+ # Landing on a method you wrote is not that, and would replace it unseen.
60
+ if Capabilities.names(self).include?(name) && !generations.key?(name)
61
+ raise Error, "#{self}##{name} is already a method — generates would replace it"
62
+ end
63
+
64
+ unless prompt.nil? || prompt.is_a?(String) || prompt.is_a?(Proc)
65
+ raise Error, "#{self}##{name}: a prompt is a String or a block returning one, got #{prompt.class}"
66
+ end
67
+
47
68
  generations[name] = Generation.new(
48
69
  name:,
49
70
  prompt: prompt || humanize(name),
50
71
  schema: Schema.define(returns:, &schema),
51
- strategy: Strategies.fetch(strategy || self.strategy)
72
+ strategy: Strategies.fetch(strategy || self.strategy),
73
+ model:
52
74
  )
53
75
  define_method(name) { |**inputs| generate(name, inputs) }
54
76
  define_singleton_method(name) { |**inputs| new.public_send(name, **inputs) }
@@ -88,8 +110,20 @@ module Omakase
88
110
  @chat = chat
89
111
  end
90
112
 
91
- # A fresh conversation per call.
92
- def chat = @chat || RubyLLM.chat(**self.class.chat_options)
113
+ # A fresh conversation per call — two threads calling one agent must not
114
+ # share a mutable chat. What carries between calls is the object's own state.
115
+ # 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))
117
+
118
+ # That state, as the model should read it: rebuilt on every call, and added
119
+ # to the class's instructions. Override it to remember anything.
120
+ def context = nil
121
+
122
+ # Resuming a run is loading the object back, so an agent marshals like any
123
+ # other Ruby object — minus the live chat, which is rebuilt on demand.
124
+ def marshal_dump = (instance_variables - [:@chat]).to_h { |name| [name, instance_variable_get(name)] }
125
+
126
+ def marshal_load(state) = state.each { |name, value| instance_variable_set(name, value) }
93
127
 
94
128
  # For generated code meeting an object whose type it does not know.
95
129
  def doc(object) = puts(Doc.of(object))
@@ -105,7 +139,7 @@ module Omakase
105
139
 
106
140
  def p(*args)
107
141
  args.each { |arg| omakase_output.puts(arg.inspect) }
108
- args.size <= 1 ? args.first : args
142
+ (args.size <= 1) ? args.first : args
109
143
  end
110
144
 
111
145
  alias_method :pp, :p
@@ -116,7 +150,10 @@ module Omakase
116
150
 
117
151
  def generate(name, inputs)
118
152
  generation = self.class.generations.fetch(name)
119
- generation.strategy.call(Request.new(agent: self, generation:, inputs:))
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
120
157
  rescue RubyLLM::Error, RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError => e
121
158
  raise ProviderError, "#{self.class}##{name}: #{e.message}"
122
159
  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
@@ -8,16 +8,29 @@ module Omakase
8
8
 
9
9
  module_function
10
10
 
11
- def of(object)
12
- state = object.instance_variables.map { |name| " #{name} = #{object.instance_variable_get(name).inspect}" }
13
- [object.class.to_s, *signatures(object), *state].join("\n")
11
+ # A framework's base class defines hundreds of methods the model has no use
12
+ # for. What it wants is what this class adds — its columns, its associations,
13
+ # and the methods you wrote.
14
+ def boundary = defined?(ActiveRecord::Base) ? [*CORE, ActiveRecord::Base] : CORE
15
+
16
+ def of(object) = [object.class.to_s, *signatures(object), *state(object)].join("\n")
17
+
18
+ # An object that answers `attributes` says what it holds better than its
19
+ # instance variables do — and a record's columns are state, not API.
20
+ def state(object)
21
+ values = object.respond_to?(:attributes) ? object.attributes : ivars(object)
22
+ values.map { |name, value| " #{name} = #{value.inspect}" }
14
23
  end
15
24
 
25
+ def ivars(object) = object.instance_variables.to_h { |name| [name, object.instance_variable_get(name)] }
26
+
16
27
  def signatures(object)
17
28
  object.class.ancestors
18
- .take_while { |mod| !CORE.include?(mod) }
29
+ .take_while { |mod| !boundary.include?(mod) }
30
+ .reject { |mod| mod.to_s.end_with?("GeneratedAttributeMethods") }
19
31
  .flat_map { |mod| mod.public_instance_methods(false) }
20
32
  .uniq.sort
33
+ .reject { |name| name.match?(/\A_|_associated_records_for_/) }
21
34
  .map { |name| " #{name}(#{Capabilities.parameters(object.method(name))})" }
22
35
  end
23
36
  end
@@ -8,6 +8,7 @@ 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
14
  # What `finish(value)` handed back: the answer as a Ruby value, not as text.
@@ -28,7 +29,7 @@ module Omakase
28
29
 
29
30
  # The model can only fix what it can locate, so point at the line.
30
31
  def failure(error, code)
31
- line = error.backtrace&.grep(/\A#{Regexp.escape(SOURCE)}:\d+/)&.first&.slice(/:(\d+)/, 1)&.to_i
32
+ line = error.backtrace&.grep(TRACE)&.first&.slice(/:(\d+)/, 1)&.to_i
32
33
  source = code.lines[line - 1]&.strip if line&.positive?
33
34
  ["#{error.class}: #{error.message}", ("line #{line}: #{source}" if source)].compact.join("\n")
34
35
  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
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # Recall by meaning rather than by key: text goes in, the closest of it comes
5
+ # back out. The embeddings are RubyLLM's and the search is a dot product over
6
+ # an array — enough for the few hundred things one agent learns about its
7
+ # work. Past that it is your database's job (pgvector, the `neighbor` gem),
8
+ # and this is the interface to reimplement against it.
9
+ class Memory
10
+ def initialize = @entries = {}
11
+
12
+ # Keyed by the text, so remembering the same thing twice costs one entry.
13
+ def remember(text)
14
+ @entries[text] ||= unit(Omakase.embedder.call(text))
15
+ text
16
+ end
17
+
18
+ def recall(query, limit: 5)
19
+ return [] if empty?
20
+
21
+ vector = unit(Omakase.embedder.call(query))
22
+ @entries.max_by(limit) { |_, remembered| dot(vector, remembered) }.map(&:first)
23
+ end
24
+
25
+ def size = @entries.size
26
+
27
+ def empty? = @entries.empty?
28
+
29
+ private
30
+
31
+ def dot(one, other) = one.zip(other).sum { |a, b| a * b }
32
+
33
+ # Unit vectors, so the dot product is the cosine and lengths cannot skew it.
34
+ def unit(vector)
35
+ norm = Math.sqrt(vector.sum { |value| value * value })
36
+ norm.zero? ? vector : vector.map { |value| value / norm }
37
+ end
38
+ end
39
+ 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
- def instructions = agent.class.instructions
10
+ def instructions = [agent.class.instructions, agent.context].reject { |text| text.to_s.empty? }.join("\n\n")
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]
11
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
 
@@ -29,6 +32,8 @@ module Omakase
29
32
 
30
33
  def json = @json ||= definition.new.to_json_schema
31
34
 
35
+ def code_only? = false
36
+
32
37
  # The shape, in the shorthand the model writes back: `{city: <string>}`.
33
38
  def describe
34
39
  return "<#{properties.fetch("result")["type"]}>" if wrapped?
@@ -63,7 +68,7 @@ module Omakase
63
68
  def properties = json.fetch("properties")
64
69
 
65
70
  def demand(value, type)
66
- 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))
67
72
  raise ContractError, "expected <#{type}>, got #{value.inspect}" unless matched
68
73
 
69
74
  value
@@ -12,13 +12,16 @@ 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
19
19
 
20
- # It never called finish: fall back to a tool-free turn under the schema.
21
- Predict.call(request, task: "#{request.task}\n\nWork done:\n#{notes}")
20
+ # It never called finish. A JSON answer can still be given in a tool-free turn.
21
+ return Predict.call(request, task: "#{request.task}\n\nWork done:\n#{notes}") unless request.schema.code_only?
22
+
23
+ # An object cannot come back as JSON, so there is nowhere to fall back to.
24
+ raise ContractError, "#{request.generation.name}: the model never called finish(#{request.schema.describe})"
22
25
  end
23
26
 
24
27
  def instructions(request)
@@ -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
44
  @answer = Executor::Answer.new(value: @schema.take(outcome.value))
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
data/lib/omakase/type.rb CHANGED
@@ -8,7 +8,16 @@ module Omakase
8
8
  @klass = klass
9
9
  end
10
10
 
11
- def describe = "a #{@klass}"
11
+ # A Struct or Data says what it holds, and the model needs that to build one:
12
+ # `Refund.new(order_id:, amount:, reason:)` beats `a Refund`.
13
+ def describe
14
+ return "a #{@klass}" unless @klass.respond_to?(:members)
15
+
16
+ "#{@klass}.new(#{@klass.members.map { |name| "#{name}:" }.join(", ")})"
17
+ end
18
+
19
+ # Only code can build it, so :predict is not a fallback.
20
+ def code_only? = true
12
21
 
13
22
  def take(value)
14
23
  return value if value.is_a?(@klass)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omakase
4
- VERSION = "0.0.2.alpha"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/omakase.rb CHANGED
@@ -46,8 +46,38 @@ 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
54
+
55
+ # How text becomes a vector, for Memory. Anything answering `call(text)`
56
+ # will do; the model and its provider are RubyLLM's to configure.
57
+ def embedder=(embedder)
58
+ @embedder = callable!(embedder, "embedder")
59
+ end
60
+
61
+ def embedder = @embedder ||= ->(text) { RubyLLM.embed(text).vectors }
62
+
63
+ # Every step, as it happens: a generation starts, model-written code runs,
64
+ # an answer lands. Anything answering `call(event, **payload)` will do —
65
+ # a logger, a tracer, a test. Nil, the default, costs nothing.
66
+ def listener=(listener)
67
+ @listener = callable!(listener, "listener")
68
+ end
69
+
70
+ attr_reader :listener
71
+
72
+ def emit(event, **payload) = @listener&.call(event, **payload)
73
+
74
+ private
75
+
76
+ # Fail where the swap is made, not deep inside a generation.
77
+ def callable!(object, name)
78
+ return object if object.nil? || object.respond_to?(:call)
79
+
80
+ raise Error, "#{name} must answer call, got #{object.class}"
81
+ end
52
82
  end
53
83
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omakase-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2.alpha
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - eugeny
@@ -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
@@ -74,6 +75,7 @@ files:
74
75
  - lib/omakase/fake_chat.rb
75
76
  - lib/omakase/generation.rb
76
77
  - lib/omakase/mcp.rb
78
+ - lib/omakase/memory.rb
77
79
  - lib/omakase/request.rb
78
80
  - lib/omakase/schema.rb
79
81
  - lib/omakase/skills.rb
@@ -90,6 +92,7 @@ metadata:
90
92
  homepage_uri: https://github.com/esshka/omakase
91
93
  source_code_uri: https://github.com/esshka/omakase
92
94
  bug_tracker_uri: https://github.com/esshka/omakase/issues
95
+ changelog_uri: https://github.com/esshka/omakase/blob/main/CHANGELOG.md
93
96
  rubygems_mfa_required: 'true'
94
97
  post_install_message:
95
98
  rdoc_options: []
@@ -102,9 +105,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
102
105
  version: '3.2'
103
106
  required_rubygems_version: !ruby/object:Gem::Requirement
104
107
  requirements:
105
- - - ">"
108
+ - - ">="
106
109
  - !ruby/object:Gem::Version
107
- version: 1.3.1
110
+ version: '0'
108
111
  requirements: []
109
112
  rubygems_version: 3.4.10
110
113
  signing_key: