omakase-agents 0.0.1.alpha → 0.1.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: c1cd37ac842736548d6501feab0ad139df3bad22437902f19ead73ed5eae819c
4
- data.tar.gz: 733def6d93d950e7bd1507240877d90447dd7359255ba4e3c5bc3d93ce3794c6
3
+ metadata.gz: 9c9bb739c426cb0b979359a772e7cdcbe84019c303d10c0d1c0b27fe8b8d00c0
4
+ data.tar.gz: 88c343d1a654d19be11b6071658e962be3f64dc477e8be1aa767520c3c7ac13f
5
5
  SHA512:
6
- metadata.gz: 78f54588fa331091f7e604bab6637a31a9f7f14298b0f513df78e243f851079f9181c2c71c627f0a33d5069ec541711adee81e17516916077990b5e1495a4533
7
- data.tar.gz: 4c97ef7304cce538f6dd4b9d95d6c7053bc6fff9ee6cab88a02b9d44ccdc38e78edfcf95d669a3a9a20fd9f94557d28e05b714eeac3405957d47e1e615f89592
6
+ metadata.gz: 8d02d96784acf426a52dc5eb2207d3a426e9beddb1106cd96195941781557019823be03502e9c580253ff249a8dfa7df0d4210e7eace17a6cfaca1d362321151
7
+ data.tar.gz: 6fb9221fb891f4a9b0898b8924ec2705e093acfeba76367c57bde8d56810227da3ea9667036638360515797ceda2bf1d5edd442766f1fb87f44aeeaa75265166
data/README.md CHANGED
@@ -1,37 +1,72 @@
1
1
  # Omakase
2
2
 
3
- A light agent framework — about 600 lines of library. *Omakase* (お任せ): you name what you want,
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?color=c8452e&label=)
7
+
6
8
  The whole philosophy: **an agent is an object**. Its fields are state, its methods are what the
7
9
  model can call, and the methods it *declares without a body* are written by the model at runtime —
8
10
  the method name and prompt are the specification, the schema is the contract.
9
11
 
10
12
  ```ruby
11
- class InventoryAgent < ApplicationAgent
12
- 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."
13
15
 
14
- describe "Units of an item on hand"
15
- 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)
16
18
 
17
- describe "Unit price of an item"
18
- 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.")
19
21
 
20
- generates :can_fulfill_order, "Decide whether the order fits the budget and is in stock." do
21
- boolean :can_fulfill
22
- number :total_cost
23
- array :unavailable, of: :string
24
- end
22
+ generates :decide, "Decide this refund, and name the policy you applied.", returns: Refund
25
23
  end
26
24
 
27
- InventoryAgent.can_fulfill_order(items: %w[apple banana orange], budget: 5.0)
28
- # => {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">
29
28
  ```
30
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
+
31
50
  There is no tool abstraction to keep in sync: the model writes Ruby that runs on the agent object,
32
51
  reads what it printed and returned, and answers in the declared schema. Adding a tool is adding a
33
52
  method; deleting one is deleting a method.
34
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
+
35
70
  ## Installation
36
71
 
37
72
  Ruby 3.2+.
@@ -40,17 +75,37 @@ Ruby 3.2+.
40
75
  gem "omakase-agents" # the library is `Omakase`
41
76
  ```
42
77
 
43
- Alpha (`0.0.1.alpha`) and not on RubyGems yet, so until it is, build it from a checkout:
44
-
45
78
  ```bash
46
- git clone https://github.com/esshka/omakase
47
- cd omakase
48
- gem build omakase-agents.gemspec
49
- gem install ./omakase-agents-*.gem
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
@@ -131,6 +186,110 @@ Omakase.configure do |config|
131
186
  end
