omakase-agents 0.0.1.alpha

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c1cd37ac842736548d6501feab0ad139df3bad22437902f19ead73ed5eae819c
4
+ data.tar.gz: 733def6d93d950e7bd1507240877d90447dd7359255ba4e3c5bc3d93ce3794c6
5
+ SHA512:
6
+ metadata.gz: 78f54588fa331091f7e604bab6637a31a9f7f14298b0f513df78e243f851079f9181c2c71c627f0a33d5069ec541711adee81e17516916077990b5e1495a4533
7
+ data.tar.gz: 4c97ef7304cce538f6dd4b9d95d6c7053bc6fff9ee6cab88a02b9d44ccdc38e78edfcf95d669a3a9a20fd9f94557d28e05b714eeac3405957d47e1e615f89592
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eugeny
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,336 @@
1
+ # Omakase
2
+
3
+ A light agent framework — about 600 lines of library. *Omakase* (お任せ): you name what you want,
4
+ the rest is left to the chef.
5
+
6
+ The whole philosophy: **an agent is an object**. Its fields are state, its methods are what the
7
+ model can call, and the methods it *declares without a body* are written by the model at runtime —
8
+ the method name and prompt are the specification, the schema is the contract.
9
+
10
+ ```ruby
11
+ class InventoryAgent < ApplicationAgent
12
+ instructions "You check inventory."
13
+
14
+ describe "Units of an item on hand"
15
+ def stock_of(item) = STOCK.dig(item, :stock) || 0
16
+
17
+ describe "Unit price of an item"
18
+ def price_of(item) = STOCK.dig(item, :price) || 0.0
19
+
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
25
+ end
26
+
27
+ InventoryAgent.can_fulfill_order(items: %w[apple banana orange], budget: 5.0)
28
+ # => {can_fulfill: false, total_cost: 2.05, unavailable: ["orange"]}
29
+ ```
30
+
31
+ There is no tool abstraction to keep in sync: the model writes Ruby that runs on the agent object,
32
+ reads what it printed and returned, and answers in the declared schema. Adding a tool is adding a
33
+ method; deleting one is deleting a method.
34
+
35
+ ## Installation
36
+
37
+ Ruby 3.2+.
38
+
39
+ ```ruby
40
+ gem "omakase-agents" # the library is `Omakase`
41
+ ```
42
+
43
+ Alpha (`0.0.1.alpha`) and not on RubyGems yet, so until it is, build it from a checkout:
44
+
45
+ ```bash
46
+ git clone https://github.com/esshka/omakase
47
+ cd omakase
48
+ gem build omakase-agents.gemspec
49
+ gem install ./omakase-agents-*.gem
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Declaring an agent
55
+
56
+ ```ruby
57
+ class ApplicationAgent < Omakase::Agent # every agent inherits the configuration
58
+ model "claude-sonnet-4-5"
59
+ end
60
+
61
+ class FeedbackAgent < ApplicationAgent
62
+ instructions "You analyze customer feedback." # the system prompt
63
+ strategy :predict # :code_act (default) or :predict
64
+
65
+ generates :summarize # no prompt given: the method name is the prompt
66
+ generates :score, returns: :integer
67
+ generates :analyze, "Analyze the feedback." do
68
+ string :sentiment, enum: %w[positive negative neutral mixed]
69
+ array :topics, of: :string
70
+ end
71
+ end
72
+ ```
73
+
74
+ Generation methods take keyword arguments, which are rendered into the prompt, and are callable on
75
+ the instance or on the class:
76
+
77
+ ```ruby
78
+ FeedbackAgent.analyze(text: "Great product, but shipping was slow")
79
+ # => {sentiment: "mixed", topics: ["product quality", "shipping speed"]}
80
+
81
+ FeedbackAgent.new.analyze(text: "…")
82
+ ```
83
+
84
+ `describe` above an ordinary method is the docstring Ruby does not have — it is what the model reads
85
+ when it decides what to call.
86
+
87
+ ### Return types
88
+
89
+ The block is a [schematist](https://github.com/crmne/schematist) schema and becomes the provider's
90
+ structured-output contract, so a generation method returns validated data, never text to parse.
91
+ For a single value, name the type instead:
92
+
93
+ ```ruby
94
+ generates :count_items, returns: :integer # :string (default), :integer, :number, :boolean
95
+ ```
96
+
97
+ Both forms are the same mechanism: a schema whose only property is `result` unwraps to that value.
98
+ A Ruby class works too — `returns: Ticket` — and then the method hands back the object rather than
99
+ data; see [`:code_act`](#strategies) for what that requires.
100
+
101
+ ### Models and providers
102
+
103
+ Any provider RubyLLM supports — Anthropic, OpenAI, Gemini, Bedrock, Azure, Mistral, DeepSeek, xAI,
104
+ Perplexity, OpenRouter, Ollama, GPUStack, VertexAI. Give the model id, and the provider when the id
105
+ is not one RubyLLM has in its registry:
106
+
107
+ ```ruby
108
+ class ApplicationAgent < Omakase::Agent
109
+ model "claude-sonnet-4-5" # resolved from the registry
110
+ model "meta/muse-glimmer-30b", provider: :openrouter # taken on trust
111
+ model "qwen3:1.7b", provider: :ollama # local
112
+ end
113
+ ```
114
+
115
+ Naming a provider implies `assume_model_exists: true`; any other RubyLLM chat option passes through.
116
+ Subclasses inherit the setting and can override it, so one `ApplicationAgent` configures the lot.
117
+
118
+ Credentials come from the environment — one call covers every provider:
119
+
120
+ ```ruby
121
+ Omakase.configure_from_env # ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OLLAMA_API_BASE, …
122
+ ```
123
+
124
+ Or set them yourself; `configure` is RubyLLM's, with all of its options:
125
+
126
+ ```ruby
127
+ Omakase.configure do |config|
128
+ config.anthropic_api_key = Rails.application.credentials.anthropic_api_key
129
+ config.default_model = "claude-sonnet-4-5" # used by agents that declare no model
130
+ config.request_timeout = 120
131
+ end
132
+ ```
133
+
134
+ ### Testing
135
+
136
+ `Omakase::Agent.new(chat:)` takes any object that quacks like a `RubyLLM::Chat`, and one ships with
137
+ the library, so agents are tested without a network:
138
+
139
+ ```ruby
140
+ chat = Omakase::FakeChat.new { {"severity" => "high", "summary" => "…"} }
141
+ assert_equal "high", SupportAgent.new(chat:).triage(message: "broken")[:severity]
142
+
143
+ # drive the tool the way a model would
144
+ chat = Omakase::FakeChat.new { |fake| fake.run("finish(stock_of(:apple))") }
145
+ ```
146
+
147
+ It records `instructions`, `schema`, `tools` and `tasks`, so the prompt is assertable too.
148
+
149
+ ## Rails
150
+
151
+ Agents live in `app/agents` — Rails autoloads it, and reloading is safe because everything a
152
+ generation method needs is rebuilt when the class is. [`examples/rails_app.rb`](examples/rails_app.rb)
153
+ is all of this as one runnable file: initializer, agent, controller, one request.
154
+
155
+ ```ruby
156
+ # config/initializers/omakase.rb
157
+ Omakase.configure do |config|
158
+ config.anthropic_api_key = Rails.application.credentials.anthropic_api_key
159
+ config.default_model = "claude-sonnet-4-5"
160
+ config.request_timeout = 60 # RubyLLM's default is 300s — too long for a web request
161
+ config.max_retries = 3 # transient provider failures, with backoff
162
+ config.logger = Rails.logger
163
+ config.instrumenter = ActiveSupport::Notifications
164
+ end
165
+ ```
166
+
167
+ That last line puts every call on the notification bus, so tokens, cost and latency land in your
168
+ logs and APM without any code of ours:
169
+
170
+ ```ruby
171
+ ActiveSupport::Notifications.subscribe("chat.ruby_llm") do |*, payload|
172
+ Rails.logger.info(model: payload[:model], input: payload[:input_tokens], output: payload[:output_tokens])
173
+ end
174
+ ```
175
+
176
+ A generation call takes seconds, so keep it off the request thread:
177
+
178
+ ```ruby
179
+ class TriageJob < ApplicationJob
180
+ def perform(ticket) = ticket.update!(SupportAgent.triage(message: ticket.body))
181
+ end
182
+ ```
183
+
184
+ Jobs move data, not objects: arguments and results have to serialize, so a `returns: SomeClass`
185
+ answer — a live Ruby object — does not survive the trip. [`examples/support_job.rb`](examples/support_job.rb)
186
+ is the runnable version, three tickets triaged concurrently by the async adapter.
187
+
188
+ **Threads.** Puma is multi-threaded and so is this: printing from generated code goes to a
189
+ per-thread buffer, and each call gets its own chat and its own agent instance. Concurrent calls are
190
+ just threads:
191
+
192
+ ```ruby
193
+ ids.map { |id| Thread.new { WarehouseAgent.appraise(item_id: id) } }.map(&:value)
194
+ ```
195
+
196
+ Sharing one agent instance across threads is your business as usual — its state is yours. Do not
197
+ turn on RubyLLM's `tool_concurrency`: that runs generated code against the same agent in parallel.
198
+
199
+ **Errors.** Everything raised at the boundary is an `Omakase::Error`:
200
+
201
+ | | |
202
+ | --- | --- |
203
+ | `Omakase::ContractError` | the answer did not match the declared return type, twice |
204
+ | `Omakase::ProviderError` | the provider failed — rate limit, overload, bad key — after RubyLLM's retries |
205
+
206
+ Inside a `:code_act` loop neither reaches you: a contract miss and a raised exception both come back
207
+ to the model as an observation, and it tries again within its call budget.
208
+
209
+ ## How it works
210
+
211
+ Calling a generation method does four things:
212
+
213
+ 1. **Builds a request.** `Request` carries the agent, the generation (prompt, schema, strategy), the
214
+ keyword arguments, and a fresh chat — one conversation per call, no accumulated history.
215
+ 2. **Renders the prompt.** The agent's `instructions` are the system prompt; the method's prompt and
216
+ its arguments are the user message.
217
+ 3. **Runs the strategy** (below), which is where the LLM work happens.
218
+ 4. **Holds it to the contract.** Whether the answer arrived as JSON or as a Ruby value from
219
+ `finish`, it is symbolized, unwrapped if it is a lone `result`, and refused if it is off-contract.
220
+
221
+ ### Strategies
222
+
223
+ A strategy is *how* a generation method gets its answer — one call, a code loop, your own
224
+ retry-and-critique scheme. It is an execution detail, not part of the method's contract: swapping
225
+ one for another changes cost and capability, and no caller changes.
226
+
227
+ Formally, a strategy is anything that responds to `call(request)` and returns the cast value. Two
228
+ ship with the library:
229
+
230
+ **`:predict`** — one call. The chat is configured with the instructions and the schema, the task
231
+ goes in, structured output comes back; an answer that misses the schema gets one correction
232
+ turn naming what was wrong. No code runs. Right for classification, extraction, rewriting — anything the model can answer from the prompt alone.
233
+
234
+ **`:code_act`** *(default)* — the model acts by writing Ruby. It gets one tool, `ruby`, whose code
235
+ is `instance_eval`d on the agent, so the agent’s methods and state are the API; anything printed and
236
+ the value of the last expression come back as the observation, and the loop repeats until the model
237
+ calls `finish(value)`. `Capabilities` lists the agent’s own methods (with their `describe` text) in
238
+ the system prompt, minus the method being written, so it cannot recurse into itself. A failure comes
239
+ back with the line that raised, `doc(object)` prints what an object of an unfamiliar type offers, and
240
+ an answer that misses the contract is rejected into the same loop — the model corrects itself without
241
+ another request. Nothing in the provider bounds a tool loop, so the tool does: ten calls, then a turn
242
+ to answer with what it has.
243
+
244
+ Because the answer is computed rather than retyped, the return type can be a Ruby class and the
245
+ method hands back the object itself:
246
+
247
+ ```ruby
248
+ generates :file_ticket, "File a ticket for the message.", returns: Ticket
249
+ # => #<struct Ticket id="A-1", severity="high">
250
+ ```
251
+
252
+ That needs `:code_act` — `:predict` has no code in which to build one. If the model never calls
253
+ `finish`, the strategy falls back to a tool-free turn under the JSON schema.
254
+
255
+ Set the default per agent with `strategy :predict`, per method with
256
+ `generates :triage, strategy: :predict`, or pass your own object:
257
+
258
+ ```ruby
259
+ module CriticStrategy
260
+ def self.call(request)
261
+ draft = Omakase::Strategies::CodeAct.call(request)
262
+ ...
263
+ end
264
+ end
265
+
266
+ generates :plan, strategy: CriticStrategy
267
+ ```
268
+
269
+ ### Layout
270
+
271
+ lib/omakase.rb configuration
272
+ lib/omakase/agent.rb the DSL: model, instructions, describe, generates
273
+ lib/omakase/generation.rb a declared method: prompt, schema, strategy
274
+ lib/omakase/request.rb one invocation of one
275
+ lib/omakase/schema.rb return types the provider enforces
276
+ lib/omakase/type.rb return types that are a Ruby class
277
+ lib/omakase/capabilities.rb the agent’s own methods, listed for the model
278
+ lib/omakase/doc.rb what an unfamiliar object offers, for generated code
279
+ lib/omakase/executor.rb runs generated Ruby against the agent
280
+ lib/omakase/tools/ruby.rb that executor, as a RubyLLM tool, with a call budget
281
+ lib/omakase/fake_chat.rb the stand-in chat for tests
282
+ lib/omakase/strategies/ code_act, predict
283
+
284
+ ## Examples
285
+
286
+ Copy `.env.example` to `.env` and fill in a key; `MODEL` and `PROVIDER` there pick the backend.
287
+
288
+ | File | Shows |
289
+ | --- | --- |
290
+ | [`feedback_agent.rb`](examples/feedback_agent.rb) | structured output in one call |
291
+ | [`inventory_agent.rb`](examples/inventory_agent.rb) | the agent's methods as the model's tools |
292
+ | [`warehouse_agent.rb`](examples/warehouse_agent.rb) | inspecting objects whose types are unknown |
293
+ | [`support_agent.rb`](examples/support_agent.rb) | plain Ruby orchestrating generated methods |
294
+ | [`support_job.rb`](examples/support_job.rb) | generation off the request thread, via ActiveJob |
295
+ | [`rails_app.rb`](examples/rails_app.rb) | a whole Rails app in one file: initializer, agent, controller |
296
+
297
+ ```bash
298
+ bundle exec rake # tests, no network
299
+ ruby examples/inventory_agent.rb
300
+ ```
301
+
302
+ ## Safety
303
+
304
+ Generated code runs with `instance_eval` in your process. In a Rails app that means it can reach
305
+ `ActiveRecord`, `ENV`, and the filesystem — a container does not help, because your app is inside it
306
+ too. Two rules follow:
307
+
308
+ - **Untrusted input (anything a user typed) belongs to `:predict`.** No code runs there.
309
+ - **`:code_act` is for work you control** — internal tooling, workers, isolated environments.
310
+
311
+ What is bounded: ten tool calls per generation, a 30-second timeout per execution, and 4KB of
312
+ observation. What is not: what the code can reach. For real isolation, swap the executor —
313
+ anything answering `call(agent, code, timeout:)` will do:
314
+
315
+ ```ruby
316
+ Omakase.executor = MySubprocessExecutor # returns an observation String or Executor::Answer
317
+ ```
318
+
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.
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # Fields are state, methods are what the model can call, `generates` declares
5
+ # the methods the model implements.
6
+ class Agent
7
+ class << self
8
+ # The model and any RubyLLM chat option. Naming a provider takes the model
9
+ # id on trust, since providers like OpenRouter or Ollama serve ids that are
10
+ # not in RubyLLM's registry.
11
+ def model(id = nil, **options)
12
+ return chat_options if id.nil? && options.empty?
13
+
14
+ options = {assume_model_exists: true, **options} if options[:provider]
15
+ @chat_options = {model: id, **options}.compact
16
+ end
17
+
18
+ def instructions(text = nil)
19
+ return @instructions.to_s if text.nil?
20
+
21
+ @instructions = text
22
+ end
23
+
24
+ def strategy(name = nil)
25
+ return @strategy || :code_act if name.nil?
26
+
27
+ @strategy = name
28
+ end
29
+
30
+ # Documents the method defined next — the docstring Ruby does not have.
31
+ def describe(text)
32
+ @pending_description = text
33
+ end
34
+
35
+ # Without a prompt, the method name is the prompt.
36
+ def generates(name, prompt = nil, returns: nil, strategy: nil, &schema)
37
+ generations[name] = Generation.new(
38
+ name:,
39
+ prompt: prompt || humanize(name),
40
+ schema: Schema.define(returns:, &schema),
41
+ strategy: Strategies.fetch(strategy || self.strategy)
42
+ )
43
+ define_method(name) { |**inputs| generate(name, inputs) }
44
+ define_singleton_method(name) { |**inputs| new.public_send(name, **inputs) }
45
+ end
46
+
47
+ def generations = @generations ||= {}
48
+
49
+ def descriptions = @descriptions ||= {}
50
+
51
+ def chat_options = @chat_options ||= {}
52
+
53
+ private
54
+
55
+ def humanize(name)
56
+ text = name.to_s.tr("_", " ").capitalize
57
+ text.end_with?("?", "!") ? text : "#{text}."
58
+ end
59
+
60
+ def method_added(name)
61
+ super
62
+ descriptions[name] = @pending_description if @pending_description
63
+ @pending_description = nil
64
+ end
65
+
66
+ def inherited(subclass)
67
+ super
68
+ subclass.instance_variable_set(:@chat_options, chat_options.dup)
69
+ subclass.instructions(instructions) unless instructions.empty?
70
+ subclass.strategy(@strategy) if @strategy
71
+ subclass.generations.merge!(generations)
72
+ subclass.descriptions.merge!(descriptions)
73
+ end
74
+ end
75
+
76
+ # `chat:` injects a prepared RubyLLM::Chat — the seam for tests.
77
+ def initialize(chat: nil)
78
+ @chat = chat
79
+ end
80
+
81
+ # A fresh conversation per call.
82
+ def chat = @chat || RubyLLM.chat(**self.class.chat_options)
83
+
84
+ # For generated code meeting an object whose type it does not know.
85
+ def doc(object) = puts(Doc.of(object))
86
+
87
+ # How generated code answers: with the value itself.
88
+ def finish(value) = throw(Executor::RESULT, value)
89
+
90
+ # Printing from generated code goes to the observation, not to the process's
91
+ # stdout — and the buffer is per thread, so concurrent agents stay separate.
92
+ def puts(*args) = omakase_output.puts(*args)
93
+
94
+ def print(*args) = omakase_output.print(*args)
95
+
96
+ def p(*args)
97
+ args.each { |arg| omakase_output.puts(arg.inspect) }
98
+ args.size <= 1 ? args.first : args
99
+ end
100
+
101
+ alias_method :pp, :p
102
+
103
+ private
104
+
105
+ def omakase_output = Thread.current[Executor::OUTPUT] || $stdout
106
+
107
+ def generate(name, inputs)
108
+ generation = self.class.generations.fetch(name)
109
+ generation.strategy.call(Request.new(agent: self, generation:, inputs:))
110
+ rescue RubyLLM::Error, RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError => e
111
+ raise ProviderError, "#{self.class}##{name}: #{e.message}"
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # The agent's own methods, written out for the model. Adding a tool is adding
5
+ # a method.
6
+ module Capabilities
7
+ module_function
8
+
9
+ def of(agent_class, except: nil)
10
+ (names(agent_class) - [except]).sort.map { |name| entry(agent_class, name) }
11
+ end
12
+
13
+ def names(agent_class)
14
+ agent_class.ancestors
15
+ .take_while { |mod| mod != Agent }
16
+ .flat_map { |mod| mod.public_instance_methods(false) }
17
+ .uniq
18
+ end
19
+
20
+ def entry(agent_class, name)
21
+ signature = "#{name}(#{parameters(agent_class.instance_method(name))})"
22
+ description = agent_class.descriptions[name] || agent_class.generations[name]&.prompt
23
+ description ? "#{signature} — #{description}" : signature
24
+ end
25
+
26
+ def parameters(method)
27
+ method.parameters.map do |kind, name|
28
+ case kind
29
+ when :key, :keyreq then "#{name}:"
30
+ when :keyrest then "**#{name}"
31
+ when :rest then "*#{name}"
32
+ when :opt then "#{name} = ..."
33
+ else name.to_s
34
+ end
35
+ end.join(", ")
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # What an object offers, for a model that has never seen its type: what the
5
+ # object's own classes define, not what Ruby gives everything.
6
+ module Doc
7
+ CORE = [Object, Kernel, BasicObject, Struct, Data, Enumerable, Comparable].freeze
8
+
9
+ module_function
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")
14
+ end
15
+
16
+ def signatures(object)
17
+ object.class.ancestors
18
+ .take_while { |mod| !CORE.include?(mod) }
19
+ .flat_map { |mod| mod.public_instance_methods(false) }
20
+ .uniq.sort
21
+ .map { |name| " #{name}(#{Capabilities.parameters(object.method(name))})" }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # Runs model-written Ruby in the agent's own context.
5
+ # ponytail: instance_eval is not a sandbox — see Omakase.executor to swap it.
6
+ module Executor
7
+ SOURCE = "(generated)"
8
+ RESULT = :omakase_result
9
+ OUTPUT = :omakase_output
10
+ TIMEOUT = 30
11
+ MAX_OUTPUT = 4_000
12
+
13
+ # What `finish(value)` handed back: the answer as a Ruby value, not as text.
14
+ Answer = Data.define(:value)
15
+
16
+ module_function
17
+
18
+ def call(agent, code, timeout: TIMEOUT)
19
+ printed = StringIO.new
20
+ answer = catch(RESULT) do
21
+ value = capturing(printed) { Timeout.timeout(timeout) { agent.instance_eval(code, SOURCE, 1) } }
22
+ return observation([printed.string.chomp, "=> #{value.inspect}"])
23
+ end
24
+ Answer.new(value: answer)
25
+ rescue ScriptError, StandardError => e
26
+ observation([printed.string.chomp, failure(e, code)])
27
+ end
28
+
29
+ # The model can only fix what it can locate, so point at the line.
30
+ def failure(error, code)
31
+ line = error.backtrace&.grep(/\A#{Regexp.escape(SOURCE)}:\d+/)&.first&.slice(/:(\d+)/, 1)&.to_i
32
+ source = code.lines[line - 1]&.strip if line&.positive?
33
+ ["#{error.class}: #{error.message}", ("line #{line}: #{source}" if source)].compact.join("\n")
34
+ end
35
+
36
+ def observation(parts)
37
+ text = parts.reject(&:empty?).join("\n")
38
+ text = "#{text[0, MAX_OUTPUT]}\n… (truncated)" if text.length > MAX_OUTPUT
39
+ text.empty? ? "(no output)" : text
40
+ end
41
+
42
+ # Thread-local, so concurrent agents never share a buffer. Agent#puts reads it.
43
+ def capturing(io)
44
+ previous = Thread.current[OUTPUT]
45
+ Thread.current[OUTPUT] = io
46
+ yield
47
+ ensure
48
+ Thread.current[OUTPUT] = previous
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # Stands in for a RubyLLM::Chat so agents can be tested without a network:
5
+ # records how the chat was configured, then runs the script you gave it.
6
+ #
7
+ # agent = SupportAgent.new(chat: Omakase::FakeChat.new { {"severity" => "high"} })
8
+ #
9
+ # The script receives the chat, so it can drive the tool the way a model would:
10
+ #
11
+ # Omakase::FakeChat.new { |chat| chat.run("finish(42)") }
12
+ class FakeChat
13
+ Response = Struct.new(:content)
14
+
15
+ attr_reader :instructions, :schema, :tools, :tasks
16
+
17
+ def initialize(&script)
18
+ @script = script
19
+ @instructions = []
20
+ @tools = []
21
+ @tasks = []
22
+ end
23
+
24
+ def with_instructions(text) = tap { @instructions << text }
25
+
26
+ def with_schema(schema) = tap { @schema = schema }
27
+
28
+ def with_tool(tool, **) = tap { @tools << tool }
29
+
30
+ def ask(task)
31
+ @tasks << task
32
+ Response.new(@script.call(self))
33
+ end
34
+
35
+ # Run code the way the model would, through the agent's one tool.
36
+ def run(code) = tools.fetch(0).call(code:)
37
+ end
38
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
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)
7
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # One invocation of a generation method: all a strategy may depend on.
5
+ Request = Data.define(:agent, :generation, :inputs) do
6
+ def chat = agent.chat
7
+
8
+ def schema = generation.schema
9
+
10
+ def instructions = agent.class.instructions
11
+
12
+ def task
13
+ return generation.prompt if inputs.empty?
14
+
15
+ arguments = inputs.map { |name, value| "- #{name}: #{value.inspect}" }
16
+ "#{generation.prompt}\n\nInputs:\n#{arguments.join("\n")}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # The declared return type, enforced by the provider. A schema whose only
5
+ # property is `result` unwraps to that value.
6
+ class Schema
7
+ RESULT = :result
8
+ SCALARS = %i[string integer number boolean].freeze
9
+ RUBY_TYPES = {
10
+ "string" => String, "integer" => Integer, "number" => Numeric,
11
+ "array" => Array, "object" => Hash
12
+ }.freeze
13
+
14
+ def self.define(returns: nil, &block)
15
+ return new(Schematist::Schema.create(&block)) if block
16
+ return Type.new(returns) if returns.is_a?(Module)
17
+
18
+ type = returns || :string
19
+ raise Error, "returns: must be one of #{SCALARS.join(", ")}, a class, or a block" unless SCALARS.include?(type)
20
+
21
+ new(Schematist::Schema.create { public_send(type, RESULT) })
22
+ end
23
+
24
+ attr_reader :definition
25
+
26
+ def initialize(definition)
27
+ @definition = definition
28
+ end
29
+
30
+ def json = @json ||= definition.new.to_json_schema
31
+
32
+ # The shape, in the shorthand the model writes back: `{city: <string>}`.
33
+ def describe
34
+ return "<#{properties.fetch("result")["type"]}>" if wrapped?
35
+
36
+ "{#{properties.map { |name, spec| "#{name}: <#{spec["type"]}>" }.join(", ")}}"
37
+ end
38
+
39
+ # From the provider's JSON: unwrap first, then hold it to the contract.
40
+ def cast(content)
41
+ raise ContractError, "expected JSON matching #{JSON.generate(json)}, got #{content.inspect}" unless content.is_a?(Hash)
42
+
43
+ data = RubyLLM::Utils.deep_symbolize_keys(content)
44
+ take(wrapped? ? data.fetch(RESULT) { raise ContractError, %(missing "result" in #{data.inspect}) } : data)
45
+ end
46
+
47
+ # From a Ruby value the generated code computed.
48
+ def take(value)
49
+ return demand(value, properties.fetch("result")["type"]) if wrapped?
50
+ raise ContractError, "expected #{describe}, got #{value.inspect}" unless value.is_a?(Hash)
51
+
52
+ data = RubyLLM::Utils.deep_symbolize_keys(value)
53
+ missing = json.fetch("required").map(&:to_sym) - data.keys
54
+ raise ContractError, "missing #{missing.join(", ")} — expected #{describe}" if missing.any?
55
+
56
+ data
57
+ end
58
+
59
+ def wrapped? = definition.properties.keys == [RESULT]
60
+
61
+ private
62
+
63
+ def properties = json.fetch("properties")
64
+
65
+ def demand(value, type)
66
+ matched = type == "boolean" ? [true, false].include?(value) : value.is_a?(RUBY_TYPES.fetch(type))
67
+ raise ContractError, "expected <#{type}>, got #{value.inspect}" unless matched
68
+
69
+ value
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ module Strategies
5
+ # The model acts by writing Ruby against the agent object and answers with
6
+ # `finish(value)` — the answer is computed, not retyped.
7
+ module CodeAct
8
+ module_function
9
+
10
+ def call(request)
11
+ tool = Tools::Ruby.new(request.agent, request.schema)
12
+ notes = request.chat
13
+ .with_instructions(instructions(request))
14
+ .with_tool(tool)
15
+ .ask(request.task)
16
+ .content
17
+
18
+ return tool.answer.value if tool.answer
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}")
22
+ end
23
+
24
+ def instructions(request)
25
+ <<~TEXT
26
+ #{request.instructions}
27
+
28
+ You act by writing Ruby: call the `ruby` tool with code that is evaluated on the
29
+ agent object, so its methods and state are available on self.
30
+
31
+ #{capabilities(request).join("\n")}
32
+
33
+ `doc(object)` prints what an object of an unfamiliar type offers.
34
+
35
+ Return the answer from inside the code, never as a message — the last thing you run is:
36
+
37
+ finish(#{request.schema.describe})
38
+
39
+ Work in as few tool calls as you can.
40
+ TEXT
41
+ end
42
+
43
+ def capabilities(request)
44
+ Capabilities.of(request.agent.class, except: request.generation.name).map { |entry| "- #{entry}" }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ module Strategies
5
+ # One call, no code execution: the model answers straight into the schema.
6
+ module Predict
7
+ CORRECTION = "Answer again as JSON, and nothing else."
8
+
9
+ module_function
10
+
11
+ def call(request, task: request.task)
12
+ chat = request.chat
13
+ .with_instructions(instructions(request))
14
+ .with_schema(request.schema.definition)
15
+
16
+ request.schema.cast(chat.ask(task).content)
17
+ rescue ContractError => e
18
+ # One correction turn, told exactly what was wrong with the last answer.
19
+ request.schema.cast(chat.ask("#{e.message}\n\n#{CORRECTION}").content)
20
+ end
21
+
22
+ # Weaker providers treat the schema as a hint, so it goes in the prompt too.
23
+ def instructions(request)
24
+ "#{request.instructions}\n\nAnswer as JSON matching this schema:\n#{JSON.generate(request.schema.json)}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # How a generation method gets its answer. Named ones are looked up by symbol
5
+ # (`strategy :predict`); anything that responds to `call(request)` also works,
6
+ # which is the seam for your own.
7
+ module Strategies
8
+ module_function
9
+
10
+ def fetch(strategy)
11
+ return strategy if strategy.respond_to?(:call)
12
+
13
+ const_get(strategy.to_s.split("_").map(&:capitalize).join)
14
+ rescue NameError
15
+ raise Error, "unknown strategy #{strategy.inspect}"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ module Tools
5
+ # The model's one tool. Every capability of the agent reaches the model
6
+ # through it, so the model composes calls in code instead of one per turn.
7
+ class Ruby < RubyLLM::Tool
8
+ BUDGET = 10
9
+
10
+ description <<~TEXT
11
+ Evaluate Ruby in the context of the agent object: its methods and state are
12
+ directly available on self. Anything printed, plus the value of the last
13
+ expression, is returned to you. Call finish(value) to answer.
14
+ TEXT
15
+
16
+ param :code, desc: "Ruby source to evaluate."
17
+
18
+ attr_reader :answer
19
+
20
+ def initialize(agent, schema, budget: BUDGET, timeout: Executor::TIMEOUT, executor: Omakase.executor)
21
+ super()
22
+ @agent = agent
23
+ @schema = schema
24
+ @budget = budget
25
+ @timeout = timeout
26
+ @executor = executor
27
+ @calls = 0
28
+ end
29
+
30
+ def name = "ruby"
31
+
32
+ def execute(code:)
33
+ # Nothing bounds the provider's tool loop, so the budget does.
34
+ @calls += 1
35
+ return "No tool calls left — answer with what you have." if @calls == @budget + 1
36
+ return halt("Tool budget spent.") if @calls > @budget + 1
37
+
38
+ outcome = @executor.call(@agent, code, timeout: @timeout)
39
+ return outcome unless outcome.is_a?(Executor::Answer)
40
+
41
+ @answer = Executor::Answer.new(value: @schema.take(outcome.value))
42
+ halt("Answer accepted.")
43
+ rescue Error => e
44
+ # Off-contract answers are corrected inside the same loop, not by another request.
45
+ "finish rejected: #{e.message}"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # A return type that is a Ruby class: the model builds the object in code and
5
+ # hands the object itself back, so nothing goes through JSON.
6
+ class Type
7
+ def initialize(klass)
8
+ @klass = klass
9
+ end
10
+
11
+ def describe = "a #{@klass}"
12
+
13
+ def take(value)
14
+ return value if value.is_a?(@klass)
15
+
16
+ raise ContractError, "expected #{describe}, got #{value.class}"
17
+ end
18
+
19
+ def definition
20
+ raise Error, "returns: #{@klass} needs the :code_act strategy — the model has to build the object in code"
21
+ end
22
+
23
+ alias_method :json, :definition
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ VERSION = "0.0.1.alpha"
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is `omakase-agents`; the library is `Omakase`.
4
+ require_relative "omakase"
data/lib/omakase.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Object-oriented agents: an agent is a Ruby object. Its methods are its tools,
4
+ # and the methods it *declares* but does not implement are written by an LLM.
5
+ require "json"
6
+ require "ruby_llm"
7
+ require "schematist"
8
+ require "stringio"
9
+ require "timeout"
10
+ require "zeitwerk"
11
+
12
+ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
13
+ loader.ignore("#{__dir__}/omakase-agents.rb")
14
+ loader.setup
15
+
16
+ module Omakase
17
+ Error = Class.new(StandardError)
18
+ # The answer did not match the declared return type.
19
+ ContractError = Class.new(Error)
20
+ # The model or its provider failed. RubyLLM has already retried what it retries.
21
+ ProviderError = Class.new(Error)
22
+
23
+ class << self
24
+ # Providers, keys, default model, timeouts, logging — all of it is RubyLLM's.
25
+ def configure(&) = RubyLLM.configure(&)
26
+
27
+ # Reads every provider credential RubyLLM knows from the environment:
28
+ # ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OLLAMA_API_BASE, VERTEXAI_PROJECT_ID, …
29
+ def configure_from_env(env = ENV)
30
+ configure do |config|
31
+ provider_options.each do |option|
32
+ value = env[option.to_s.upcase]
33
+ config.public_send(:"#{option}=", value) if value
34
+ end
35
+ end
36
+ end
37
+
38
+ def provider_options
39
+ slugs = RubyLLM::Provider.providers.keys
40
+ RubyLLM::Configuration.options.select do |option|
41
+ slugs.any? { |slug| option.to_s.start_with?("#{slug}_") }
42
+ end
43
+ end
44
+
45
+ # Where generated code runs. Anything answering `call(agent, code, timeout:)`
46
+ # will do — swap in a subprocess or a container to get real isolation.
47
+ attr_writer :executor
48
+
49
+ def executor = @executor ||= Executor
50
+ end
51
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omakase-agents
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1.alpha
5
+ platform: ruby
6
+ authors:
7
+ - eugeny
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ruby_llm
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.16'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.16'
27
+ - !ruby/object:Gem::Dependency
28
+ name: schematist
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: zeitwerk
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.7'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.7'
55
+ description: |
56
+ A light agent framework on top of RubyLLM. An agent is an object: its fields are state, its
57
+ methods are what the model can call, and the methods it declares without a body are written by
58
+ the model at runtime — the method name and prompt are the specification, the schema is the
59
+ contract. The model acts by writing Ruby that runs on the agent itself.
60
+ email:
61
+ - esshka@gmail.com
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - LICENSE
67
+ - README.md
68
+ - lib/omakase-agents.rb
69
+ - lib/omakase.rb
70
+ - lib/omakase/agent.rb
71
+ - lib/omakase/capabilities.rb
72
+ - lib/omakase/doc.rb
73
+ - lib/omakase/executor.rb
74
+ - lib/omakase/fake_chat.rb
75
+ - lib/omakase/generation.rb
76
+ - lib/omakase/request.rb
77
+ - lib/omakase/schema.rb
78
+ - lib/omakase/strategies.rb
79
+ - lib/omakase/strategies/code_act.rb
80
+ - lib/omakase/strategies/predict.rb
81
+ - lib/omakase/tools/ruby.rb
82
+ - lib/omakase/type.rb
83
+ - lib/omakase/version.rb
84
+ homepage: https://github.com/esshka/omakase
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ homepage_uri: https://github.com/esshka/omakase
89
+ source_code_uri: https://github.com/esshka/omakase
90
+ bug_tracker_uri: https://github.com/esshka/omakase/issues
91
+ rubygems_mfa_required: 'true'
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '3.2'
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">"
104
+ - !ruby/object:Gem::Version
105
+ version: 1.3.1
106
+ requirements: []
107
+ rubygems_version: 3.4.10
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: Agents as plain Ruby objects.
111
+ test_files: []