agentilda 1.0.3
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.
- checksums.yaml +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- metadata +349 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Runs one agent against one plan by shelling out to the `claude` CLI.
|
|
5
|
+
#
|
|
6
|
+
# The autonomy boundary is "docs plus code, but nothing leaves the machine",
|
|
7
|
+
# and it is enforced twice over:
|
|
8
|
+
#
|
|
9
|
+
# BEFORE — the agent is told, and `--disallowedTools` withholds the tools
|
|
10
|
+
# that would let it push.
|
|
11
|
+
# AFTER — the harness checks that HEAD did not move and no new remote ref
|
|
12
|
+
# appeared. A prompt is a request; a check is a guarantee, and only
|
|
13
|
+
# one of them survives a model deciding it knows better.
|
|
14
|
+
class Executor
|
|
15
|
+
# Raised from inside the streaming block to stop an invocation
|
|
16
|
+
# mid-flight — the token budget crossed, or the grace period after `q`
|
|
17
|
+
# run out. TTY::Command's reader thread re-raises it out of `run`, and
|
|
18
|
+
# its `ensure` terminates the child on the way, so raising here is how
|
|
19
|
+
# the child is killed rather than merely abandoned.
|
|
20
|
+
class Aborted < StandardError; end
|
|
21
|
+
|
|
22
|
+
# What one invocation did, and what it spent doing it.
|
|
23
|
+
#
|
|
24
|
+
# {#to_ary} is deliberate: every caller of {Executor#call} destructures
|
|
25
|
+
# `ok, note = executor.call(...)`, and the meter is an addition to that
|
|
26
|
+
# answer rather than a replacement for it. Callers that want the tokens
|
|
27
|
+
# ask for them by name.
|
|
28
|
+
#
|
|
29
|
+
# @!attribute [r] ok
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
# @!attribute [r] note
|
|
32
|
+
# @return [String] one line, for the report
|
|
33
|
+
# @!attribute [r] up
|
|
34
|
+
# @return [Integer] tokens sent, sub-agents included
|
|
35
|
+
# @!attribute [r] down
|
|
36
|
+
# @return [Integer] tokens generated
|
|
37
|
+
# @!attribute [r] subagents
|
|
38
|
+
# @return [Integer] sub-agents this agent spawned
|
|
39
|
+
# @!attribute [r] delegated
|
|
40
|
+
# @return [Integer] of {#up}, how much arrived as an unsplit sub-agent
|
|
41
|
+
# total rather than as a direction of its own
|
|
42
|
+
# @!attribute [r] seconds
|
|
43
|
+
# @return [Float] wall clock, from argv to exit
|
|
44
|
+
Result = Data.define(:ok, :note, :up, :down, :subagents, :delegated, :seconds) do
|
|
45
|
+
# @return [Array(Boolean, String)]
|
|
46
|
+
def to_ary = [ok, note]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Tools no agent may use under this autonomy level, whatever its definition
|
|
50
|
+
# asks for. Git itself is reachable through Bash, which is why the
|
|
51
|
+
# after-check exists as well.
|
|
52
|
+
#
|
|
53
|
+
# The exception is an agent that declares `network: true`. Closed is the
|
|
54
|
+
# right default — most specialists here read the repository and write to
|
|
55
|
+
# it, and a model that decides to go looking online mid-task is a model
|
|
56
|
+
# doing something nobody asked for. But a researcher inverts that: reading
|
|
57
|
+
# the internet is the entire job, and denying it silently produced an
|
|
58
|
+
# agent that ran, found nothing, and reported success.
|
|
59
|
+
DENIED_TOOLS = %w[WebFetch WebSearch].freeze
|
|
60
|
+
|
|
61
|
+
# Commands that leave the machine, denied to every agent by default.
|
|
62
|
+
#
|
|
63
|
+
# These are passed to `claude` as `Bash(<command>:*)` tool specifiers, so
|
|
64
|
+
# they are withheld rather than merely discouraged. This list spent a while
|
|
65
|
+
# as a regular expression that nothing referenced — a guard in the shape of
|
|
66
|
+
# a constant, enforcing nothing — which is exactly how `gh pr review` came
|
|
67
|
+
# to be reachable by an agent nobody had granted it to.
|
|
68
|
+
FORBIDDEN_COMMANDS = [
|
|
69
|
+
"git push", "git commit",
|
|
70
|
+
"gh pr create", "gh pr edit", "gh pr merge",
|
|
71
|
+
"gh pr review", "gh pr comment",
|
|
72
|
+
"gh release create"
|
|
73
|
+
].freeze
|
|
74
|
+
|
|
75
|
+
# The subset no agent's `may:` can lift, however its definition is written.
|
|
76
|
+
#
|
|
77
|
+
# Pushing and merging change a branch everybody else builds on, and an
|
|
78
|
+
# unattended loop doing either has no way to be wrong quietly. Reviewing
|
|
79
|
+
# does not: an approval is reversible, visible, and attributable to the
|
|
80
|
+
# identity that made it. That difference is the whole line between
|
|
81
|
+
# `hansolo-reviewer` approving and `hansolo-reviewer` merging.
|
|
82
|
+
UNGRANTABLE = ["git push", "gh pr merge"].freeze
|
|
83
|
+
|
|
84
|
+
# The `stdout:` and `stderr:` sections of a {TTY::Command::ExitError}
|
|
85
|
+
# message. stdout runs until stderr starts; stderr runs to the end, because
|
|
86
|
+
# what an agent prints there is not guaranteed to be one line.
|
|
87
|
+
STDOUT_SECTION = /^[ \t]*stdout:[ \t]*(.*?)(?=\n[ \t]*stderr:|\z)/m
|
|
88
|
+
STDERR_SECTION = /^[ \t]*stderr:[ \t]*(.*)\z/m
|
|
89
|
+
|
|
90
|
+
# How much of what the agent said survives into a one-line report.
|
|
91
|
+
REASON_LIMIT = 300
|
|
92
|
+
|
|
93
|
+
# Where the raw stream of each invocation is kept.
|
|
94
|
+
#
|
|
95
|
+
# Under `run -j` several agents work at once, and more than one
|
|
96
|
+
# `agentilda` may be driving the same checkout, so a trace is named
|
|
97
|
+
# per invocation rather than shared. To get an agent's final answer back
|
|
98
|
+
# out of one afterwards:
|
|
99
|
+
#
|
|
100
|
+
# jq -r 'select(.type=="result").result' <trace>
|
|
101
|
+
#
|
|
102
|
+
# and to replay what it did, tool call by tool call:
|
|
103
|
+
#
|
|
104
|
+
# jq -r 'select(.type=="assistant")
|
|
105
|
+
# | .message.content[]?
|
|
106
|
+
# | select(.type=="tool_use")
|
|
107
|
+
# | "\(.name) \(.input|tostring[0:80])"' <trace>
|
|
108
|
+
#
|
|
109
|
+
# Outside the repository on purpose: the harness checks afterwards that the
|
|
110
|
+
# agent moved nothing it should not have, and a megabyte of NDJSON dropped
|
|
111
|
+
# into the working tree is exactly the kind of thing that check would then
|
|
112
|
+
# have to learn to ignore.
|
|
113
|
+
TRACE_DIR = File.join(Dir.tmpdir, "agentilda-traces")
|
|
114
|
+
|
|
115
|
+
# Environment variables the `claude` CLI reads as credentials, in
|
|
116
|
+
# preference to a claude.ai login.
|
|
117
|
+
#
|
|
118
|
+
# A project `.env` that sets one of these for the application's own use
|
|
119
|
+
# reaches every agent a run spawns. `claude` then authenticates with that
|
|
120
|
+
# key rather than the login, and a stale or unrelated one turns an entire
|
|
121
|
+
# run into `401 API key is invalid`, three minutes per agent. The CLI warns
|
|
122
|
+
# rather than unsets. Driving it with an API key on purpose is legitimate,
|
|
123
|
+
# and nothing here can tell the two apart.
|
|
124
|
+
CREDENTIAL_VARS = %w[ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN].freeze
|
|
125
|
+
|
|
126
|
+
# @param env [Hash]
|
|
127
|
+
# @return [Array<String>] credential variables currently set
|
|
128
|
+
def self.foreign_credentials(env = ENV)
|
|
129
|
+
CREDENTIAL_VARS.reject { |name| env[name].to_s.strip.empty? }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Guards {.claim_child}'s registry: under `-j` several invocations spawn
|
|
133
|
+
# at once, and two of them finding the same fresh child would put one pid
|
|
134
|
+
# on two spinner lines.
|
|
135
|
+
CHILDREN_MUTEX = Mutex.new
|
|
136
|
+
@claimed_children = []
|
|
137
|
+
|
|
138
|
+
# The pid of a `claude` child this process spawned and nobody has claimed
|
|
139
|
+
# yet, so a spinner line can name the process it is narrating.
|
|
140
|
+
#
|
|
141
|
+
# TTY::Command never exposes the pid it spawned, so this reads the
|
|
142
|
+
# process table instead: direct children of this process whose command is
|
|
143
|
+
# `claude`. With several invocations racing, first-come order cannot say
|
|
144
|
+
# which child belongs to which caller — a claimed pid might in principle
|
|
145
|
+
# label a sibling's line — which is why the pid decorates the UI and is
|
|
146
|
+
# never used to signal or kill anything.
|
|
147
|
+
#
|
|
148
|
+
# @param parent [Integer]
|
|
149
|
+
# @param listing [String, nil] `ps` output, injectable for the suite
|
|
150
|
+
# @return [Integer, nil] nil when no unclaimed child is found
|
|
151
|
+
def self.claim_child(parent: Process.pid, listing: nil)
|
|
152
|
+
listing ||= `ps -ax -o pid=,ppid=,command= 2>/dev/null`
|
|
153
|
+
CHILDREN_MUTEX.synchronize do
|
|
154
|
+
pid = listing.lines.filter_map { |line|
|
|
155
|
+
child, ppid, command = line.strip.split(/\s+/, 3)
|
|
156
|
+
child.to_i if ppid.to_i == parent && command.to_s.match?(%r{(\A|/)claude(\s|\z)})
|
|
157
|
+
}.find { |candidate| !@claimed_children.include?(candidate) }
|
|
158
|
+
@claimed_children << pid if pid
|
|
159
|
+
pid
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Forget a finished invocation's pid, so the registry does not grow for
|
|
164
|
+
# the life of a long run and a recycled pid stays claimable.
|
|
165
|
+
#
|
|
166
|
+
# @param pid [Integer, nil]
|
|
167
|
+
# @return [void]
|
|
168
|
+
def self.release_child(pid)
|
|
169
|
+
CHILDREN_MUTEX.synchronize { @claimed_children.delete(pid) } if pid
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# What `claude` said, out of the four labelled sections
|
|
173
|
+
# {TTY::Command::ExitError} builds its message from.
|
|
174
|
+
#
|
|
175
|
+
# The first of those sections is the command line, which for an agent is a
|
|
176
|
+
# shell-escaped copy of its several-thousand-character prompt. Reporting it
|
|
177
|
+
# said that an invocation had failed, at length, and nothing at all about
|
|
178
|
+
# why. The run that found this printed the same escaped prompt ten times
|
|
179
|
+
# while the answer, `401 API key is invalid`, sat unread in `stdout:`.
|
|
180
|
+
#
|
|
181
|
+
# This keeps both streams, because they carry different halves. `claude`
|
|
182
|
+
# reports its own failures on stdout; the line naming the *cause* of that
|
|
183
|
+
# 401 (`ANTHROPIC_API_KEY … takes precedence over your claude.ai login`)
|
|
184
|
+
# was on stderr.
|
|
185
|
+
#
|
|
186
|
+
# @param error [TTY::Command::ExitError]
|
|
187
|
+
# @return [String]
|
|
188
|
+
def self.failure_reason(error)
|
|
189
|
+
status = error.message[/^[ \t]*exit status:[ \t]*(\S+)/, 1]
|
|
190
|
+
outcome = status ? "exited #{status}" : "failed"
|
|
191
|
+
said = [STDOUT_SECTION, STDERR_SECTION]
|
|
192
|
+
.filter_map { |section| tail(error.message[section, 1]) }
|
|
193
|
+
.join(" | ")
|
|
194
|
+
|
|
195
|
+
said.empty? ? "#{outcome} and said nothing" : "#{outcome}: #{said}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @param text [String, nil]
|
|
199
|
+
# @return [String, nil] the last few meaningful lines, on one line
|
|
200
|
+
def self.tail(text)
|
|
201
|
+
lines = text.to_s.split("\n").map(&:strip).reject { |line| line.empty? || line == "Nothing written" }
|
|
202
|
+
return nil if lines.empty?
|
|
203
|
+
|
|
204
|
+
joined = lines.last(3).join(" ")
|
|
205
|
+
(joined.length > REASON_LIMIT) ? "#{joined[0, REASON_LIMIT - 1]}…" : joined
|
|
206
|
+
end
|
|
207
|
+
private_class_method :tail
|
|
208
|
+
|
|
209
|
+
# What went wrong, preferring what the stream managed to parse.
|
|
210
|
+
#
|
|
211
|
+
# `claude` reports its own failures two different ways. A run that got far
|
|
212
|
+
# enough emits a `result` event saying so, and that is the readable one. A
|
|
213
|
+
# run that failed before it started — the 401 that cost a whole round three
|
|
214
|
+
# minutes an agent — prints prose on stdout and never emits an event at
|
|
215
|
+
# all, so those lines are what {Transcript#plain} holds and what is left to
|
|
216
|
+
# report. {.failure_reason} stays the last resort, for a failure that
|
|
217
|
+
# printed nothing either way.
|
|
218
|
+
#
|
|
219
|
+
# @param error [TTY::Command::ExitError]
|
|
220
|
+
# @param transcript [Agentilda::Transcript]
|
|
221
|
+
# @return [String]
|
|
222
|
+
def reason_for(error, transcript)
|
|
223
|
+
return "failed: #{transcript.error}" if transcript.failed?
|
|
224
|
+
|
|
225
|
+
said = transcript.plain.last(3).join(" ")
|
|
226
|
+
said.empty? ? self.class.failure_reason(error) : "failed: #{said}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @param root [String] the repository the agents work in
|
|
230
|
+
# @param command [TTY::Command]
|
|
231
|
+
# @param timeout [Integer] seconds before one agent is abandoned
|
|
232
|
+
# @param dry_run [Boolean] plan the invocation, do not run it
|
|
233
|
+
# @param trace_dir [String] where each invocation's raw stream is kept
|
|
234
|
+
# @param instructions [String, nil] what `run --prompt` typed, appended
|
|
235
|
+
# to the agent's own prompt. The command only accepts it alongside
|
|
236
|
+
# `--agent`, so exactly one agent ever hears it.
|
|
237
|
+
# @param model [String, nil] what `run --model` typed. The flag actually
|
|
238
|
+
# typed beats what an agent's frontmatter declares, the same precedence
|
|
239
|
+
# every other flag here follows; nil leaves each agent its own choice.
|
|
240
|
+
# @param max_tokens [Integer, nil] budget per invocation, input plus
|
|
241
|
+
# output, sub-agents included. The prompt states it so the agent can
|
|
242
|
+
# plan to finish inside it, and the meter enforces it so the statement
|
|
243
|
+
# is true. nil is unmetered.
|
|
244
|
+
# @param interactive [Boolean] whether someone is at the keyboard. Only
|
|
245
|
+
# then does each invocation get a control file, because a prompt that
|
|
246
|
+
# says "poll this file" when nothing will ever write to it is asking
|
|
247
|
+
# for wasted reads all run long.
|
|
248
|
+
def initialize(root:, command: TTY::Command.new(printer: :null), timeout: 900, dry_run: false,
|
|
249
|
+
trace_dir: TRACE_DIR, instructions: nil, model: nil, max_tokens: nil, interactive: false)
|
|
250
|
+
@root = File.expand_path(root)
|
|
251
|
+
@command = command
|
|
252
|
+
@timeout = timeout
|
|
253
|
+
@dry_run = dry_run
|
|
254
|
+
@trace_dir = trace_dir
|
|
255
|
+
@instructions = instructions.to_s.strip
|
|
256
|
+
@model = model
|
|
257
|
+
@max_tokens = max_tokens
|
|
258
|
+
@interactive = interactive
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# @return [String]
|
|
262
|
+
attr_reader :root
|
|
263
|
+
|
|
264
|
+
# The seconds this agent gets before it is abandoned: its own
|
|
265
|
+
# `timeout:` frontmatter when it declares one, the run-wide default
|
|
266
|
+
# otherwise. Public so the UI can count the same clock down that this
|
|
267
|
+
# class will enforce — two clocks is how a timer hits zero and the
|
|
268
|
+
# agent keeps running.
|
|
269
|
+
#
|
|
270
|
+
# @param agent [Agentilda::Agent]
|
|
271
|
+
# @return [Integer]
|
|
272
|
+
def timeout_for(agent) = agent.timeout || @timeout
|
|
273
|
+
|
|
274
|
+
# @param agent [Agentilda::Agent]
|
|
275
|
+
# @param subject [Agentilda::Subject]
|
|
276
|
+
# @return [Agentilda::Executor::Result] whether it worked, a one-line
|
|
277
|
+
# note, and what it spent. Destructures as `ok, note` for callers that
|
|
278
|
+
# want no more than that.
|
|
279
|
+
# @yieldparam progress [Agentilda::Transcript::Progress] what the
|
|
280
|
+
# agent is doing and what it has spent, as both change
|
|
281
|
+
def call(agent, subject, root: @root, &on_progress)
|
|
282
|
+
started = UI.monotonic
|
|
283
|
+
if @dry_run
|
|
284
|
+
return Result.new(ok: true, note: "dry run — would invoke #{agent.name}", up: 0, down: 0,
|
|
285
|
+
subagents: 0, delegated: 0, seconds: 0.0)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
before = head(root)
|
|
289
|
+
trace = trace_path(agent, subject)
|
|
290
|
+
transcript = Transcript.new(trace:, &on_progress)
|
|
291
|
+
control = (Control.register(@trace_dir, "#{subject.feature.ordinal}-#{agent.name}") if @interactive)
|
|
292
|
+
|
|
293
|
+
timeout = timeout_for(agent)
|
|
294
|
+
begin
|
|
295
|
+
hunted = false
|
|
296
|
+
@command.run(*invocation(agent, subject, root:, control:), timeout:) do |out, _err|
|
|
297
|
+
# Once, on the first chunk: the child exists by the time it has
|
|
298
|
+
# produced output, and a `ps` per chunk would be a `ps` per token.
|
|
299
|
+
unless hunted
|
|
300
|
+
hunted = true
|
|
301
|
+
transcript.pid = self.class.claim_child
|
|
302
|
+
end
|
|
303
|
+
transcript.push(out)
|
|
304
|
+
abort_if_over(transcript)
|
|
305
|
+
end
|
|
306
|
+
transcript.finish
|
|
307
|
+
rescue Aborted => e
|
|
308
|
+
transcript.finish
|
|
309
|
+
return failure(transcript, started, "aborted: #{e.message} — trace: #{trace}")
|
|
310
|
+
rescue TTY::Command::TimeoutExceeded
|
|
311
|
+
transcript.finish
|
|
312
|
+
return failure(transcript, started,
|
|
313
|
+
"timed out after #{timeout}s, last seen #{transcript.activity || "starting up"} — trace: #{trace}")
|
|
314
|
+
rescue TTY::Command::ExitError => e
|
|
315
|
+
transcript.finish
|
|
316
|
+
return failure(transcript, started, "claude #{reason_for(e, transcript)} — trace: #{trace}")
|
|
317
|
+
ensure
|
|
318
|
+
Control.release(control) if control
|
|
319
|
+
self.class.release_child(transcript.pid)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
if transcript.failed?
|
|
323
|
+
return failure(transcript, started, "claude reported: #{transcript.error} — trace: #{trace}")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
violation = boundary_violation(before, root)
|
|
327
|
+
return failure(transcript, started, violation) if violation
|
|
328
|
+
|
|
329
|
+
spent(transcript, started, ok: true,
|
|
330
|
+
note: "completed#{" · #{transcript.tools} tool calls" if transcript.tools.positive?}")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# A failed invocation still spent what it spent, and a run that burned two
|
|
334
|
+
# hundred thousand tokens before timing out is a different fact from one
|
|
335
|
+
# that failed to authenticate and spent nothing. Both used to report the
|
|
336
|
+
# same thing.
|
|
337
|
+
#
|
|
338
|
+
# @param transcript [Agentilda::Transcript]
|
|
339
|
+
# @param started [Float]
|
|
340
|
+
# @param note [String]
|
|
341
|
+
# @return [Agentilda::Executor::Result]
|
|
342
|
+
def failure(transcript, started, note) = spent(transcript, started, ok: false, note:)
|
|
343
|
+
|
|
344
|
+
# @param transcript [Agentilda::Transcript]
|
|
345
|
+
# @param started [Float]
|
|
346
|
+
# @param ok [Boolean]
|
|
347
|
+
# @param note [String]
|
|
348
|
+
# @return [Agentilda::Executor::Result]
|
|
349
|
+
def spent(transcript, started, ok:, note:)
|
|
350
|
+
Result.new(ok:, note:, up: transcript.up, down: transcript.down,
|
|
351
|
+
subagents: transcript.spawned, delegated: transcript.delegated,
|
|
352
|
+
seconds: UI.monotonic - started)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The exact argv, exposed so a spec can assert the boundary flags without
|
|
356
|
+
# running anything.
|
|
357
|
+
#
|
|
358
|
+
# @param agent [Agentilda::Agent]
|
|
359
|
+
# @param subject [Agentilda::Subject]
|
|
360
|
+
# @return [Array<String>]
|
|
361
|
+
def invocation(agent, subject, root: @root, control: nil)
|
|
362
|
+
# `--include-partial-messages` is what the token meter runs on. Without
|
|
363
|
+
# it the stream reports a settled input count and a placeholder output
|
|
364
|
+
# count — 2 for a four-thousand-token answer — and a spinner counting
|
|
365
|
+
# what came back would read zero all run. See {Transcript#meter}.
|
|
366
|
+
argv = ["claude", "-p", prompt_for(agent, subject, root, control:), "--add-dir", root,
|
|
367
|
+
"--output-format", "stream-json", "--verbose", "--include-partial-messages"]
|
|
368
|
+
denied = denied_for(agent)
|
|
369
|
+
argv += ["--disallowedTools", denied.join(",")] unless denied.empty?
|
|
370
|
+
argv += ["--allowedTools", agent.allowed_tools.join(",")] unless agent.allowed_tools.empty?
|
|
371
|
+
model = @model || agent.model
|
|
372
|
+
argv += ["--model", model] if model
|
|
373
|
+
argv
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# What this particular agent may not touch: the network tools unless it
|
|
377
|
+
# asked for them, plus every forbidden command it has not been granted.
|
|
378
|
+
# Both decisions are recorded in a reviewable file rather than passed as a
|
|
379
|
+
# flag by whoever happened to start the run.
|
|
380
|
+
#
|
|
381
|
+
# @param agent [Agentilda::Agent]
|
|
382
|
+
# @return [Array<String>]
|
|
383
|
+
def denied_for(agent)
|
|
384
|
+
tools = agent.network ? [] : DENIED_TOOLS
|
|
385
|
+
tools + denied_commands(agent).map { |command| "Bash(#{command}:*)" }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# @param agent [Agentilda::Agent]
|
|
389
|
+
# @return [Array<String>] commands withheld from this agent
|
|
390
|
+
def denied_commands(agent) = FORBIDDEN_COMMANDS - granted_to(agent)
|
|
391
|
+
|
|
392
|
+
# @param agent [Agentilda::Agent]
|
|
393
|
+
# @return [Array<String>] what its `may:` actually buys it
|
|
394
|
+
def granted_to(agent) = agent.may - UNGRANTABLE
|
|
395
|
+
|
|
396
|
+
private
|
|
397
|
+
|
|
398
|
+
# One file per invocation. The plan number and the agent's name make it
|
|
399
|
+
# findable; the pid and the clock keep two concurrent runs from writing to
|
|
400
|
+
# the same one. See {TRACE_DIR} for how to read one back.
|
|
401
|
+
#
|
|
402
|
+
# @param agent [Agentilda::Agent]
|
|
403
|
+
# @param subject [Agentilda::Subject]
|
|
404
|
+
# @return [String]
|
|
405
|
+
def trace_path(agent, subject)
|
|
406
|
+
FileUtils.mkdir_p(@trace_dir)
|
|
407
|
+
name = format("%s-%s-%s-%d-%04x.ndjson", Time.now.strftime("%Y%m%d-%H%M%S"),
|
|
408
|
+
subject.feature.ordinal, agent.name, Process.pid, rand(0x10000))
|
|
409
|
+
File.join(@trace_dir, name)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# @param agent [Agentilda::Agent]
|
|
413
|
+
# @param subject [Agentilda::Subject]
|
|
414
|
+
# @return [String]
|
|
415
|
+
def prompt_for(agent, subject, root = @root, control: nil)
|
|
416
|
+
<<~PROMPT
|
|
417
|
+
#{agent.prompt}
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## This invocation
|
|
422
|
+
|
|
423
|
+
Plan folder : #{subject.feature.path}
|
|
424
|
+
Plan number : #{subject.feature.ordinal}
|
|
425
|
+
Current state : #{subject.status.emoji} #{subject.status.label}
|
|
426
|
+
Repository root: #{root}
|
|
427
|
+
|
|
428
|
+
#{"The folder's name is not currently justified: #{subject.violation}" if subject.violation}
|
|
429
|
+
#{operator_instructions}#{budget_section}#{control_section(control)}
|
|
430
|
+
## Boundary — enforced, not requested
|
|
431
|
+
|
|
432
|
+
You may read anything, and write source, tests and the plan's own
|
|
433
|
+
markdown.
|
|
434
|
+
|
|
435
|
+
These are withheld from you, not merely discouraged — `claude` is
|
|
436
|
+
invoked with them disallowed:
|
|
437
|
+
|
|
438
|
+
#{denied_commands(agent).map { |c| " #{c}" }.join("\n")}
|
|
439
|
+
#{granted(agent)}
|
|
440
|
+
The harness checks afterwards that HEAD has not moved, and a round that
|
|
441
|
+
moved it is reported as a failure and rolled into the report. A prompt
|
|
442
|
+
is a request; a check is a guarantee.
|
|
443
|
+
|
|
444
|
+
Claim what you are about to write with ~/.claude/agent-lock.sh first,
|
|
445
|
+
and release it when you are done.
|
|
446
|
+
PROMPT
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# The budget crossed, or the grace period after `q` spent — checked as
|
|
450
|
+
# each chunk of output arrives, which is as often as an agent can be
|
|
451
|
+
# observed at all. An agent producing nothing is the timeout's problem.
|
|
452
|
+
#
|
|
453
|
+
# @param transcript [Agentilda::Transcript]
|
|
454
|
+
# @return [void]
|
|
455
|
+
# @raise [Agentilda::Executor::Aborted]
|
|
456
|
+
def abort_if_over(transcript)
|
|
457
|
+
spent = transcript.up + transcript.down
|
|
458
|
+
if @max_tokens&.positive? && spent > @max_tokens
|
|
459
|
+
raise Aborted, "token budget of #{@max_tokens} exceeded (↑#{transcript.up} ↓#{transcript.down})"
|
|
460
|
+
end
|
|
461
|
+
raise Aborted, "still running #{Control::GRACE}s after q" if Control.overdue?
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# The section `run --max-tokens` adds. Stating the number is what lets
|
|
465
|
+
# the agent finish before it, rather than discovering the cap by dying
|
|
466
|
+
# on it with half a file written.
|
|
467
|
+
#
|
|
468
|
+
# @return [String]
|
|
469
|
+
def budget_section
|
|
470
|
+
return "" unless @max_tokens&.positive?
|
|
471
|
+
|
|
472
|
+
"\n## Token budget — #{@max_tokens} tokens, enforced\n\n" \
|
|
473
|
+
"This invocation is aborted once its total spend (input plus output, " \
|
|
474
|
+
"sub-agents included) crosses #{@max_tokens} tokens. Budget the work: " \
|
|
475
|
+
"plan what fits, write results to disk as you go, and finish — or " \
|
|
476
|
+
"write a handoff note into the plan folder — before the meter runs " \
|
|
477
|
+
"out. Anything unwritten at the cap is lost.\n"
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# The section a control file adds, present only when someone is at the
|
|
481
|
+
# keyboard to write into it.
|
|
482
|
+
#
|
|
483
|
+
# @param control [String, nil]
|
|
484
|
+
# @return [String]
|
|
485
|
+
def control_section(control)
|
|
486
|
+
return "" if control.nil?
|
|
487
|
+
|
|
488
|
+
"\n## Control file — poll it between steps\n\n" \
|
|
489
|
+
" #{control}\n\n" \
|
|
490
|
+
"Read this file before each significant step. Empty means carry on. " \
|
|
491
|
+
"A line saying WRAP_UP means finish the essential remainder as fast " \
|
|
492
|
+
"as possible. STOP means write what you have to disk, note where you " \
|
|
493
|
+
"stopped in the plan folder's markdown, and end your turn now.\n"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# The section `run --prompt` adds, labelled as coming from the person who
|
|
497
|
+
# started the run so the agent can tell a one-off steer from its own
|
|
498
|
+
# standing definition.
|
|
499
|
+
#
|
|
500
|
+
# @return [String]
|
|
501
|
+
def operator_instructions
|
|
502
|
+
return "" if @instructions.empty?
|
|
503
|
+
|
|
504
|
+
"\n## Operator instructions — this invocation only\n\n#{@instructions}\n"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Spelled out in the prompt as well as withheld at the tool layer, because
|
|
508
|
+
# an agent that does not know it has been granted something does not use
|
|
509
|
+
# it — and `hansolo-reviewer` silently never approving anything looks
|
|
510
|
+
# exactly like `hansolo-reviewer` approving nothing worth approving.
|
|
511
|
+
#
|
|
512
|
+
# @param agent [Agentilda::Agent]
|
|
513
|
+
# @return [String]
|
|
514
|
+
def granted(agent)
|
|
515
|
+
granted = granted_to(agent)
|
|
516
|
+
return "" if granted.empty?
|
|
517
|
+
|
|
518
|
+
"\nYou may run these, which most agents may not:\n\n" +
|
|
519
|
+
granted.map { |c| " #{c}" }.join("\n") + "\n"
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# @return [String, nil] current commit, or nil outside a repository
|
|
523
|
+
def head(root = @root)
|
|
524
|
+
out = `git -C #{root.shellescape} rev-parse HEAD 2>/dev/null`.strip
|
|
525
|
+
out.empty? ? nil : out
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# @param before [String, nil]
|
|
529
|
+
# @return [String, nil] what boundary was crossed, or nil
|
|
530
|
+
def boundary_violation(before, root = @root)
|
|
531
|
+
return nil if before.nil?
|
|
532
|
+
|
|
533
|
+
after = head(root)
|
|
534
|
+
return "agent committed (HEAD moved #{before[0, 7]} → #{after[0, 7]}) — the boundary is docs plus code, no commits" if after != before
|
|
535
|
+
|
|
536
|
+
nil
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|