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.
@@ -43,9 +43,10 @@ module Omakase
43
43
 
44
44
  # From the provider's JSON: unwrap first, then hold it to the contract.
45
45
  def cast(content)
46
+ content = parse(content) if content.is_a?(String)
46
47
  raise ContractError, "expected JSON matching #{JSON.generate(json)}, got #{content.inspect}" unless content.is_a?(Hash)
47
48
 
48
- data = RubyLLM::Utils.deep_symbolize_keys(content)
49
+ data = symbolize(content)
49
50
  take(wrapped? ? data.fetch(RESULT) { raise ContractError, %(missing "result" in #{data.inspect}) } : data)
50
51
  end
51
52
 
@@ -54,9 +55,9 @@ module Omakase
54
55
  return demand(value, properties.fetch("result")["type"]) if wrapped?
55
56
  raise ContractError, "expected #{describe}, got #{value.inspect}" unless value.is_a?(Hash)
56
57
 
57
- data = RubyLLM::Utils.deep_symbolize_keys(value)
58
- missing = json.fetch("required").map(&:to_sym) - data.keys
59
- raise ContractError, "missing #{missing.join(", ")} — expected #{describe}" if missing.any?
58
+ data = symbolize(value)
59
+ problem = object_mismatch(data, json, nil)
60
+ raise ContractError, "#{problem} — expected #{describe}" if problem
60
61
 
61
62
  data
62
63
  end
@@ -67,11 +68,60 @@ module Omakase
67
68
 
68
69
  def properties = json.fetch("properties")
69
70
 
71
+ # RubyLLM 2 hands structured output back as a JSON string. Not JSON stays a
72
+ # String, so cast reports what the model actually said.
73
+ def parse(content)
74
+ JSON.parse(content)
75
+ rescue JSON::ParserError
76
+ content
77
+ end
78
+
79
+ def symbolize(value)
80
+ case value
81
+ when Hash then value.to_h { |key, item| [key.respond_to?(:to_sym) ? key.to_sym : key, symbolize(item)] }
82
+ when Array then value.map { |item| symbolize(item) }
83
+ else value
84
+ end
85
+ end
86
+
70
87
  def demand(value, type)
71
- matched = (type == "boolean") ? [true, false].include?(value) : value.is_a?(RUBY_TYPES.fetch(type))
72
- raise ContractError, "expected <#{type}>, got #{value.inspect}" unless matched
88
+ raise ContractError, "expected <#{type}>, got #{value.inspect}" unless type?(value, type)
73
89
 
74
90
  value
75
91
  end
92
+
93
+ def type?(value, type)
94
+ case type
95
+ when "boolean" then [true, false].include?(value)
96
+ when "null" then value.nil?
97
+ when Array then type.any? { |each| type?(value, each) }
98
+ else value.is_a?(RUBY_TYPES.fetch(type, BasicObject))
99
+ end
100
+ end
101
+
102
+ # The first place a nested value breaks its schema, as a path the model can fix.
103
+ def mismatch(value, spec, path)
104
+ return "#{path}: expected <#{Array(spec["type"]).join("|")}>, got #{value.inspect}" if spec["type"] && !type?(value, spec["type"])
105
+ return "#{path}: expected one of #{spec["enum"].inspect}, got #{value.inspect}" if spec["enum"] && !spec["enum"].include?(value)
106
+
107
+ case value
108
+ when Array then value.each_with_index.filter_map { |item, i| mismatch(item, spec["items"], "#{path}[#{i}]") if spec["items"] }.first
109
+ when Hash then object_mismatch(value, spec, path)
110
+ end
111
+ end
112
+
113
+ # An optional field left nil is a field left out, which is allowed.
114
+ def object_mismatch(value, spec, path)
115
+ required = Array(spec["required"])
116
+ missing = required.map(&:to_sym) - value.keys
117
+ return [path, "missing #{missing.join(", ")}"].compact.join(": ") if missing.any?
118
+
119
+ Hash(spec["properties"]).filter_map do |name, child|
120
+ field = value[name.to_sym]
121
+ next if field.nil? && !required.include?(name)
122
+
123
+ mismatch(field, child, [path, name].compact.join(".")) if value.key?(name.to_sym)
124
+ end.first
125
+ end
76
126
  end
77
127
  end
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: how-to-act
3
+ description: How to write the Ruby that implements a generation — finish, prints, doc, ivars. Call once before you act.
4
+ ---
5
+
6
+ You write Ruby. It runs on the agent: its methods and ivars are yours, on self.
7
+
8
+ Call a method. Print what you need to see. `finish` with the answer — the value, not a sentence about it.
9
+
10
+ ```ruby
11
+ orders = orders_for("ada@example.com")
12
+ orders.each { |o| puts "total: #{o.total}" }
13
+ puts policy_on(:damage)
14
+ finish(Refund.new(order_id: 1, amount: 39.9, reason: "cracked mug, policy :damage"))
15
+ ```
16
+
17
+ Without `finish`, the last expression comes back as `=> …` and you keep going.
18
+
19
+ ```ruby
20
+ stock_of("apple") + stock_of("pear")
21
+ # => 7
22
+ ```
23
+
24
+ The inputs are local variables. Locals and ivars last for the rest of this generation:
25
+
26
+ ```ruby
27
+ n = items.sum { |item| stock_of(item) }
28
+ # later:
29
+ finish(n + 1)
30
+ ```
31
+
32
+ An object you do not know:
33
+
34
+ ```ruby
35
+ doc(orders.first) # a class works too
36
+ ```
37
+
38
+ For meaning — classify, judge, summarize — call a generation method, not a regex. Never type out large data by hand: compute it or take it from an input.
39
+
40
+ Prints come back to you, not to the process. If `finish` is refused, the message says why — fix it in the next call. Do not retype a value you already computed. Work in as few calls as you can.
@@ -6,11 +6,18 @@ module Omakase
6
6
  # capabilities; the body only arrives when the model calls the method — which
7
7
  # is all "loaded on demand" has to mean.
8
8
  module Skills
9
+ CORE = File.expand_path("skills/how_to_act", __dir__)
10
+
9
11
  module_function
10
12
 
13
+ # Every agent gets the how-to. Skip when a parent already defined it.
14
+ def attach_core(agent_class)
15
+ attach(agent_class, CORE) unless Capabilities.names(agent_class).include?(:how_to_act)
16
+ end
17
+
11
18
  def attach(agent_class, path)
12
19
  directory = File.expand_path(path)
13
- front_matter, body = parse(File.read(File.join(directory, "SKILL.md")))
20
+ front_matter, body = parse(File.read(File.join(directory, "SKILL.md"), encoding: "UTF-8"))
14
21
  name = (front_matter["name"] || File.basename(directory)).tr("-", "_").to_sym
15
22
  raise Error, "#{agent_class} already has ##{name}" if Capabilities.names(agent_class).include?(name)
16
23
 
@@ -19,12 +26,19 @@ module Omakase
19
26
  name
20
27
  end
21
28
 
22
- # The front matter every SKILL.md in the wild is written with.
29
+ # The front matter every SKILL.md in the wild is written with — CRLF too.
23
30
  def parse(text)
24
- match = text.match(/\A---\n(.*?)\n---\n(.*)\z/m)
31
+ match = text.match(/\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z/m)
25
32
  return [{}, text.strip] unless match
26
33
 
27
- [YAML.safe_load(match[1]), match[2].strip]
34
+ [front_matter(match[1]), match[2].strip]
35
+ end
36
+
37
+ # Claude Code style hints like `argument-hint: "<x>" [-p]` are not YAML; read those line by line.
38
+ def front_matter(text)
39
+ YAML.safe_load(text)
40
+ rescue Psych::SyntaxError
41
+ text.scan(/^([\w-]+):[ \t]*(.*?)\r?$/).to_h
28
42
  end
29
43
  end
30
44
  end
@@ -9,14 +9,16 @@ module Omakase
9
9
 
10
10
  def call(request)
11
11
  tool = Tools::Ruby.new(request.agent, request.schema)
12
- notes = request.chat
13
- .with_instructions(instructions(request))
14
- .with_tool(tool)
15
- .ask(request.task, with: request.attachments)
16
- .content
17
-
12
+ chat = Predict.instruct(request.chat, instructions(request), request.context)
13
+ .with_tools(tool)
14
+ .ask_later(request.task(preview: true), with: request.attachments)
15
+ response = run(chat, tool)
16
+ # A text reply is usually a model that forgot how to answer, not one that is done.
17
+ response = run(chat.ask_later(nudge(request)), tool) unless tool.done?
18
18
  return tool.answer.value if tool.answer
19
19
 
20
+ notes = response&.content
21
+
20
22
  # It never called finish. A JSON answer can still be given in a tool-free turn.
21
23
  return Predict.call(request, task: "#{request.task}\n\nWork done:\n#{notes}") unless request.schema.code_only?
22
24
 
@@ -24,18 +26,33 @@ module Omakase
24
26
  raise ContractError, "#{request.generation.name}: the model never called finish(#{request.schema.describe})"
25
27
  end
26
28
 
29
+ def run(chat, tool)
30
+ response = nil
31
+ response = chat.step until chat.complete? || tool.done?
32
+ response
33
+ end
34
+
35
+ def nudge(request)
36
+ "Your reply was text with no tool call, so the task is not done. " \
37
+ "Call the `ruby` tool and end with finish(#{request.schema.describe})."
38
+ end
39
+
40
+ # Nothing here changes between calls of one generation, so providers can cache it.
27
41
  def instructions(request)
28
42
  <<~TEXT
29
43
  #{request.instructions}
30
44
 
31
45
  You act by writing Ruby: call the `ruby` tool with code that is evaluated on the
32
- agent object, so its methods and state are available on self.
46
+ agent object, so its methods and state are available on self. The inputs are
47
+ local variables in that code — use them, do not retype them.
33
48
 
34
49
  #{capabilities(request).join("\n")}
35
50
 
36
- `doc(object)` prints what an object of an unfamiliar type offers.
51
+ `doc(object)` prints what an object of an unfamiliar type offers; a class works too.
52
+ `how_to_act` is the rest, with examples — call it once before you write code.
37
53
 
38
- Return the answer from inside the code, never as a message — the last thing you run is:
54
+ Run the task; do not define a method for it. Return the answer from inside the
55
+ code, never as a message — the last thing you run is:
39
56
 
40
57
  finish(#{request.schema.describe})
41
58
 
@@ -9,8 +9,7 @@ module Omakase
9
9
  module_function
10
10
 
11
11
  def call(request, task: request.task)
12
- chat = request.chat
13
- .with_instructions(instructions(request))
12
+ chat = instruct(request.chat, instructions(request), request.context)
14
13
  .with_schema(request.schema.definition)
15
14
 
16
15
  request.schema.cast(chat.ask(task, with: request.attachments).content)
@@ -19,6 +18,13 @@ module Omakase
19
18
  request.schema.cast(chat.ask("#{e.message}\n\n#{CORRECTION}").content)
20
19
  end
21
20
 
21
+ # The stable text first and marked for the cache; the context, which
22
+ # changes from call to call, after it.
23
+ def instruct(chat, stable, context)
24
+ chat = chat.with_instructions(stable, cache_until_here: true)
25
+ context.empty? ? chat : chat.with_instructions(context, append: true)
26
+ end
27
+
22
28
  # Weaker providers treat the schema as a hint, so it goes in the prompt too.
23
29
  def instructions(request)
24
30
  "#{request.instructions}\n\nAnswer as JSON matching this schema:\n#{JSON.generate(request.schema.json)}"
@@ -6,6 +6,8 @@ module Omakase
6
6
  # through it, so the model composes calls in code instead of one per turn.
7
7
  class Ruby < RubyLLM::Tool
8
8
  BUDGET = 10
9
+ # Only a fence around the whole code: one inside it is part of a string.
10
+ FENCE = /\A```(?:ruby|rb)?[ \t]*\r?\n(.*?)\r?\n?```\z/m
9
11
 
10
12
  description <<~TEXT
11
13
  Evaluate Ruby in the context of the agent object: its methods and state are
@@ -13,7 +15,7 @@ module Omakase
13
15
  expression, is returned to you. Call finish(value) to answer.
14
16
  TEXT
15
17
 
16
- param :code, desc: "Ruby source to evaluate."
18
+ parameter :code, description: "Ruby source to evaluate."
17
19
 
18
20
  attr_reader :answer
19
21
 
@@ -29,11 +31,17 @@ module Omakase
29
31
 
30
32
  def name = "ruby"
31
33
 
34
+ # RubyLLM 2 tools cannot end the loop, so the strategy asks this between steps.
35
+ def done? = !@answer.nil? || @calls > @budget + 1
36
+
32
37
  def execute(code:)
38
+ code = code.strip[FENCE, 1] || code
33
39
  # Nothing bounds the provider's tool loop, so the budget does.
34
40
  @calls += 1
35
41
  return "No tool calls left — answer with what you have." if @calls == @budget + 1
36
- return halt("Tool budget spent.") if @calls > @budget + 1
42
+ return "Tool budget spent." if @calls > @budget + 1
43
+ # A later call in the same round must not act on an agent that has answered.
44
+ return "Answer already accepted." if @answer
37
45
 
38
46
  outcome = @executor.call(@agent, code, timeout: @timeout)
39
47
  Omakase.emit(:ruby, agent: @agent, code:, outcome:)
@@ -41,8 +49,8 @@ module Omakase
41
49
  # The seam's contract, checked here so a wrong executor cannot reach the model.
42
50
  raise Error, "executor must return a String or Executor::Answer, got #{outcome.class}" unless outcome.is_a?(Executor::Answer)
43
51
 
44
- @answer = Executor::Answer.new(value: @schema.take(outcome.value))
45
- halt("Answer accepted.")
52
+ @answer = Executor::Answer.new(value: @schema.take(outcome.value), printed: outcome.printed)
53
+ "Answer accepted."
46
54
  rescue ContractError => e
47
55
  # Off-contract answers are corrected inside the same loop, not by another request.
48
56
  # Anything else — a broken executor, a bad configuration — is not the model's to fix.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omakase
4
+ # The listener, printed for a human: `Omakase.listener = Omakase::Trace.new`.
5
+ # A run reads top to bottom — the call, the code the model wrote, the answer.
6
+ # Colour when the stream is a terminal, plain when it is a log.
7
+ class Trace
8
+ COLOURS = {generation: 36, ruby: 33, answer: 32, error: 31, mcp: 31}.freeze
9
+ LIMIT = 800
10
+
11
+ def initialize(io: $stderr)
12
+ @io = io
13
+ @colour = io.respond_to?(:tty?) && io.tty?
14
+ end
15
+
16
+ def call(event, agent:, **payload)
17
+ head, body = case event
18
+ when :generation then ["→ #{agent.class}##{payload[:name]}", inputs(payload[:inputs])]
19
+ when :ruby then ["· ruby", "#{payload[:code].strip}\n#{outcome(payload[:outcome])}"]
20
+ when :answer then ["← #{agent.class}##{payload[:name]}", truncate(payload[:value].inspect)]
21
+ when :error then ["✗ #{agent.class}##{payload[:name]}", truncate("#{payload[:error].class}: #{payload[:error].message}")]
22
+ # A down sidecar is not an error the run raises, so nothing else would say it.
23
+ when :mcp then ["! #{agent} mcp #{payload[:name]}", truncate(payload[:error].message)]
24
+ else return # a listener that raises takes the run down with it
25
+ end
26
+
27
+ # A generation called from generated code sits inside its caller's.
28
+ indent = " " * [(Thread.current[Agent::RUNNING]&.size || 1) - 1, 0].max
29
+ @io.puts(indent + paint(event, head))
30
+ @io.puts(body.gsub(/^/, "#{indent} ")) unless body.empty?
31
+ end
32
+
33
+ private
34
+
35
+ def inputs(inputs) = inputs.map { |name, value| "#{name}: #{truncate(value.inspect)}" }.join("\n")
36
+
37
+ # An Answer is `finish(value)` ending the run — with anything printed before it,
38
+ # which the tool result never carries because the run is over. Otherwise the
39
+ # outcome is already the text the model reads.
40
+ def outcome(outcome)
41
+ return truncate(outcome.to_s) unless outcome.is_a?(Executor::Answer)
42
+
43
+ truncate([outcome.printed, "finish #{outcome.value.inspect}"].reject(&:empty?).join("\n"))
44
+ end
45
+
46
+ def truncate(text) = (text.length > LIMIT) ? "#{text[0, LIMIT]}…" : text
47
+
48
+ def paint(event, text) = @colour ? "\e[#{COLOURS.fetch(event)}m#{text}\e[0m" : text
49
+ end
50
+ end
data/lib/omakase/type.rb CHANGED
@@ -20,9 +20,9 @@ module Omakase
20
20
  def code_only? = true
21
21
 
22
22
  def take(value)
23
- return value if value.is_a?(@klass)
23
+ raise ContractError, "expected #{describe}, got #{value.class}" unless value.is_a?(@klass)
24
24
 
25
- raise ContractError, "expected #{describe}, got #{value.class}"
25
+ well_formed(value)
26
26
  end
27
27
 
28
28
  def definition
@@ -30,5 +30,18 @@ module Omakase
30
30
  end
31
31
 
32
32
  alias_method :json, :definition
33
+
34
+ private
35
+
36
+ # An object that can say whether it is well-formed gets asked — ActiveModel,
37
+ # ActiveRecord, anything of that shape. The refusal reaches the model as an
38
+ # observation, so your own validations are what it has to satisfy, and they
39
+ # stay where you wrote them instead of being retyped into a prompt.
40
+ def well_formed(value)
41
+ return value unless value.respond_to?(:valid?) && value.respond_to?(:errors)
42
+ return value if value.valid?
43
+
44
+ raise ContractError, "#{@klass} is invalid: #{value.errors.full_messages.join("; ")}"
45
+ end
33
46
  end
34
47
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Omakase
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/omakase.rb CHANGED
@@ -21,6 +21,8 @@ module Omakase
21
21
  ContractError = Class.new(Error)
22
22
  # The model or its provider failed. RubyLLM has already retried what it retries.
23
23
  ProviderError = Class.new(Error)
24
+ # What RubyLLM.chat itself takes; every other option is a `with_*` call on the chat.
25
+ CHAT_ARGUMENTS = %i[model provider protocol assume_model_exists context].freeze
24
26
 
25
27
  class << self
26
28
  # Providers, keys, default model, timeouts, logging — all of it is RubyLLM's.
@@ -45,7 +47,8 @@ module Omakase
45
47
  end
46
48
 
47
49
  # Where generated code runs. Anything answering `call(agent, code, timeout:)`
48
- # will do — swap in a subprocess or a container to get real isolation.
50
+ # will do — Executor::Subprocess is the reference: a child process, so a
51
+ # timeout cannot take this one with it.
49
52
  def executor=(executor)
50
53
  @executor = callable!(executor, "executor")
51
54
  end
@@ -60,6 +63,35 @@ module Omakase
60
63
 
61
64
  def embedder = @embedder ||= ->(text) { RubyLLM.embed(text).vectors }
62
65
 
66
+ # How an agent gets a chat when none was injected. Anything answering
67
+ # `call(**options)` will do — one line in test_helper.rb keeps a whole suite
68
+ # off the network, including the class-level calls a job makes.
69
+ def chat_factory=(factory)
70
+ @chat_factory = callable!(factory, "chat_factory")
71
+ end
72
+
73
+ def chat_factory = @chat_factory ||= method(:build_chat)
74
+
75
+ # `caching: true`, `thinking: {effort: :high}`, `temperature: 0.2` become the
76
+ # chat's own with_* calls. Caching is on unless you say `caching: false`: the
77
+ # tool loop resends the whole chat on every step.
78
+ def build_chat(**options)
79
+ chat = RubyLLM.chat(**options.slice(*CHAT_ARGUMENTS))
80
+ {caching: true, **options.except(*CHAT_ARGUMENTS)}.each do |name, value|
81
+ setter = :"with_#{name}"
82
+ raise Error, "unknown chat option #{name}: RubyLLM::Chat has no ##{setter}" unless chat.respond_to?(setter)
83
+
84
+ keywords = chat.method(setter).parameters.any? { |kind, _| kind == :keyrest }
85
+ case value
86
+ when true then chat.public_send(setter)
87
+ when Array then chat.public_send(setter, *value)
88
+ when Hash then keywords ? chat.public_send(setter, **value) : chat.public_send(setter, value)
89
+ else chat.public_send(setter, value)
90
+ end
91
+ end
92
+ chat
93
+ end
94
+
63
95
  # Every step, as it happens: a generation starts, model-written code runs,
64
96
  # an answer lands. Anything answering `call(event, **payload)` will do —
65
97
  # a logger, a tracer, a test. Nil, the default, costs nothing.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omakase-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - eugeny
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 2026-09-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.16'
19
+ version: '2.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.16'
26
+ version: '2.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: schematist
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -79,10 +79,12 @@ files:
79
79
  - lib/omakase/request.rb
80
80
  - lib/omakase/schema.rb
81
81
  - lib/omakase/skills.rb
82
+ - lib/omakase/skills/how_to_act/SKILL.md
82
83
  - lib/omakase/strategies.rb
83
84
  - lib/omakase/strategies/code_act.rb
84
85
  - lib/omakase/strategies/predict.rb
85
86
  - lib/omakase/tools/ruby.rb
87
+ - lib/omakase/trace.rb
86
88
  - lib/omakase/type.rb
87
89
  - lib/omakase/version.rb
88
90
  homepage: https://github.com/esshka/omakase