Module: Omakase::Executor

Defined in:
lib/omakase/executor.rb

Overview

Runs model-written Ruby in the agent's own context. ponytail: instance_eval is not a sandbox — Subprocess isolates a crash, not File.

Defined Under Namespace

Modules: Subprocess Classes: Answer

Constant Summary collapse

SOURCE =
"(generated)"
RESULT =
:omakase_result
OUTPUT =
:omakase_output
TIMEOUT =
30
TRACE =
/\A#{Regexp.escape(SOURCE)}:\d+/
MAX_OUTPUT =
4_000

Class Method Summary collapse

Class Method Details

.call(agent, code, timeout: TIMEOUT) ⇒ Object



23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/omakase/executor.rb', line 23

def call(agent, code, timeout: TIMEOUT)
  printed = StringIO.new
  answer = catch(RESULT) do
    value = capturing(printed) { Timeout.timeout(timeout) { evaluate(agent, code) } }
    return observation([printed.string.chomp, "=> #{value.inspect}"])
  end
  Answer.new(value: answer, printed: printed.string.chomp)
rescue ScriptError, StandardError => e
  observation([printed.string.chomp, failure(e, code)])
rescue SystemExit
  # In process, exit would end the host. exit! cannot be caught: Subprocess covers that.
  observation([printed.string.chomp, "exit is not allowed — answer with finish(value)"])
end

.capturing(io) ⇒ Object

Thread-local, so concurrent agents never share a buffer. Agent#puts reads it.



63
64
65
66
67
68
69
# File 'lib/omakase/executor.rb', line 63

def capturing(io)
  previous = Thread.current[OUTPUT]
  Thread.current[OUTPUT] = io
  yield
ensure
  Thread.current[OUTPUT] = previous
end

.evaluate(agent, code) ⇒ Object

Inside a generation the inputs are locals; outside one, plain instance_eval.



38
39
40
41
# File 'lib/omakase/executor.rb', line 38

def evaluate(agent, code)
  scope = agent.respond_to?(:omakase_scope, true) && agent.send(:omakase_scope)
  scope ? scope.eval(code, SOURCE, 1) : agent.instance_eval(code, SOURCE, 1)
end

.failure(error, code) ⇒ Object

The model can only fix what it can locate, so point at the line. A SyntaxError has no generated frame to point from.



45
46
47
48
49
50
51
# File 'lib/omakase/executor.rb', line 45

def failure(error, code)
  message = "#{error.class}: #{error.message}"
  line = error.backtrace.grep(TRACE).first&.slice(/:(\d+)/, 1)&.to_i
  return message unless line

  "#{message}\nline #{line}: #{code.lines[line - 1].to_s.strip}"
end

.observation(parts) ⇒ Object

Head and tail: the error or the value comes last, and it is what the model needs next.



54
55
56
57
58
59
60
# File 'lib/omakase/executor.rb', line 54

def observation(parts)
  text = parts.reject(&:empty?).join("\n")
  return text if text.length <= MAX_OUTPUT

  half = MAX_OUTPUT / 2
  "#{text[0, half]}\n… (#{text.length - MAX_OUTPUT} characters truncated)\n#{text[-half..]}"
end