omakase-agents 0.2.0 → 0.4.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 +83 -0
- data/README.md +252 -23
- data/lib/omakase/agent.rb +80 -13
- data/lib/omakase/capabilities.rb +7 -2
- data/lib/omakase/doc.rb +20 -4
- data/lib/omakase/executor.rb +152 -11
- data/lib/omakase/fake_chat.rb +28 -3
- data/lib/omakase/mcp.rb +90 -1
- data/lib/omakase/request.rb +15 -3
- data/lib/omakase/schema.rb +56 -6
- data/lib/omakase/skills/how_to_act/SKILL.md +40 -0
- data/lib/omakase/skills.rb +18 -4
- data/lib/omakase/strategies/code_act.rb +26 -9
- data/lib/omakase/strategies/predict.rb +8 -2
- data/lib/omakase/tools/ruby.rb +12 -4
- data/lib/omakase/trace.rb +50 -0
- data/lib/omakase/type.rb +15 -2
- data/lib/omakase/version.rb +1 -1
- data/lib/omakase.rb +33 -1
- metadata +6 -4
data/lib/omakase/agent.rb
CHANGED
|
@@ -4,6 +4,11 @@ module Omakase
|
|
|
4
4
|
# Fields are state, methods are what the model can call, `generates` declares
|
|
5
5
|
# the methods the model implements.
|
|
6
6
|
class Agent
|
|
7
|
+
# The generations this thread is inside, so one cannot re-enter itself.
|
|
8
|
+
RUNNING = :omakase_running
|
|
9
|
+
# Each level opens a chat with a fresh tool budget, so depth is what bounds the cost.
|
|
10
|
+
# ponytail: a constant; a setting when a real tree needs more.
|
|
11
|
+
MAX_DEPTH = 10
|
|
7
12
|
class << self
|
|
8
13
|
# The model and any RubyLLM chat option. Naming a provider takes the model
|
|
9
14
|
# id on trust, since providers like OpenRouter or Ollama serve ids that are
|
|
@@ -29,9 +34,14 @@ module Omakase
|
|
|
29
34
|
|
|
30
35
|
# An MCP server's tools, as methods on the agent. Options are passed to
|
|
31
36
|
# `ruby_llm-mcp` verbatim: `mcp :files, transport_type: :stdio, config: {command: "npx", …}`.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
# The server opens on the first instance, not at class load.
|
|
38
|
+
def mcp(name, **options) = MCP.defer(self, name, options)
|
|
39
|
+
|
|
40
|
+
# Here rather than in `initialize`, which a subclass may override and
|
|
41
|
+
# never call super from — and then generate with none of its tools.
|
|
42
|
+
def new(...)
|
|
43
|
+
MCP.ensure(self)
|
|
44
|
+
super
|
|
35
45
|
end
|
|
36
46
|
|
|
37
47
|
# A skill directory — a SKILL.md with YAML front matter. Its description
|
|
@@ -53,8 +63,9 @@ module Omakase
|
|
|
53
63
|
end
|
|
54
64
|
|
|
55
65
|
# 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
|
-
|
|
66
|
+
# string is a prompt read at call time, on the agent. `takes:` names the
|
|
67
|
+
# keyword arguments, and then Ruby checks them.
|
|
68
|
+
def generates(name, prompt = nil, takes: nil, returns: nil, strategy: nil, model: nil, &schema)
|
|
58
69
|
# Redeclaring an inherited generation is how a subclass specialises one.
|
|
59
70
|
# Landing on a method you wrote is not that, and would replace it unseen.
|
|
60
71
|
if Capabilities.names(self).include?(name) && !generations.key?(name)
|
|
@@ -72,7 +83,7 @@ module Omakase
|
|
|
72
83
|
strategy: Strategies.fetch(strategy || self.strategy),
|
|
73
84
|
model:
|
|
74
85
|
)
|
|
75
|
-
|
|
86
|
+
define_generation_method(name, takes)
|
|
76
87
|
define_singleton_method(name) { |**inputs| new.public_send(name, **inputs) }
|
|
77
88
|
end
|
|
78
89
|
|
|
@@ -84,6 +95,25 @@ module Omakase
|
|
|
84
95
|
|
|
85
96
|
private
|
|
86
97
|
|
|
98
|
+
# Named inputs become a real signature, so a missing or misspelled argument
|
|
99
|
+
# is an ArgumentError at the call rather than noise in a prompt — and the
|
|
100
|
+
# model reads the names too, instead of `**inputs`.
|
|
101
|
+
def define_generation_method(name, takes)
|
|
102
|
+
return define_method(name) { |**inputs| generate(name, inputs) } if takes.nil?
|
|
103
|
+
|
|
104
|
+
keywords = Array(takes)
|
|
105
|
+
bad = [name.to_s.chomp("?").chomp("!"), *keywords].reject { |word| /\A[a-z_]\w*\z/.match?(word.to_s) }
|
|
106
|
+
raise Error, "#{self}##{name}: takes: needs plain keyword names, got #{bad.inspect}" if bad.any?
|
|
107
|
+
|
|
108
|
+
class_eval <<~RUBY, __FILE__, __LINE__ + 1
|
|
109
|
+
def #{name}(#{keywords.map { |key| "#{key}:" }.join(", ")}, with: nil)
|
|
110
|
+
inputs = {#{keywords.map { |key| "#{key}: #{key}" }.join(", ")}}
|
|
111
|
+
inputs[:with] = with unless with.nil?
|
|
112
|
+
generate(:#{name}, inputs)
|
|
113
|
+
end
|
|
114
|
+
RUBY
|
|
115
|
+
end
|
|
116
|
+
|
|
87
117
|
def humanize(name)
|
|
88
118
|
text = name.to_s.tr("_", " ").capitalize
|
|
89
119
|
text.end_with?("?", "!") ? text : "#{text}."
|
|
@@ -102,6 +132,7 @@ module Omakase
|
|
|
102
132
|
subclass.strategy(@strategy) if @strategy
|
|
103
133
|
subclass.generations.merge!(generations)
|
|
104
134
|
subclass.descriptions.merge!(descriptions)
|
|
135
|
+
Skills.attach_core(subclass)
|
|
105
136
|
end
|
|
106
137
|
end
|
|
107
138
|
|
|
@@ -113,7 +144,7 @@ module Omakase
|
|
|
113
144
|
# A fresh conversation per call — two threads calling one agent must not
|
|
114
145
|
# share a mutable chat. What carries between calls is the object's own state.
|
|
115
146
|
# Overrides land on top of the class's options; an injected chat ignores them.
|
|
116
|
-
def chat(**overrides) = @chat ||
|
|
147
|
+
def chat(**overrides) = @chat || Omakase.chat_factory.call(**self.class.chat_options.merge(overrides))
|
|
117
148
|
|
|
118
149
|
# That state, as the model should read it: rebuilt on every call, and added
|
|
119
150
|
# to the class's instructions. Override it to remember anything.
|
|
@@ -150,12 +181,48 @@ module Omakase
|
|
|
150
181
|
|
|
151
182
|
def generate(name, inputs)
|
|
152
183
|
generation = self.class.generations.fetch(name)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
raise
|
|
184
|
+
running = (Thread.current[RUNNING] ||= [])
|
|
185
|
+
key = [object_id, name]
|
|
186
|
+
|
|
187
|
+
# Generated code can see this method and call it. Each nested call opens its
|
|
188
|
+
# own chat with its own tool budget, so the budget would bound nothing.
|
|
189
|
+
raise Error, "#{self.class}##{name} is already running — it cannot call itself" if running.any? { |frame| frame[:key] == key }
|
|
190
|
+
raise Error, "#{self.class}##{name}: generations nest deeper than #{MAX_DEPTH}" if running.size >= MAX_DEPTH
|
|
191
|
+
|
|
192
|
+
frame = {key:, inputs:}
|
|
193
|
+
running.push(frame)
|
|
194
|
+
begin
|
|
195
|
+
Omakase.emit(:generation, agent: self, name:, inputs:)
|
|
196
|
+
value = generation.strategy.call(Request.new(agent: self, generation:, inputs:))
|
|
197
|
+
Omakase.emit(:answer, agent: self, name:, value:)
|
|
198
|
+
value
|
|
199
|
+
rescue RubyLLM::Error, RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError => e
|
|
200
|
+
error = ProviderError.new("#{self.class}##{name}: #{e.message}")
|
|
201
|
+
Omakase.emit(:error, agent: self, name:, error:)
|
|
202
|
+
raise error
|
|
203
|
+
rescue => e
|
|
204
|
+
Omakase.emit(:error, agent: self, name:, error: e)
|
|
205
|
+
raise
|
|
206
|
+
ensure
|
|
207
|
+
running.delete(frame)
|
|
208
|
+
end
|
|
159
209
|
end
|
|
210
|
+
|
|
211
|
+
# Where generated code runs during a generation: self is the agent and the
|
|
212
|
+
# inputs are locals, which then last for the rest of that generation.
|
|
213
|
+
def omakase_scope
|
|
214
|
+
frame = Thread.current[RUNNING]&.reverse_each&.find { |entry| entry[:key].first == object_id }
|
|
215
|
+
return unless frame
|
|
216
|
+
|
|
217
|
+
frame[:scope] ||= omakase_blank_binding.tap do |scope|
|
|
218
|
+
frame[:inputs].except(:with).each do |name, value|
|
|
219
|
+
scope.local_variable_set(name, value) if /\A[a-z_]\w*\z/.match?(name.to_s)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A string instance_eval, so a `def` in generated code lands on this object
|
|
225
|
+
# alone — and no local of the caller leaks in.
|
|
226
|
+
def omakase_blank_binding = instance_eval("binding", __FILE__, __LINE__)
|
|
160
227
|
end
|
|
161
228
|
end
|
data/lib/omakase/capabilities.rb
CHANGED
|
@@ -18,10 +18,15 @@ module Omakase
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def entry(agent_class, name)
|
|
21
|
-
|
|
21
|
+
method = agent_class.instance_method(name)
|
|
22
|
+
signature = "#{name}(#{parameters(method)})"
|
|
22
23
|
# A prompt written as a block needs an instance to read; `describe` it instead.
|
|
23
24
|
prompt = agent_class.generations[name]&.prompt
|
|
24
|
-
|
|
25
|
+
# Look up on the method's owner so a late attach on a parent still
|
|
26
|
+
# documents the tool for subclasses created before that generate.
|
|
27
|
+
owner = method.owner
|
|
28
|
+
description = (owner.descriptions[name] if owner.respond_to?(:descriptions)) ||
|
|
29
|
+
(prompt unless prompt.is_a?(Proc))
|
|
25
30
|
description ? "#{signature} — #{description}" : signature
|
|
26
31
|
end
|
|
27
32
|
|
data/lib/omakase/doc.rb
CHANGED
|
@@ -13,7 +13,23 @@ module Omakase
|
|
|
13
13
|
# and the methods you wrote.
|
|
14
14
|
def boundary = defined?(ActiveRecord::Base) ? [*CORE, ActiveRecord::Base] : CORE
|
|
15
15
|
|
|
16
|
-
def of(object)
|
|
16
|
+
def of(object)
|
|
17
|
+
return of_class(object) if object.is_a?(Module)
|
|
18
|
+
|
|
19
|
+
[object.class.to_s, *signatures(object.class) { |name| object.method(name) }, *state(object)].join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A class, not an instance: what one would have. The model asks this before it
|
|
23
|
+
# builds an object of a type it has only been told the name of.
|
|
24
|
+
def of_class(klass)
|
|
25
|
+
[klass.to_s, *signatures(klass) { |name| klass.instance_method(name) }, *columns(klass)].join("\n")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def columns(klass)
|
|
29
|
+
return [] unless klass.respond_to?(:column_names)
|
|
30
|
+
|
|
31
|
+
klass.column_names.map { |name| " #{name}" }
|
|
32
|
+
end
|
|
17
33
|
|
|
18
34
|
# An object that answers `attributes` says what it holds better than its
|
|
19
35
|
# instance variables do — and a record's columns are state, not API.
|
|
@@ -24,14 +40,14 @@ module Omakase
|
|
|
24
40
|
|
|
25
41
|
def ivars(object) = object.instance_variables.to_h { |name| [name, object.instance_variable_get(name)] }
|
|
26
42
|
|
|
27
|
-
def signatures(
|
|
28
|
-
|
|
43
|
+
def signatures(klass, &getter)
|
|
44
|
+
klass.ancestors
|
|
29
45
|
.take_while { |mod| !boundary.include?(mod) }
|
|
30
46
|
.reject { |mod| mod.to_s.end_with?("GeneratedAttributeMethods") }
|
|
31
47
|
.flat_map { |mod| mod.public_instance_methods(false) }
|
|
32
48
|
.uniq.sort
|
|
33
49
|
.reject { |name| name.match?(/\A_|_associated_records_for_/) }
|
|
34
|
-
.map { |name| " #{name}(#{Capabilities.parameters(
|
|
50
|
+
.map { |name| " #{name}(#{Capabilities.parameters(getter.call(name))})" }
|
|
35
51
|
end
|
|
36
52
|
end
|
|
37
53
|
end
|
data/lib/omakase/executor.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Omakase
|
|
4
4
|
# Runs model-written Ruby in the agent's own context.
|
|
5
|
-
# ponytail: instance_eval is not a sandbox —
|
|
5
|
+
# ponytail: instance_eval is not a sandbox — Subprocess isolates a crash, not File.
|
|
6
6
|
module Executor
|
|
7
7
|
SOURCE = "(generated)"
|
|
8
8
|
RESULT = :omakase_result
|
|
@@ -11,33 +11,52 @@ module Omakase
|
|
|
11
11
|
TRACE = /\A#{Regexp.escape(SOURCE)}:\d+/
|
|
12
12
|
MAX_OUTPUT = 4_000
|
|
13
13
|
|
|
14
|
-
# What `finish(value)` handed back: the answer as a Ruby value, not as text
|
|
15
|
-
|
|
14
|
+
# What `finish(value)` handed back: the answer as a Ruby value, not as text,
|
|
15
|
+
# and whatever the code printed on the way there. `printed` defaults, so a
|
|
16
|
+
# replacement executor that only knows the value still satisfies the seam.
|
|
17
|
+
Answer = Data.define(:value, :printed) do
|
|
18
|
+
def initialize(value:, printed: "") = super
|
|
19
|
+
end
|
|
16
20
|
|
|
17
21
|
module_function
|
|
18
22
|
|
|
19
23
|
def call(agent, code, timeout: TIMEOUT)
|
|
20
24
|
printed = StringIO.new
|
|
21
25
|
answer = catch(RESULT) do
|
|
22
|
-
value = capturing(printed) { Timeout.timeout(timeout) { agent
|
|
26
|
+
value = capturing(printed) { Timeout.timeout(timeout) { evaluate(agent, code) } }
|
|
23
27
|
return observation([printed.string.chomp, "=> #{value.inspect}"])
|
|
24
28
|
end
|
|
25
|
-
Answer.new(value: answer)
|
|
29
|
+
Answer.new(value: answer, printed: printed.string.chomp)
|
|
26
30
|
rescue ScriptError, StandardError => e
|
|
27
31
|
observation([printed.string.chomp, failure(e, code)])
|
|
32
|
+
rescue SystemExit
|
|
33
|
+
# In process, exit would end the host. exit! cannot be caught: Subprocess covers that.
|
|
34
|
+
observation([printed.string.chomp, "exit is not allowed — answer with finish(value)"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Inside a generation the inputs are locals; outside one, plain instance_eval.
|
|
38
|
+
def evaluate(agent, code)
|
|
39
|
+
scope = agent.respond_to?(:omakase_scope, true) && agent.send(:omakase_scope)
|
|
40
|
+
scope ? scope.eval(code, SOURCE, 1) : agent.instance_eval(code, SOURCE, 1)
|
|
28
41
|
end
|
|
29
42
|
|
|
30
|
-
# The model can only fix what it can locate, so point at the line.
|
|
43
|
+
# The model can only fix what it can locate, so point at the line. A
|
|
44
|
+
# SyntaxError has no generated frame to point from.
|
|
31
45
|
def failure(error, code)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
message = "#{error.class}: #{error.message}"
|
|
47
|
+
line = error.backtrace.grep(TRACE).first&.slice(/:(\d+)/, 1)&.to_i
|
|
48
|
+
return message unless line
|
|
49
|
+
|
|
50
|
+
"#{message}\nline #{line}: #{code.lines[line - 1].to_s.strip}"
|
|
35
51
|
end
|
|
36
52
|
|
|
53
|
+
# Head and tail: the error or the value comes last, and it is what the model needs next.
|
|
37
54
|
def observation(parts)
|
|
38
55
|
text = parts.reject(&:empty?).join("\n")
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
return text if text.length <= MAX_OUTPUT
|
|
57
|
+
|
|
58
|
+
half = MAX_OUTPUT / 2
|
|
59
|
+
"#{text[0, half]}\n… (#{text.length - MAX_OUTPUT} characters truncated)\n#{text[-half..]}"
|
|
41
60
|
end
|
|
42
61
|
|
|
43
62
|
# Thread-local, so concurrent agents never share a buffer. Agent#puts reads it.
|
|
@@ -48,5 +67,127 @@ module Omakase
|
|
|
48
67
|
ensure
|
|
49
68
|
Thread.current[OUTPUT] = previous
|
|
50
69
|
end
|
|
70
|
+
|
|
71
|
+
# Generated code runs in a child process so a timeout, a crash, or a
|
|
72
|
+
# runaway loop cannot take the parent with it. The child is a copy of
|
|
73
|
+
# this process — it can still reach ActiveRecord, ENV, and the disk.
|
|
74
|
+
# That is isolation of fate, not of capability. Untrusted input still
|
|
75
|
+
# belongs to :predict.
|
|
76
|
+
#
|
|
77
|
+
# Ivars written in the child are marshalled back one at a time, so a
|
|
78
|
+
# generation's second tool call sees what the first one set. Methods
|
|
79
|
+
# the model defined on the object die with the child.
|
|
80
|
+
module Subprocess
|
|
81
|
+
module_function
|
|
82
|
+
|
|
83
|
+
def call(agent, code, timeout: TIMEOUT)
|
|
84
|
+
IO.pipe(binmode: true) do |reader, writer|
|
|
85
|
+
pid = fork do
|
|
86
|
+
reader.close
|
|
87
|
+
# Own process group, so a timeout can kill grandchildren too.
|
|
88
|
+
Process.setsid
|
|
89
|
+
# Parent owns the deadline; Timeout here would race it.
|
|
90
|
+
payload = pack(agent, Executor.call(agent, code, timeout: nil))
|
|
91
|
+
writer.write([payload.bytesize].pack("N"), payload)
|
|
92
|
+
ensure
|
|
93
|
+
exit! 0
|
|
94
|
+
end
|
|
95
|
+
writer.close
|
|
96
|
+
collect(reader, agent, pid, clock + timeout)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def collect(reader, agent, pid, deadline)
|
|
101
|
+
payload = read_packet(reader, deadline)
|
|
102
|
+
stop(pid) if payload == :timeout
|
|
103
|
+
status = reap(pid)
|
|
104
|
+
case payload
|
|
105
|
+
when :timeout then "execution timed out"
|
|
106
|
+
when :eof then "child process #{fate(status)}"
|
|
107
|
+
else unpack(agent, payload)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def pack(agent, result)
|
|
112
|
+
kept, dropped = agent.marshal_dump.partition { |_, value| marshalable?(value) }
|
|
113
|
+
result = note_dropped(result, dropped.map(&:first))
|
|
114
|
+
Marshal.dump({result: carry(result), state: kept.to_h})
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def marshalable?(value)
|
|
118
|
+
Marshal.dump(value)
|
|
119
|
+
true
|
|
120
|
+
rescue TypeError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A dropped ivar turns the answer into an observation: silent state loss
|
|
125
|
+
# would leave the next tool call reasoning about a value that is gone.
|
|
126
|
+
def note_dropped(result, dropped)
|
|
127
|
+
return result if dropped.empty?
|
|
128
|
+
|
|
129
|
+
prior = result.is_a?(Answer) ? [result.printed, "finish #{result.value.inspect}"].reject(&:empty?).join("\n") : result
|
|
130
|
+
Executor.observation([prior, "cannot keep #{dropped.join(", ")} across the process boundary"])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Only an Answer can fail here — an observation is a String.
|
|
134
|
+
def carry(result)
|
|
135
|
+
return result if marshalable?(result)
|
|
136
|
+
|
|
137
|
+
"cannot return #{result.value.class} across the process boundary"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def unpack(agent, payload)
|
|
141
|
+
packet = Marshal.load(payload)
|
|
142
|
+
agent.marshal_load(packet[:state])
|
|
143
|
+
packet[:result]
|
|
144
|
+
rescue ArgumentError, TypeError => e
|
|
145
|
+
"#{e.message}: a class defined in generated code does not survive the process boundary"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Length-prefixed, so a leftover write-end cannot hang the parent.
|
|
149
|
+
def read_packet(io, deadline)
|
|
150
|
+
header = read_exactly(io, 4, deadline)
|
|
151
|
+
return header if header.is_a?(Symbol)
|
|
152
|
+
|
|
153
|
+
read_exactly(io, header.unpack1("N"), deadline)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# select is exact for a pipe, so readpartial cannot block past the deadline.
|
|
157
|
+
def read_exactly(io, n, deadline)
|
|
158
|
+
buf = "".b
|
|
159
|
+
while buf.bytesize < n
|
|
160
|
+
return :timeout unless IO.select([io], nil, nil, [deadline - clock, 0].max)
|
|
161
|
+
|
|
162
|
+
buf << io.readpartial(n - buf.bytesize)
|
|
163
|
+
end
|
|
164
|
+
buf
|
|
165
|
+
rescue EOFError
|
|
166
|
+
:eof
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
|
|
171
|
+
def fate(status)
|
|
172
|
+
return "was killed" if status.nil? || status.signaled?
|
|
173
|
+
return "ended without an answer" if status.success?
|
|
174
|
+
|
|
175
|
+
"exited #{status.exitstatus}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The child led its own group unless the deadline beat it to setsid.
|
|
179
|
+
def stop(pid)
|
|
180
|
+
Process.kill("KILL", (Process.getpgid(pid) == pid) ? -pid : pid)
|
|
181
|
+
rescue Errno::ESRCH
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# ECHILD: the host reaps children itself, with a CHLD trap.
|
|
186
|
+
def reap(pid)
|
|
187
|
+
Process.wait2(pid).last
|
|
188
|
+
rescue Errno::ECHILD
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
end
|
|
51
192
|
end
|
|
52
193
|
end
|
data/lib/omakase/fake_chat.rb
CHANGED
|
@@ -9,9 +9,22 @@ module Omakase
|
|
|
9
9
|
# The script receives the chat, so it can drive the tool the way a model would:
|
|
10
10
|
#
|
|
11
11
|
# Omakase::FakeChat.new { |chat| chat.run("finish(42)") }
|
|
12
|
+
#
|
|
13
|
+
# Or one reply per model turn, in order; a turn past the last one raises:
|
|
14
|
+
#
|
|
15
|
+
# Omakase::FakeChat.replies("prose, not JSON", {"result" => 42})
|
|
12
16
|
class FakeChat
|
|
13
17
|
Response = Struct.new(:content)
|
|
14
18
|
|
|
19
|
+
def self.replies(*replies)
|
|
20
|
+
new do |chat|
|
|
21
|
+
raise Error, "FakeChat: no scripted reply left for turn #{chat.tasks.size}" if replies.empty?
|
|
22
|
+
|
|
23
|
+
reply = replies.shift
|
|
24
|
+
reply.respond_to?(:call) ? reply.call(chat) : reply
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
15
28
|
attr_reader :instructions, :schema, :tools, :tasks, :attachments
|
|
16
29
|
|
|
17
30
|
def initialize(&script)
|
|
@@ -20,20 +33,32 @@ module Omakase
|
|
|
20
33
|
@tools = []
|
|
21
34
|
@tasks = []
|
|
22
35
|
@attachments = []
|
|
36
|
+
@complete = true
|
|
23
37
|
end
|
|
24
38
|
|
|
25
|
-
|
|
39
|
+
# A log of every call — the real chat replaces unless `append:`.
|
|
40
|
+
def with_instructions(text, **) = tap { @instructions << text }
|
|
26
41
|
|
|
27
42
|
def with_schema(schema) = tap { @schema = schema }
|
|
28
43
|
|
|
29
|
-
def
|
|
44
|
+
def with_tools(*tools) = tap { @tools.concat(tools) }
|
|
30
45
|
|
|
31
|
-
def ask(task, with: nil)
|
|
46
|
+
def ask(task, with: nil) = ask_later(task, with:).step
|
|
47
|
+
|
|
48
|
+
def ask_later(task, with: nil)
|
|
32
49
|
@tasks << task
|
|
33
50
|
@attachments << with if with
|
|
51
|
+
tap { @complete = false }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The whole script is one step: it answers, so the chat is then complete.
|
|
55
|
+
def step
|
|
56
|
+
@complete = true
|
|
34
57
|
Response.new(@script.call(self))
|
|
35
58
|
end
|
|
36
59
|
|
|
60
|
+
def complete? = @complete
|
|
61
|
+
|
|
37
62
|
# Run code the way the model would, through the agent's one tool.
|
|
38
63
|
def run(code) = tools.fetch(0).call(code:)
|
|
39
64
|
end
|
data/lib/omakase/mcp.rb
CHANGED
|
@@ -4,19 +4,91 @@ module Omakase
|
|
|
4
4
|
# An MCP server's tools, as methods on the agent — so generated code calls a
|
|
5
5
|
# remote tool the same way it calls anything else the agent exposes.
|
|
6
6
|
module MCP
|
|
7
|
+
LOCKS = Mutex.new
|
|
8
|
+
# A tool name landing on a method is your bug, not a sidecar being down —
|
|
9
|
+
# and both arrive as an Error out of the same call.
|
|
10
|
+
Clash = Class.new(Error)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def client_factory=(factory)
|
|
14
|
+
unless factory.nil? || factory.respond_to?(:call)
|
|
15
|
+
raise Error, "client_factory must answer call, got #{factory.class}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@client_factory = factory
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def client_factory
|
|
22
|
+
@client_factory || ->(name, **options) {
|
|
23
|
+
require "ruby_llm/mcp"
|
|
24
|
+
RubyLLM::MCP.add_client(name: name.to_s, **options)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
7
29
|
module_function
|
|
8
30
|
|
|
31
|
+
def defer(agent_class, name, options)
|
|
32
|
+
servers(agent_class)[name.to_sym] = options
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ensure(agent_class)
|
|
36
|
+
return unless pending?(agent_class)
|
|
37
|
+
|
|
38
|
+
agent_class.ancestors.take_while { |mod| mod != Agent }.reverse_each do |klass|
|
|
39
|
+
next unless klass.is_a?(Class)
|
|
40
|
+
|
|
41
|
+
mutex_for(klass).synchronize { attach_pending(klass) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def pending?(agent_class)
|
|
46
|
+
agent_class.ancestors.take_while { |mod| mod != Agent }.any? do |mod|
|
|
47
|
+
mod.is_a?(Class) && (servers(mod).keys - attached(mod)).any?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def attach_pending(klass)
|
|
52
|
+
servers(klass).each do |name, options|
|
|
53
|
+
next if attached(klass).include?(name)
|
|
54
|
+
|
|
55
|
+
# A down sidecar stays unattached — connecting and listing its tools are
|
|
56
|
+
# the same trip. The generate still runs. Next ensure retries. A name
|
|
57
|
+
# clash is not the sidecar being down, it is your bug, so it raises.
|
|
58
|
+
begin
|
|
59
|
+
attach(klass, client_factory.call(name, **options))
|
|
60
|
+
rescue Clash
|
|
61
|
+
raise
|
|
62
|
+
rescue => e
|
|
63
|
+
Omakase.emit(:mcp, agent: klass, name:, error: e)
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
attached(klass) << name
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
9
70
|
def attach(agent_class, client)
|
|
71
|
+
defined = []
|
|
10
72
|
client.tools.each do |tool|
|
|
11
73
|
name = method_name(tool)
|
|
12
74
|
# A remote tool list must not quietly shadow a capability the agent already has.
|
|
13
|
-
raise
|
|
75
|
+
raise Clash, "#{agent_class} already has ##{name}" if Capabilities.names(agent_class).include?(name)
|
|
14
76
|
|
|
15
77
|
agent_class.describe(description(tool))
|
|
16
78
|
# nil is how a model leaves an argument out; MCP servers reject it.
|
|
17
79
|
agent_class.define_method(name) { |**arguments| MCP.result(tool.execute(**arguments.compact)) }
|
|
80
|
+
defined << name
|
|
18
81
|
end
|
|
19
82
|
client
|
|
83
|
+
rescue
|
|
84
|
+
# Else the next ensure dies on "already has #name".
|
|
85
|
+
defined.each do |name|
|
|
86
|
+
agent_class.send(:remove_method, name)
|
|
87
|
+
agent_class.descriptions.delete(name)
|
|
88
|
+
end
|
|
89
|
+
agent_class.instance_variable_set(:@pending_description, nil)
|
|
90
|
+
client.close if client.respond_to?(:close)
|
|
91
|
+
raise
|
|
20
92
|
end
|
|
21
93
|
|
|
22
94
|
# Tool names may hold characters a Ruby method name cannot.
|
|
@@ -39,5 +111,22 @@ module Omakase
|
|
|
39
111
|
text = tool.description.to_s.gsub(/\s+/, " ").strip
|
|
40
112
|
[text, ("Arguments — #{arguments.join(", ")}" if arguments.any?)].compact.join(" ")
|
|
41
113
|
end
|
|
114
|
+
|
|
115
|
+
def mutex_for(klass)
|
|
116
|
+
mutex = klass.instance_variable_get(:@mcp_mutex)
|
|
117
|
+
return mutex if mutex
|
|
118
|
+
|
|
119
|
+
LOCKS.synchronize do
|
|
120
|
+
klass.instance_variable_get(:@mcp_mutex) || klass.instance_variable_set(:@mcp_mutex, Mutex.new)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def servers(klass)
|
|
125
|
+
klass.instance_variable_get(:@mcp_servers) || klass.instance_variable_set(:@mcp_servers, {})
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def attached(klass)
|
|
129
|
+
klass.instance_variable_get(:@mcp_attached) || klass.instance_variable_set(:@mcp_attached, [])
|
|
130
|
+
end
|
|
42
131
|
end
|
|
43
132
|
end
|
data/lib/omakase/request.rb
CHANGED
|
@@ -7,17 +7,21 @@ module Omakase
|
|
|
7
7
|
|
|
8
8
|
def schema = generation.schema
|
|
9
9
|
|
|
10
|
-
def instructions =
|
|
10
|
+
def instructions = agent.class.instructions
|
|
11
|
+
|
|
12
|
+
# Rebuilt on every call, so it goes after what a provider can cache.
|
|
13
|
+
def context = agent.context.to_s
|
|
11
14
|
|
|
12
15
|
# `with:` is reserved: files for the model to look at, passed through to
|
|
13
16
|
# RubyLLM's `ask(with:)` as attachments rather than rendered into the text.
|
|
14
17
|
def attachments = inputs[:with]
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
# With `preview:`, the model's code holds the inputs, so the prompt only shows them.
|
|
20
|
+
def task(preview: false)
|
|
17
21
|
arguments = inputs.except(:with)
|
|
18
22
|
return prompt if arguments.empty?
|
|
19
23
|
|
|
20
|
-
lines = arguments.map { |name, value| "- #{name}: #{value.inspect}" }
|
|
24
|
+
lines = arguments.map { |name, value| "- #{name}: #{preview ? shorten(value.inspect) : value.inspect}" }
|
|
21
25
|
"#{prompt}\n\nInputs:\n#{lines.join("\n")}"
|
|
22
26
|
end
|
|
23
27
|
|
|
@@ -27,5 +31,13 @@ module Omakase
|
|
|
27
31
|
text = generation.prompt
|
|
28
32
|
text.is_a?(Proc) ? agent.instance_exec(&text) : text
|
|
29
33
|
end
|
|
34
|
+
|
|
35
|
+
# Past the limit, code_act shows the start of an input: the whole value is a local.
|
|
36
|
+
# ponytail: inspects the whole value, then cuts; a bounded printer if a huge input shows up in a profile.
|
|
37
|
+
def shorten(text, limit = 500)
|
|
38
|
+
return text if text.length <= limit
|
|
39
|
+
|
|
40
|
+
"#{text[0, limit]}… (#{text.length} characters — the whole value is in the local)"
|
|
41
|
+
end
|
|
30
42
|
end
|
|
31
43
|
end
|