Class: Agentilda::Executor

Inherits:
Object
  • Object
show all
Defined in:
lib/agentilda/executor.rb

Overview

Runs one agent against one plan by shelling out to the claude CLI.

The autonomy boundary is "docs plus code, but nothing leaves the machine", and it is enforced twice over:

BEFORE — the agent is told, and `--disallowedTools` withholds the tools
       that would let it push.
AFTER  — the harness checks that HEAD did not move and no new remote ref
       appeared. A prompt is a request; a check is a guarantee, and only
       one of them survives a model deciding it knows better.

Defined Under Namespace

Classes: Aborted, Result

Constant Summary collapse

DENIED_TOOLS =

Tools no agent may use under this autonomy level, whatever its definition asks for. Git itself is reachable through Bash, which is why the after-check exists as well.

The exception is an agent that declares network: true. Closed is the right default — most specialists here read the repository and write to it, and a model that decides to go looking online mid-task is a model doing something nobody asked for. But a researcher inverts that: reading the internet is the entire job, and denying it silently produced an agent that ran, found nothing, and reported success.

%w[WebFetch WebSearch].freeze
FORBIDDEN_COMMANDS =

Commands that leave the machine, denied to every agent by default.

These are passed to claude as Bash(<command>:*) tool specifiers, so they are withheld rather than merely discouraged. This list spent a while as a regular expression that nothing referenced — a guard in the shape of a constant, enforcing nothing — which is exactly how gh pr review came to be reachable by an agent nobody had granted it to.

[
  "git push", "git commit",
  "gh pr create", "gh pr edit", "gh pr merge",
  "gh pr review", "gh pr comment",
  "gh release create"
].freeze
UNGRANTABLE =

The subset no agent's may: can lift, however its definition is written.

Pushing and merging change a branch everybody else builds on, and an unattended loop doing either has no way to be wrong quietly. Reviewing does not: an approval is reversible, visible, and attributable to the identity that made it. That difference is the whole line between hansolo-reviewer approving and hansolo-reviewer merging.

["git push", "gh pr merge"].freeze
STDOUT_SECTION =

The stdout: and stderr: sections of a TTY::Command::ExitError message. stdout runs until stderr starts; stderr runs to the end, because what an agent prints there is not guaranteed to be one line.

/^[ \t]*stdout:[ \t]*(.*?)(?=\n[ \t]*stderr:|\z)/m
STDERR_SECTION =
/^[ \t]*stderr:[ \t]*(.*)\z/m
REASON_LIMIT =

How much of what the agent said survives into a one-line report.

300
TRACE_DIR =

Where the raw stream of each invocation is kept.

Under run -j several agents work at once, and more than one agentilda may be driving the same checkout, so a trace is named per invocation rather than shared. To get an agent's final answer back out of one afterwards:

jq -r 'select(.type=="result").result' <trace>

and to replay what it did, tool call by tool call:

jq -r 'select(.type=="assistant")
     | .message.content[]?
     | select(.type=="tool_use")
     | "\(.name) \(.input|tostring[0:80])"' <trace>

Outside the repository on purpose: the harness checks afterwards that the agent moved nothing it should not have, and a megabyte of NDJSON dropped into the working tree is exactly the kind of thing that check would then have to learn to ignore.

File.join(Dir.tmpdir, "agentilda-traces")
CREDENTIAL_VARS =

Environment variables the claude CLI reads as credentials, in preference to a claude.ai login.

A project .env that sets one of these for the application's own use reaches every agent a run spawns. claude then authenticates with that key rather than the login, and a stale or unrelated one turns an entire run into 401 API key is invalid, three minutes per agent. The CLI warns rather than unsets. Driving it with an API key on purpose is legitimate, and nothing here can tell the two apart.

%w[ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN].freeze
CHILDREN_MUTEX =

Guards claim_child's registry: under -j several invocations spawn at once, and two of them finding the same fresh child would put one pid on two spinner lines.