132
187
  ```
133
188
 
189
+ ### MCP tools
190
+
191
+ An MCP server's tools become methods on the agent, listed among its capabilities like any other —
192
+ so generated code calls a remote tool and the agent's own methods in the same expression. Add the
193
+ `ruby_llm-mcp` gem; options are passed to it verbatim.
194
+
195
+ ```ruby
196
+ class DocsAgent < ApplicationAgent
197
+ mcp :files,
198
+ transport_type: :stdio,
199
+ config: {command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", Rails.root.to_s]}
200
+
201
+ generates :changelog, "Summarise what changed in the last release."
202
+ end
203
+ ```
204
+
205
+ The connection opens when the class is defined and the tools are read from the server then, so a
206
+ tool's arguments reach the model as documentation. A failed call raises, which the model sees and
207
+ can correct. Only text comes back: an image or audio result is dropped.
208
+
209
+ ### Skills
210
+
211
+ A skill is a directory with a `SKILL.md` — the same YAML front matter Claude Code and friends use.
212
+ `skill` reads it and defines one method: the front matter's `description` joins the agent's
213
+ capabilities, and the body is what the method returns.
214
+
215
+ ```ruby
216
+ class CommitAgent < ApplicationAgent
217
+ skill "skills/commit-style" # description: "How this project writes commit subjects…"
218
+
219
+ generates :subject_for, "Write the commit subject for this change.", returns: :string
220
+ end
221
+ ```
222
+
223
+ That is the whole of “loaded on demand”: the one-line description is in the prompt, the body only
224
+ reaches the model if the generated code calls `commit_style`. Anything else the skill ships —
225
+ scripts, templates — sits in the same directory, and the body ends with its path, so generated Ruby
226
+ can read or run it.
227
+
228
+ ### Remembering
229
+
230
+ The chat is fresh on every call — two threads calling one agent must not share a mutable
231
+ conversation. What carries between calls is the object itself: override `context`, and whatever it
232
+ returns is appended to the instructions of the next call.
233
+
234
+ ```ruby
235
+ class InterviewAgent < ApplicationAgent
236
+ instructions "You interview a Ruby candidate. One question at a time."
237
+
238
+ def context = @asked.empty? ? nil : "Questions you already asked:\n- #{@asked.join("\n- ")}"
239
+
240
+ generates :question_after, "Ask the next question, on a topic you have not covered yet."
241
+
242
+ def ask(answer) = @asked << question_after(answer:)
243
+ end
244
+ ```
245
+
246
+ So “what to keep” is a decision you write in Ruby rather than a policy the library guesses: keep the
247
+ last ten, keep a summary, keep the rows you touched. State is the memory, and it is already typed,
248
+ testable, and yours.
249
+
250
+ ### Resuming
251
+
252
+ Because the state is the object, persisting a run is persisting the object — nothing to configure:
253
+
254
+ ```ruby
255
+ Redis.current.set("interview:#{id}", Marshal.dump(agent))
256
+
257
+ agent = Marshal.load(Redis.current.get("interview:#{id}"))
258
+ agent.ask("...") # picks up with everything it kept
259
+ ```
260
+
261
+ The live chat is left out of the dump and rebuilt on the next call, so a resumed agent holds no
262
+ stale connection. In Rails you usually need none of this: the state came from your models, and the
263
+ agent is rebuilt from those rows per request.
264
+
265
+ A generation in flight is not resumable — the tool loop is RubyLLM's, and a crashed one is retried
266
+ whole, which is what `ActiveJob` does anyway.
267
+
268
+ ### Memory
269
+
270
+ `memory` adds two more methods to the agent — one to save something, one to search it by meaning:
271
+
272
+ ```ruby
273
+ class SupportAgent < ApplicationAgent
274
+ instructions "You help customers."
275
+ memory
276
+
277
+ generates :answer, "Answer the customer, using what you remember.", returns: :string
278
+ end
279
+ ```
280
+
281
+ They are listed with everything else the agent can do, so generated code decides when to reach for
282
+ them — `remember("Shipping to Canada takes three weeks")` on the way out, `recall("delivery time")`
283
+ on the way in. The store is a field, so what the agent learned marshals with it and is there on the
284
+ next run.
285
+
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
288
+ things one agent learns about its work; past that it is your database's job — pgvector and the
289
+ [`neighbor`](https://github.com/ankane/neighbor) gem — and `Omakase::Memory` is the interface to
290
+ reimplement against it. And for a few dozen facts, `@notes.grep(/shipping/)` beats every word of
291
+ this.
292
+
134
293
  ### Testing
135
294
 
136
295
  `Omakase::Agent.new(chat:)` takes any object that quacks like a `RubyLLM::Chat`, and one ships with
@@ -278,6 +437,9 @@ generates :plan, strategy: CriticStrategy
278
437
  lib/omakase/doc.rb what an unfamiliar object offers, for generated code
279
438
  lib/omakase/executor.rb runs generated Ruby against the agent
280
439
  lib/omakase/tools/ruby.rb that executor, as a RubyLLM tool, with a call budget
440
+ lib/omakase/mcp.rb an MCP server’s tools, as methods on the agent
441
+ lib/omakase/skills.rb a SKILL.md directory, as one described method
442
+ lib/omakase/memory.rb remember and recall, by meaning
281
443
  lib/omakase/fake_chat.rb the stand-in chat for tests
282
444
  lib/omakase/strategies/ code_act, predict
283
445
 
@@ -289,10 +451,15 @@ Copy `.env.example` to `.env` and fill in a key; `MODEL` and `PROVIDER` there pi
289
451
  | --- | --- |
290
452
  | [`feedback_agent.rb`](examples/feedback_agent.rb) | structured output in one call |
291
453
  | [`inventory_agent.rb`](examples/inventory_agent.rb) | the agent's methods as the model's tools |
454
+ | [`refund_agent.rb`](examples/refund_agent.rb) | ActiveRecord objects in, a Ruby object out |
292
455
  | [`warehouse_agent.rb`](examples/warehouse_agent.rb) | inspecting objects whose types are unknown |
293
456
  | [`support_agent.rb`](examples/support_agent.rb) | plain Ruby orchestrating generated methods |
294
457
  | [`support_job.rb`](examples/support_job.rb) | generation off the request thread, via ActiveJob |
295
458
  | [`rails_app.rb`](examples/rails_app.rb) | a whole Rails app in one file: initializer, agent, controller |
459
+ | [`mcp_agent.rb`](examples/mcp_agent.rb) | an MCP server's tools as methods on the agent |
460
+ | [`skill_agent.rb`](examples/skill_agent.rb) | a SKILL.md directory the model loads when it needs it |
461
+ | [`interview_agent.rb`](examples/interview_agent.rb) | remembering across calls, without a shared chat |
462
+ | [`memory_agent.rb`](examples/memory_agent.rb) | recall by meaning, kept across a marshalled run |
296
463
 
297
464
  ```bash
298
465
  bundle exec rake # tests, no network
@@ -307,6 +474,8 @@ too. Two rules follow:
307
474
 
308
475
  - **Untrusted input (anything a user typed) belongs to `:predict`.** No code runs there.
309
476
  - **`:code_act` is for work you control** — internal tooling, workers, isolated environments.
477
+ - **A marshalled agent is your data, never user input.** `Marshal.load` on bytes someone else can
478
+ write is remote code execution, resumed run or not.
310
479
 
311
480
  What is bounded: ten tool calls per generation, a 30-second timeout per execution, and 4KB of
312
481
  observation. What is not: what the code can reach. For real isolation, swap the executor —
@@ -316,21 +485,16 @@ anything answering `call(agent, code, timeout:)` will do:
316
485
  Omakase.executor = MySubprocessExecutor # returns an observation String or Executor::Answer
317
486
  ```
318
487
 
319
- ## Roadmap
320
-
321
- What is not here yet, roughly in the order it would earn its place:
322
-
323
- - [x] **Live objects** the answer is computed in code and handed back as the object, not retyped
324
- as JSON. Done: `finish(value)` plus `returns: SomeClass`.
325
- - [x] **Tracing** RubyLLM emits `chat.ruby_llm` and `tool_call.ruby_llm`; point `config.instrumenter`
326
- at `ActiveSupport::Notifications` and subscribe. Done, by not writing it.
327
- - [ ] **Conversation history** — the chat is fresh per call. Keeping one per agent would let a
328
- method continue where the last one left off, at the cost of deciding what to keep.
329
- - [ ] **Session storage** — persist that history and the agent state so a run can be resumed.
330
- - [ ] **MCP tools** external tools over the Model Context Protocol, via `ruby_llm-mcp`. Cheap to
331
- add, since generated code can call anything the agent exposes.
332
- - [ ] **Skills** — capabilities as markdown files with front matter, loaded on demand rather than
333
- all sitting in the system prompt.
334
- - [ ] **Memory** — recall that survives across sessions, backed by vector search.
335
- - [x] **Concurrency** — parallel generation calls. Done: output is buffered per thread instead of
336
- through `$stdout`, so threads no longer collide.
488
+ ## Not here, on purpose
489
+
490
+ - **A checkpoint inside a generation.** A crashed run is retried whole. The tool loop belongs to
491
+ RubyLLM, and making it resumable would be a different library.
492
+ - **Reflection and forgetting in memory.** No decay, no consolidation pass: `Memory` grows until you
493
+ prune it, and past a few hundred entries the answer is pgvector, not more code here.
494
+ - **A sandbox.** `instance_eval` runs in your process. Real isolation is a swapped executor, above.
495
+ - **Multi-agent orchestration.** An agent is an object, so one agent calling another is a method
496
+ call. There is nothing to add.
497
+ - **Streaming.** A generation method returns a value, not tokens. RubyLLM streams if you need that.
498
+
499
+ `1.0` lands when the API stops moving. Until then a minor version may move it, and `0.1.0` means
500
+ the library is usable, not that it is finished.
data/lib/omakase/agent.rb CHANGED
@@ -27,6 +27,25 @@ module Omakase
27
27
  @strategy = name
28
28
  end
29
29
 
30
+ # An MCP server's tools, as methods on the agent. Options are passed to
31
+ # `ruby_llm-mcp` verbatim: `mcp :files, transport_type: :stdio, config: {command: "npx", …}`.
32
+ def mcp(name, **options)
33
+ require "ruby_llm/mcp"
34
+ MCP.attach(self, RubyLLM::MCP.add_client(name: name.to_s, **options))
35
+ end
36
+
37
+ # A skill directory — a SKILL.md with YAML front matter. Its description
38
+ # joins the agent's capabilities; its body arrives when the model asks.
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
30
49
  # Documents the method defined next — the docstring Ruby does not have.
31
50
  def describe(text)
32
51
  @pending_description = text
@@ -78,9 +97,20 @@ module Omakase
78
97
  @chat = chat
79
98
  end
80
99
 
81
- # A fresh conversation per call.
100
+ # A fresh conversation per call — two threads calling one agent must not
101
+ # share a mutable chat. What carries between calls is the object's own state.
82
102
  def chat = @chat || RubyLLM.chat(**self.class.chat_options)
83
103
 
104
+ # That state, as the model should read it: rebuilt on every call, and added
105
+ # to the class's instructions. Override it to remember anything.
106
+ def context = nil
107
+
108
+ # Resuming a run is loading the object back, so an agent marshals like any
109
+ # other Ruby object — minus the live chat, which is rebuilt on demand.
110
+ def marshal_dump = (instance_variables - [:@chat]).to_h { |name| [name, instance_variable_get(name)] }
111
+
112
+ def marshal_load(state) = state.each { |name, value| instance_variable_set(name, value) }
113
+
84
114
  # For generated code meeting an object whose type it does not know.
85
115
  def doc(object) = puts(Doc.of(object))
86
116
 
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
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # An MCP server's tools, as methods on the agent — so generated code calls a
5
+ # remote tool the same way it calls anything else the agent exposes.
6
+ module MCP
7
+ module_function
8
+
9
+ def attach(agent_class, client)
10
+ client.tools.each do |tool|
11
+ name = method_name(tool)
12
+ # A remote tool list must not quietly shadow a capability the agent already has.
13
+ raise Error, "#{agent_class} already has ##{name}" if Capabilities.names(agent_class).include?(name)
14
+
15
+ agent_class.describe(description(tool))
16
+ # nil is how a model leaves an argument out; MCP servers reject it.
17
+ agent_class.define_method(name) { |**arguments| MCP.result(tool.execute(**arguments.compact)) }
18
+ end
19
+ client
20
+ end
21
+
22
+ # Tool names may hold characters a Ruby method name cannot.
23
+ def method_name(tool) = tool.name.tr("-", "_").to_sym
24
+
25
+ # ponytail: text only — an image or audio result is dropped.
26
+ def result(value)
27
+ raise Error, value[:error] if value.is_a?(Hash) && value[:error]
28
+
29
+ value.to_s
30
+ end
31
+
32
+ # The signature is `**arguments`, so what those arguments are goes here.
33
+ def description(tool)
34
+ schema = tool.params_schema || {}
35
+ required = schema["required"] || []
36
+ arguments = (schema["properties"] || {}).map do |name, property|
37
+ "#{name}: #{property["type"]}#{" (required)" if required.include?(name)}"
38
+ end
39
+ text = tool.description.to_s.gsub(/\s+/, " ").strip
40
+ [text, ("Arguments — #{arguments.join(", ")}" if arguments.any?)].compact.join(" ")
41
+ end
42
+ end
43
+ 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
@@ -7,7 +7,7 @@ module Omakase
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
11
 
12
12
  def task
13
13
  return generation.prompt if inputs.empty?
@@ -29,6 +29,8 @@ module Omakase
29
29
 
30
30
  def json = @json ||= definition.new.to_json_schema
31
31
 
32
+ def code_only? = false
33
+
32
34
  # The shape, in the shorthand the model writes back: `{city: <string>}`.
33
35
  def describe
34
36
  return "<#{properties.fetch("result")["type"]}>" if wrapped?
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # A skill is a directory with a SKILL.md: YAML front matter says what it is,
5
+ # the body is the guidance. The description is listed with the agent's other
6
+ # capabilities; the body only arrives when the model calls the method — which
7
+ # is all "loaded on demand" has to mean.
8
+ module Skills
9
+ module_function
10
+
11
+ def attach(agent_class, path)
12
+ directory = File.expand_path(path)
13
+ front_matter, body = parse(File.read(File.join(directory, "SKILL.md")))
14
+ name = (front_matter["name"] || File.basename(directory)).tr("-", "_").to_sym
15
+ raise Error, "#{agent_class} already has ##{name}" if Capabilities.names(agent_class).include?(name)
16
+
17
+ agent_class.describe(front_matter["description"].to_s)
18
+ agent_class.define_method(name) { "#{body}\n\nFiles for this skill are in #{directory}." }
19
+ name
20
+ end
21
+
22
+ # The front matter every SKILL.md in the wild is written with.
23
+ def parse(text)
24
+ match = text.match(/\A---\n(.*?)\n---\n(.*)\z/m)
25
+ return [{}, text.strip] unless match
26
+
27
+ [YAML.safe_load(match[1]), match[2].strip]
28
+ end
29
+ end
30
+ end
@@ -17,8 +17,11 @@ module Omakase
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)
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.1.alpha"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/omakase.rb CHANGED
@@ -8,9 +8,11 @@ require "schematist"
8
8
  require "stringio"
9
9
  require "timeout"
10
10
  require "zeitwerk"
11
+ require "yaml"
11
12
 
12
13
  loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
13
14
  loader.ignore("#{__dir__}/omakase-agents.rb")
15
+ loader.inflector.inflect("mcp" => "MCP")
14
16
  loader.setup
15
17
 
16
18
  module Omakase
@@ -47,5 +49,11 @@ module Omakase
47
49
  attr_writer :executor
48
50
 
49
51
  def executor = @executor ||= Executor
52
+
53
+ # How text becomes a vector, for Memory. Anything answering `call(text)`
54
+ # will do; the model and its provider are RubyLLM's to configure.
55
+ attr_writer :embedder
56
+
57
+ def embedder = @embedder ||= ->(text) { RubyLLM.embed(text).vectors }
50
58
  end
51
59
  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.0.1.alpha
4
+ version: 0.1.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-12 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -73,8 +73,11 @@ files:
73
73
  - lib/omakase/executor.rb
74
74
  - lib/omakase/fake_chat.rb
75
75
  - lib/omakase/generation.rb
76
+ - lib/omakase/mcp.rb
77
+ - lib/omakase/memory.rb
76
78
  - lib/omakase/request.rb
77
79
  - lib/omakase/schema.rb
80
+ - lib/omakase/skills.rb
78
81
  - lib/omakase/strategies.rb
79
82
  - lib/omakase/strategies/code_act.rb
80
83
  - lib/omakase/strategies/predict.rb
@@ -100,9 +103,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
100
103
  version: '3.2'
101
104
  required_rubygems_version: !ruby/object:Gem::Requirement
102
105
  requirements:
103
- - - ">"
106
+ - - ">="
104
107
  - !ruby/object:Gem::Version
105
- version: 1.3.1
108
+ version: '0'
106
109
  requirements: []
107
110
  rubygems_version: 3.4.10
108
111
  signing_key: