omakase-agents 0.1.0 → 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: 9c9bb739c426cb0b979359a772e7cdcbe84019c303d10c0d1c0b27fe8b8d00c0
4
- data.tar.gz: 88c343d1a654d19be11b6071658e962be3f64dc477e8be1aa767520c3c7ac13f
3
+ metadata.gz: c14f0a611e4eacd12ed77e2944f0af6e8b5f51a908ecceff832418bb7c4f6c7e
4
+ data.tar.gz: 7bdeb3cef506c2eda19939a51782c24a87347158b660c83d79f2fb643720131a
5
5
  SHA512:
6
- metadata.gz: 8d02d96784acf426a52dc5eb2207d3a426e9beddb1106cd96195941781557019823be03502e9c580253ff249a8dfa7df0d4210e7eace17a6cfaca1d362321151
7
- data.tar.gz: 6fb9221fb891f4a9b0898b8924ec2705e093acfeba76367c57bde8d56810227da3ea9667036638360515797ceda2bf1d5edd442766f1fb87f44aeeaa75265166
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,7 +3,7 @@
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?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 —
@@ -139,6 +139,22 @@ FeedbackAgent.new.analyze(text: "…")
139
139
  `describe` above an ordinary method is the docstring Ruby does not have — it is what the model reads
140
140
  when it decides what to call.
141
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
+
142
158
  ### Return types
143
159
 
