Class: Agentilda::Executor
- Inherits:
-
Object
- Object
- Agentilda::Executor
- 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
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
claudeasBash(<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 howgh pr reviewcame 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-reviewerapproving andhansolo-reviewermerging. ["git push", "gh pr merge"].freeze
- STDOUT_SECTION =
The
stdout:andstderr: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 -jseveral agents work at once, and more than oneagentildamay 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
claudeCLI reads as credentials, in preference to a claude.ai login.A project
.envthat sets one of these for the application's own use reaches every agent a run spawns.claudethen authenticates with that key rather than the login, and a stale or unrelated one turns an entire run into401 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
-jseveral 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
- #root ⇒ String readonly
Class Method Summary collapse
-
.claim_child(parent: Process.pid, listing: nil) ⇒ Integer?
The pid of a
claudechild this process spawned and nobody has claimed yet, so a spinner line can name the process it is narrating. -
.failure_reason(error) ⇒ String
What
claudesaid, out of the four labelled sections TTY::Command::ExitError builds its message from. -
.foreign_credentials(env = ENV) ⇒ Array<String>
Credential variables currently set.
-
.release_child(pid) ⇒ void
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.
Instance Method Summary collapse
-
#call(agent, subject, root: @root) {|progress| ... } ⇒ Agentilda::Executor::Result
Whether it worked, a one-line note, and what it spent.
-
#denied_commands(agent) ⇒ Array<String>
Commands withheld from this 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.
-
#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.
-
#granted_to(agent) ⇒ Array<String>
What its
may:actually buys it. -
#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
constructor
A new instance of Executor.
-
#invocation(agent, subject, root: @root, control: nil) ⇒ Array<String>
The exact argv, exposed so a spec can assert the boundary flags without running anything.
-
#reason_for(error, transcript) ⇒ String
What went wrong, preferring what the stream managed to parse.
- #spent(transcript, started, ok:, note:) ⇒ Agentilda::Executor::Result
-
#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.
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.
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.(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
#root ⇒ String (readonly)
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.
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.
188 189 190 191 192 193 194 195 196 |
# File 'lib/agentilda/executor.rb', line 188 def self.failure_reason(error) status = error.[/^[ \t]*exit status:[ \t]*(\S+)/, 1] outcome = status ? "exited #{status}" : "failed" said = [STDOUT_SECTION, STDERR_SECTION] .filter_map { |section| tail(error.[section, 1]) } .join(" | ") said.empty? ? "#{outcome} and said nothing" : "#{outcome}: #{said}" end |
.foreign_credentials(env = ENV) ⇒ Array<String>
Returns 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.
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.
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.} — 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.
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.
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.
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.
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.
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.
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
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.
272 |
# File 'lib/agentilda/executor.rb', line 272 def timeout_for(agent) = agent.timeout || @timeout |