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.
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
- def mcp(name, **options)
33
- require "ruby_llm/mcp"
34
- MCP.attach(self, RubyLLM::MCP.add_client(name: name.to_s, **options))
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
- def generates(name, prompt = nil, returns: nil, strategy: nil, model: nil, &schema)
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
- define_method(name) { |**inputs| generate(name, inputs) }
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 || RubyLLM.chat(**self.class.chat_options.merge(overrides))
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
- 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
157
- rescue RubyLLM::Error, RubyLLM::ConfigurationError, RubyLLM::ModelNotFoundError => e
158
- raise ProviderError, "#{self.class}##{name}: #{e.message}"
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
@@ -18,10 +18,15 @@ module Omakase
18
18
  end
19
19
 
20
20
  def entry(agent_class, name)
21
- signature = "#{name}(#{parameters(agent_class.instance_method(name))})"
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
- description = agent_class.descriptions[name] || (prompt unless prompt.is_a?(Proc))
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) = [object.class.to_s, *signatures(object), *state(object)].join("\n")
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(object)
28
- object.class.ancestors
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(object.method(name))})" }
50
+ .map { |name| " #{name}(#{Capabilities.parameters(getter.call(name))})" }
35
51
  end
36
52
  end
37
53
  end
@@ -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 — see Omakase.executor to swap it.
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
- Answer = Data.define(:value)
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.instance_eval(code, SOURCE, 1) } }
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
- line = error.backtrace&.grep(TRACE)&.first&.slice(/:(\d+)/, 1)&.to_i
33
- source = code.lines[line - 1]&.strip if line&.positive?
34
- ["#{error.class}: #{error.message}", ("line #{line}: #{source}" if source)].compact.join("\n")
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
- text = "#{text[0, MAX_OUTPUT]}\n… (truncated)" if text.length > MAX_OUTPUT
40
- text.empty? ? "(no output)" : text
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
@@ -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
- def with_instructions(text) = tap { @instructions << text }
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 with_tool(tool, **) = tap { @tools << tool }
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 Error, "#{agent_class} already has ##{name}" if Capabilities.names(agent_class).include?(name)
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
@@ -7,17 +7,21 @@ module Omakase
7
7
 
8
8
  def schema = generation.schema
9
9
 
10
- def instructions = [agent.class.instructions, agent.context].reject { |text| text.to_s.empty? }.join("\n\n")
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
- def task
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