Mutex.new

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(root:, command: TTY::Command.new(printer: :null), timeout: 900, dry_run: false, trace_dir: TRACE_DIR, instructions: nil, model: nil, max_tokens: nil, interactive: false) ⇒ Executor

Returns a new instance of Executor.

Parameters:

  • root (String)

    the repository the agents work in

  • command (TTY::Command) (defaults to: TTY::Command.new(printer: :null))
  • timeout (Integer) (defaults to: 900)

    seconds before one agent is abandoned

  • dry_run (Boolean) (defaults to: false)

    plan the invocation, do not run it

  • trace_dir (String) (defaults to: TRACE_DIR)

    where each invocation's raw stream is kept

  • instructions (String, nil) (defaults to: nil)

    what run --prompt typed, appended to the agent's own prompt. The command only accepts it alongside --agent, so exactly one agent ever hears it.

  • model (String, nil) (defaults to: nil)

    what run --model typed. The flag actually typed beats what an agent's frontmatter declares, the same precedence every other flag here follows; nil leaves each agent its own choice.

  • max_tokens (Integer, nil) (defaults to: nil)

    budget per invocation, input plus output, sub-agents included. The prompt states it so the agent can plan to finish inside it, and the meter enforces it so the statement is true. nil is unmetered.

  • interactive (Boolean) (defaults to: false)

    whether someone is at the keyboard. Only then does each invocation get a control file, because a prompt that says "poll this file" when nothing will ever write to it is asking for wasted reads all run long.



248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/agentilda/executor.rb', line 248

def initialize(root:, command: TTY::Command.new(printer: :null), timeout: 900, dry_run: false,
  trace_dir: TRACE_DIR, instructions: nil, model: nil, max_tokens: nil, interactive: false)
  @root = File.expand_path(root)
  @command = command
  @timeout = timeout
  @dry_run = dry_run
  @trace_dir = trace_dir
  @instructions = instructions.to_s.strip
  @model = model
  @max_tokens = max_tokens
  @interactive = interactive
end

Instance Attribute Details

#rootString (readonly)

Returns:

  • (String)


262
263
264
# File 'lib/agentilda/executor.rb', line 262

def root
  @root
end

Class Method Details

.claim_child(parent: Process.pid, listing: nil) ⇒ Integer?

The pid of a claude child this process spawned and nobody has claimed yet, so a spinner line can name the process it is narrating.

TTY::Command never exposes the pid it spawned, so this reads the process table instead: direct children of this process whose command is claude. With several invocations racing, first-come order cannot say which child belongs to which caller — a claimed pid might in principle label a sibling's line — which is why the pid decorates the UI and is never used to signal or kill anything.

Parameters:

  • parent (Integer) (defaults to: Process.pid)
  • listing (String, nil) (defaults to: nil)

    ps output, injectable for the suite

Returns:

  • (Integer, nil)

    nil when no unclaimed child is found



151
152
153
154
155
156
157
158
159
160
161
# File 'lib/agentilda/executor.rb', line 151

def self.claim_child(parent: Process.pid, listing: nil)
  listing ||= `ps -ax -o pid=,ppid=,command= 2>/dev/null`
  CHILDREN_MUTEX.synchronize do
    pid = listing.lines.filter_map { |line|
      child, ppid, command = line.strip.split(/\s+/, 3)
      child.to_i if ppid.to_i == parent && command.to_s.match?(%r{(\A|/)claude(\s|\z)})
    }.find { |candidate| !@claimed_children.include?(candidate) }
    @claimed_children << pid if pid
    pid
  end
end

.failure_reason(error) ⇒ String

What claude said, out of the four labelled sections TTY::Command::ExitError builds its message from.

The first of those sections is the command line, which for an agent is a shell-escaped copy of its several-thousand-character prompt. Reporting it said that an invocation had failed, at length, and nothing at all about why. The run that found this printed the same escaped prompt ten times while the answer, 401 API key is invalid, sat unread in stdout:.

This keeps both streams, because they carry different halves. claude reports its own failures on stdout; the line naming the cause of that 401 (ANTHROPIC_API_KEY … takes precedence over your claude.ai login) was on stderr.

