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 +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +113 -4
- data/lib/omakase/agent.rb +23 -6
- data/lib/omakase/capabilities.rb +3 -1
- data/lib/omakase/executor.rb +2 -1
- data/lib/omakase/fake_chat.rb +4 -2
- data/lib/omakase/generation.rb +3 -2
- data/lib/omakase/request.rb +16 -4
- data/lib/omakase/schema.rb +4 -1
- data/lib/omakase/strategies/code_act.rb +1 -1
- data/lib/omakase/strategies/predict.rb +1 -1
- data/lib/omakase/tools/ruby.rb +6 -2
- data/lib/omakase/version.rb +1 -1
- data/lib/omakase.rb +26 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c14f0a611e4eacd12ed77e2944f0af6e8b5f51a908ecceff832418bb7c4f6c7e
|
|
4
|
+
data.tar.gz: 7bdeb3cef506c2eda19939a51782c24a87347158b660c83d79f2fb643720131a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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) 
|
|
6
|
+
**[esshka.github.io/omakase](https://esshka.github.io/omakase/)** · [rubygems](https://rubygems.org/gems/omakase-agents) · [changelog](CHANGELOG.md) 
|
|
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
|
|
287
|
-
tests offline
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/omakase/capabilities.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
+
# A prompt written as a block needs an instance to read; `describe` it instead.
|
|
23
|
+
prompt = agent_class.generations[name]&.prompt
|
|
24
|
+
description = agent_class.descriptions[name] || (prompt unless prompt.is_a?(Proc))
|
|
23
25
|
description ? "#{signature} — #{description}" : signature
|
|
24
26
|
end
|
|
25
27
|
|
data/lib/omakase/executor.rb
CHANGED
|
@@ -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(
|
|
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
|
data/lib/omakase/fake_chat.rb
CHANGED
|
@@ -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
|
|
data/lib/omakase/generation.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/omakase/request.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
data/lib/omakase/schema.rb
CHANGED
|
@@ -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
|
|
@@ -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)
|
data/lib/omakase/tools/ruby.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
46
|
+
rescue ContractError => e
|
|
44
47
|
# Off-contract answers are corrected inside the same loop, not by another request.
|
|
48
|
+
# Anything else — a broken executor, a bad configuration — is not the model's to fix.
|
|
45
49
|
"finish rejected: #{e.message}"
|
|
46
50
|
end
|
|
47
51
|
end
|
data/lib/omakase/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
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.
|
|
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: []
|