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