Parameters:

  • error (TTY::Command::ExitError)

Returns:

  • (String)


188
189
190
191
192
193
194
195
196
# File 'lib/agentilda/executor.rb', line 188

def self.failure_reason(error)
  status = error.message[/^[ \t]*exit status:[ \t]*(\S+)/, 1]
  outcome = status ? "exited #{status}" : "failed"
  said = [STDOUT_SECTION, STDERR_SECTION]
    .filter_map { |section| tail(error.message[section, 1]) }
    .join(" | ")

  said.empty? ? "#{outcome} and said nothing" : "#{outcome}: #{said}"
end

.foreign_credentials(env = ENV) ⇒ Array<String>

Returns credential variables currently set.

Parameters:

  • env (Hash) (defaults to: ENV)

Returns:

  • (Array<String>)

    credential variables currently set



128
129
130
# File 'lib/agentilda/executor.rb', line 128

def self.foreign_credentials(env = ENV)
  CREDENTIAL_VARS.reject { |name| env[name].to_s.strip.empty? }
end

.release_child(pid) ⇒ void

This method returns an undefined value.

Forget a finished invocation's pid, so the registry does not grow for the life of a long run and a recycled pid stays claimable.

Parameters:

  • pid (Integer, nil)


168
169
170
# File 'lib/agentilda/executor.rb', line 168

def self.release_child(pid)
  CHILDREN_MUTEX.synchronize { @claimed_children.delete(pid) } if pid
end

Instance Method Details

#call(agent, subject, root: @root) {|progress| ... } ⇒ Agentilda::Executor::Result

Returns whether it worked, a one-line note, and what it spent. Destructures as ok, note for callers that want no more than that.

Parameters:

Yield Parameters:

Returns:

  • (Agentilda::Executor::Result)

    whether it worked, a one-line note, and what it spent. Destructures as ok, note for callers that want no more than that.



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/agentilda/executor.rb', line 281