144
160
  The block is a [schematist](https://github.com/crmne/schematist) schema and becomes the provider's
@@ -153,6 +169,20 @@ Both forms are the same mechanism: a schema whose only property is `result` unwr
153
169
  A Ruby class works too — `returns: Ticket` — and then the method hands back the object rather than
154
170
  data; see [`:code_act`](#strategies) for what that requires.
155
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
+
156
186
  ### Models and providers
157
187
 
158
188
  Any provider RubyLLM supports — Anthropic, OpenAI, Gemini, Bedrock, Azure, Mistral, DeepSeek, xAI,
@@ -170,6 +200,13 @@ end
170
200
  Naming a provider implies `assume_model_exists: true`; any other RubyLLM chat option passes through.
171
201
  Subclasses inherit the setting and can override it, so one `ApplicationAgent` configures the lot.
172
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
+
173
210
  Credentials come from the environment — one call covers every provider:
174
211
 
175
212
  ```ruby
@@ -283,8 +320,19 @@ them — `remember("Shipping to Canada takes three weeks")` on the way out, `rec
283
320
  on the way in. The store is a field, so what the agent learned marshals with it and is there on the
284
321
  next run.
285
322
 
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
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
288
336
  things one agent learns about its work; past that it is your database's job — pgvector and the
289
337
  [`neighbor`](https://github.com/ankane/neighbor) gem — and `Omakase::Memory` is the interface to
290
338
  reimplement against it. And for a few dozen facts, `@notes.grep(/shipping/)` beats every word of
@@ -305,6 +353,18 @@ chat = Omakase::FakeChat.new { |fake| fake.run("finish(stock_of(:apple))") }
305
353
 
306
354
  It records `instructions`, `schema`, `tools` and `tasks`, so the prompt is assertable too.
307
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
+
308
368
  ## Rails
309
369
 
310
370
  Agents live in `app/agents` — Rails autoloads it, and reloading is safe because everything a
@@ -355,6 +415,49 @@ ids.map { |id| Thread.new { WarehouseAgent.appraise(item_id: id) } }.map(&:value
355
415
  Sharing one agent instance across threads is your business as usual — its state is yours. Do not
356
416
  turn on RubyLLM's `tool_concurrency`: that runs generated code against the same agent in parallel.
357
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
+
358
461
  **Errors.** Everything raised at the boundary is an `Omakase::Error`:
359
462
 
360
463
  | | |
@@ -400,6 +503,12 @@ an answer that misses the contract is rejected into the same loop — the model
400
503
  another request. Nothing in the provider bounds a tool loop, so the tool does: ten calls, then a turn
401
504
  to answer with what it has.
402
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
+
403
512
  Because the answer is computed rather than retyped, the return type can be a Ruby class and the
404
513
  method hands back the object itself:
405
514
 
@@ -462,7 +571,7 @@ Copy `.env.example` to `.env` and fill in a key; `MODEL` and `PROVIDER` there pi
462
571
  | [`memory_agent.rb`](examples/memory_agent.rb) | recall by meaning, kept across a marshalled run |
463
572
 
464
573
  ```bash
465
- bundle exec rake # tests, no network
574
+ bundle exec rake # tests and Standard, no network
466
575
  ruby examples/inventory_agent.rb
467
576
  ```
468
577
 
data/lib/omakase/agent.rb CHANGED
@@ -46,18 +46,31 @@ module Omakase
46
46
  describe "Search what you remember, by meaning; the closest few come back"
47
47
  define_method(:recall) { |query, limit: 5| (@memory ||= Memory.new).recall(query, limit:) }
48
48
  end
49
+
49
50
  # Documents the method defined next — the docstring Ruby does not have.
50
51
  def describe(text)
51
52
  @pending_description = text
52
53
  end
53
54
 
54
- # Without a prompt, the method name is the prompt.
55
- 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
+
56
68
  generations[name] = Generation.new(
57
69
  name:,
58
70
  prompt: prompt || humanize(name),
59
71
  schema: Schema.define(returns:, &schema),
60
- strategy: Strategies.fetch(strategy || self.strategy)
72
+ strategy: Strategies.fetch(strategy || self.strategy),
73
+ model:
61
74
  )
62
75
  define_method(name) { |**inputs| generate(name, inputs) }
63
76
  define_singleton_method(name) { |**inputs| new.public_send(name, **inputs) }
@@ -99,7 +112,8 @@ module Omakase
99
112
 
100
113
  # A fresh conversation per call — two threads calling one agent must not
101
114
  # share a mutable chat. What carries between calls is the object's own state.
102
- def chat = @chat || RubyLLM.chat(**self.class.chat_options)
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))
103
117
 
104
118
  # That state, as the model should read it: rebuilt on every call, and added
105
119
  # to the class's instructions. Override it to remember anything.
@@ -125,7 +139,7 @@ module Omakase
125
139
 
126
140
  def p(*args)
127
141
  args.each { |arg| omakase_output.puts(arg.inspect) }
128
- args.size <= 1 ? args.first : args
142
+ (args.size <= 1) ? args.first : args
129
143
  end
130
144
 
131
145
  alias_method :pp, :p
@@ -136,7 +150,10 @@ module Omakase
136
150
 
137
151
  def generate(name, inputs)
138
152
  generation = self.class.generations.fetch(name)
139
- 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
140
157
  rescue RubyLLM::Error, RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError => e
141
158
  raise ProviderError, "#{self.class}##{name}: #{e.message}"
142
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
 
@@ -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
@@ -3,17 +3,29 @@
3
3
  module Omakase
4
4
  # One invocation of a generation method: all a strategy may depend on.
5
5
  Request = Data.define(:agent, :generation, :inputs) do
6
- def chat = agent.chat
6
+ def chat = agent.chat(**{model: generation.model}.compact)
7
7
 
8
8
  def schema = generation.schema
9
9
 
10
10
  def instructions = [agent.class.instructions, agent.context].reject { |text| text.to_s.empty? }.join("\n\n")
11
11
 
12
+ # `with:` is reserved: files for the model to look at, passed through to
13
+ # RubyLLM's `ask(with:)` as attachments rather than rendered into the text.
14
+ def attachments = inputs[:with]
15
+
12
16
  def task
13
- return generation.prompt if inputs.empty?
17
+ arguments = inputs.except(:with)
18
+ return prompt if arguments.empty?
19
+
20
+ lines = arguments.map { |name, value| "- #{name}: #{value.inspect}" }
21
+ "#{prompt}\n\nInputs:\n#{lines.join("\n")}"
22
+ end
14
23
 
15
- arguments = inputs.map { |name, value| "- #{name}: #{value.inspect}" }
16
- "#{generation.prompt}\n\nInputs:\n#{arguments.join("\n")}"
24
+ # A prompt written as a block is read at call time, on the agent so one
25
+ # declaration serves an object however it happens to be configured.
26
+ def prompt
27
+ text = generation.prompt
28
+ text.is_a?(Proc) ? agent.instance_exec(&text) : text
17
29
  end
18
30
  end
19
31
  end
@@ -12,6 +12,9 @@ module Omakase
12
12
  }.freeze
13
13
 
14
14
  def self.define(returns: nil, &block)
15
+ # Two contracts in one declaration: one of them would be dropped, silently.
16
+ raise Error, "returns: and a schema block are two different contracts — declare one" if returns && block
17
+
15
18
  return new(Schematist::Schema.create(&block)) if block
16
19
  return Type.new(returns) if returns.is_a?(Module)
17
20
 
@@ -65,7 +68,7 @@ module Omakase
65
68
  def properties = json.fetch("properties")
66
69
 
67
70
  def demand(value, type)
68
- matched = type == "boolean" ? [true, false].include?(value) : value.is_a?(RUBY_TYPES.fetch(type))
71
+ matched = (type == "boolean") ? [true, false].include?(value) : value.is_a?(RUBY_TYPES.fetch(type))
69
72
  raise ContractError, "expected <#{type}>, got #{value.inspect}" unless matched
70
73
 
71
74
  value
@@ -12,7 +12,7 @@ module Omakase
12
12
  notes = request.chat
13
13
  .with_instructions(instructions(request))
14
14
  .with_tool(tool)
15
- .ask(request.task)
15
+ .ask(request.task, with: request.attachments)
16
16
  .content
17
17
 
18
18
  return tool.answer.value if tool.answer
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omakase
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/omakase.rb CHANGED
@@ -46,14 +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
52
54
 
53
55
  # How text becomes a vector, for Memory. Anything answering `call(text)`
54
56
  # will do; the model and its provider are RubyLLM's to configure.
55
- attr_writer :embedder
57
+ def embedder=(embedder)
58
+ @embedder = callable!(embedder, "embedder")
59
+ end
56
60
 
57
61
  def embedder = @embedder ||= ->(text) { RubyLLM.embed(text).vectors }
62
+
63
+ # 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
58
82
  end
59
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.1.0
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
@@ -91,6 +92,7 @@ metadata:
91
92
  homepage_uri: https://github.com/esshka/omakase
92
93
  source_code_uri: https://github.com/esshka/omakase
93
94
  bug_tracker_uri: https://github.com/esshka/omakase/issues
95
+ changelog_uri: https://github.com/esshka/omakase/blob/main/CHANGELOG.md
94
96
  rubygems_mfa_required: 'true'
95
97
  post_install_message:
96
98
  rdoc_options: []