def call(agent, subject, root: @root, &on_progress)
  started = UI.monotonic
  if @dry_run
    return Result.new(ok: true, note: "dry run — would invoke #{agent.name}", up: 0, down: 0,
      subagents: 0, delegated: 0, seconds: 0.0)
  end

  before = head(root)
  trace = trace_path(agent, subject)
  transcript = Transcript.new(trace:, &on_progress)
  control = (Control.register(@trace_dir, "#{subject.feature.ordinal}-#{agent.name}") if @interactive)

  timeout = timeout_for(agent)
  begin
    hunted = false
    @command.run(*invocation(agent, subject, root:, control:), timeout:) do |out, _err|
      # Once, on the first chunk: the child exists by the time it has
      # produced output, and a `ps` per chunk would be a `ps` per token.
      unless hunted
        hunted = true
        transcript.pid = self.class.claim_child
      end
      transcript.push(out)
      abort_if_over(transcript)
    end
    transcript.finish
  rescue Aborted => e
    transcript.finish
    return failure(transcript, started, "aborted: #{e.message} — trace: #{trace}")
  rescue TTY::Command::TimeoutExceeded
    transcript.finish
    return failure(transcript, started,
      "timed out after #{timeout}s, last seen #{transcript.activity || "starting up"} — trace: #{trace}")
  rescue TTY::Command::ExitError => e
    transcript.finish
    return failure(transcript, started, "claude #{reason_for(e, transcript)} — trace: #{trace}")
  ensure
    Control.release(control) if control
    self.class.release_child(transcript.pid)
  end

  if transcript.failed?
    return failure(transcript, started, "claude reported: #{transcript.error} — trace: #{trace}")
  end

  violation = boundary_violation(before, root)
  return failure(transcript, started, violation) if violation

  spent(transcript, started, ok: true,
    note: "completed#{" · #{transcript.tools} tool calls" if transcript.tools.positive?}")
end

#denied_commands(agent) ⇒ Array<String>

Returns commands withheld from this agent.

Parameters:

Returns:

  • (Array<String>)

    commands withheld from this agent



390
# File 'lib/agentilda/executor.rb', line 390

def denied_commands(agent) = FORBIDDEN_COMMANDS - granted_to(agent)

#denied_for(agent) ⇒ Array<String>

What this particular agent may not touch: the network tools unless it asked for them, plus every forbidden command it has not been granted. Both decisions are recorded in a reviewable file rather than passed as a flag by whoever happened to start the run.

Parameters:

Returns:

  • (Array<String>)


383
384
385
386
# File 'lib/agentilda/executor.rb', line 383

def denied_for(agent)
  tools = agent.network ? [] : DENIED_TOOLS
  tools + denied_commands(agent).map { |command| "Bash(#{command}:*)" }
end

#failure(transcript, started, note) ⇒ Agentilda::Executor::Result

A failed invocation still spent what it spent, and a run that burned two hundred thousand tokens before timing out is a different fact from one that failed to authenticate and spent nothing. Both used to report the same thing.

Parameters:

Returns:



342
# File 'lib/agentilda/executor.rb', line 342

def failure(transcript, started, note) = spent(transcript, started, ok: false, note:)

#granted_to(agent) ⇒ Array<String>

Returns what its may: actually buys it.

Parameters:

Returns:

  • (Array<String>)

    what its may: actually buys it



394
# File 'lib/agentilda/executor.rb', line 394

def granted_to(agent) = agent.may - UNGRANTABLE

#invocation(agent, subject, root: @root, control: nil) ⇒ Array<String>

The exact argv, exposed so a spec can assert the boundary flags without running anything.

Parameters:

Returns:

  • (Array<String>)


361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/agentilda/executor.rb', line 361

def invocation(agent, subject, root: @root, control: nil)
  # `--include-partial-messages` is what the token meter runs on. Without
  # it the stream reports a settled input count and a placeholder output
  # count — 2 for a four-thousand-token answer — and a spinner counting
  # what came back would read zero all run. See {Transcript#meter}.
  argv = ["claude", "-p", prompt_for(agent, subject, root, control:), "--add-dir", root,
    "--output-format", "stream-json", "--verbose", "--include-partial-messages"]
  denied = denied_for(agent)
  argv += ["--disallowedTools", denied.join(",")] unless denied.empty?
  argv += ["--allowedTools", agent.allowed_tools.join(",")] unless agent.allowed_tools.empty?
  model = @model || agent.model
  argv += ["--model", model] if model
  argv
end

#reason_for(error, transcript) ⇒ String

What went wrong, preferring what the stream managed to parse.

claude reports its own failures two different ways. A run that got far enough emits a result event saying so, and that is the readable one. A run that failed before it started — the 401 that cost a whole round three minutes an agent — prints prose on stdout and never emits an event at all, so those lines are what Transcript#plain holds and what is left to report. failure_reason stays the last resort, for a failure that printed nothing either way.

Parameters:

Returns:

  • (String)


222
223
224
225
226
227
# File 'lib/agentilda/executor.rb', line 222

def reason_for(error, transcript)
  return "failed: #{transcript.error}" if transcript.failed?

  said = transcript.plain.last(3).join(" ")
  said.empty? ? self.class.failure_reason(error) : "failed: #{said}"
end

#spent(transcript, started, ok:, note:) ⇒ Agentilda::Executor::Result

Parameters:

Returns:



349
350
351
352
353
# File 'lib/agentilda/executor.rb', line 349

def spent(transcript, started, ok:, note:)
  Result.new(ok:, note:, up: transcript.up, down: transcript.down,
    subagents: transcript.spawned, delegated: transcript.delegated,
    seconds: UI.monotonic - started)
end

#timeout_for(agent) ⇒ Integer

The seconds this agent gets before it is abandoned: its own timeout: frontmatter when it declares one, the run-wide default otherwise. Public so the UI can count the same clock down that this class will enforce — two clocks is how a timer hits zero and the agent keeps running.

Parameters:

Returns:

  • (Integer)


272
# File 'lib/agentilda/executor.rb', line 272

def timeout_for(agent) = agent.timeout || @timeout