agentilda 1.0.3 → 2.0.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.
- checksums.yaml +4 -4
- data/agentilda.gemspec +8 -6
- data/agents/hansolo-reviewer.md +42 -13
- data/agents/lando-broker.md +40 -46
- data/agents/leah-researcher.md +36 -58
- data/agents/luke-backend.md +49 -61
- data/agents/palpatine-planner.md +39 -21
- data/agents/rey-frontend.md +44 -86
- data/agents/yoda-writer.md +38 -36
- data/bin/setup +26 -0
- data/exe/agentilda +13 -92
- data/exe/tilda-rs +0 -0
- data/lib/agentilda/adoption.rb +7 -2
- data/lib/agentilda/agent.rb +40 -99
- data/lib/agentilda/agents.rb +122 -0
- data/lib/agentilda/board.rb +91 -0
- data/lib/agentilda/brief.rb +54 -5
- data/lib/agentilda/child.rb +78 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +22 -6
- data/lib/agentilda/cli/create/create.rb +46 -19
- data/lib/agentilda/cli/docs/docs.rb +6 -2
- data/lib/agentilda/cli/index/index.rb +6 -4
- data/lib/agentilda/cli/linear/linear.rb +3 -2
- data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
- data/lib/agentilda/cli/mail/mail.rb +31 -0
- data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
- data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
- data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
- data/lib/agentilda/cli/run/run.rb +207 -99
- data/lib/agentilda/cli/unblock/unblock.rb +24 -13
- data/lib/agentilda/cli/worktree/worktree.rb +85 -0
- data/lib/agentilda/cli.rb +43 -0
- data/lib/agentilda/clock.rb +114 -0
- data/lib/agentilda/console.rb +157 -0
- data/lib/agentilda/control.rb +63 -0
- data/lib/agentilda/creator.rb +7 -2
- data/lib/agentilda/dashboard.rb +219 -0
- data/lib/agentilda/dev_work.rb +2 -0
- data/lib/agentilda/diagram.rb +12 -7
- data/lib/agentilda/dispatcher.rb +646 -0
- data/lib/agentilda/documentation.rb +14 -6
- data/lib/agentilda/executor.rb +362 -204
- data/lib/agentilda/feature.rb +14 -135
- data/lib/agentilda/frontmatter.rb +4 -2
- data/lib/agentilda/github.rb +19 -11
- data/lib/agentilda/index.rb +11 -11
- data/lib/agentilda/keyboard.rb +76 -8
- data/lib/agentilda/launcher.rb +69 -0
- data/lib/agentilda/ledger.rb +233 -0
- data/lib/agentilda/linear/api.rb +14 -10
- data/lib/agentilda/linear/attribution.rb +5 -5
- data/lib/agentilda/linear/import.rb +30 -14
- data/lib/agentilda/linear/issue.rb +6 -3
- data/lib/agentilda/linear/mapping.rb +18 -17
- data/lib/agentilda/linear/push.rb +19 -12
- data/lib/agentilda/linear/survey.rb +3 -2
- data/lib/agentilda/linear/unit.rb +12 -12
- data/lib/agentilda/linear.rb +5 -6
- data/lib/agentilda/mailbox.rb +141 -0
- data/lib/agentilda/markdown.rb +1 -1
- data/lib/agentilda/progress_log.rb +5 -1
- data/lib/agentilda/publisher.rb +6 -3
- data/lib/agentilda/pull_request.rb +0 -186
- data/lib/agentilda/pull_requests.rb +230 -0
- data/lib/agentilda/reporter.rb +9 -3
- data/lib/agentilda/resync.rb +120 -52
- data/lib/agentilda/roster.rb +31 -48
- data/lib/agentilda/runner.rb +72 -389
- data/lib/agentilda/screen/ratatui/bar.rb +57 -0
- data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
- data/lib/agentilda/screen/ratatui.rb +318 -0
- data/lib/agentilda/state_file.rb +174 -0
- data/lib/agentilda/state_machine.rb +60 -32
- data/lib/agentilda/status.rb +120 -37
- data/lib/agentilda/subject.rb +133 -0
- data/lib/agentilda/tally.rb +17 -11
- data/lib/agentilda/transcript.rb +47 -16
- data/lib/agentilda/tree.rb +10 -6
- data/lib/agentilda/ui.rb +204 -190
- data/lib/agentilda/unblocker.rb +25 -11
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda/worktree.rb +14 -18
- data/lib/agentilda.rb +57 -70
- metadata +61 -15
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -261
- data/bin/create-plan-folder +0 -125
- data/lib/dry/cli/banner.rb +0 -293
data/lib/agentilda/executor.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
3
7
|
module Agentilda
|
|
4
8
|
# Runs one agent against one plan by shelling out to the `claude` CLI.
|
|
5
9
|
#
|
|
@@ -12,13 +16,6 @@ module Agentilda
|
|
|
12
16
|
# appeared. A prompt is a request; a check is a guarantee, and only
|
|
13
17
|
# one of them survives a model deciding it knows better.
|
|
14
18
|
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
19
|
# What one invocation did, and what it spent doing it.
|
|
23
20
|
#
|
|
24
21
|
# {#to_ary} is deliberate: every caller of {Executor#call} destructures
|
|
@@ -41,11 +38,65 @@ module Agentilda
|
|
|
41
38
|
# total rather than as a direction of its own
|
|
42
39
|
# @!attribute [r] seconds
|
|
43
40
|
# @return [Float] wall clock, from argv to exit
|
|
44
|
-
|
|
41
|
+
# @!attribute [r] killed
|
|
42
|
+
# @return [Symbol, nil] :timeout when the clock's backstop fired, :key
|
|
43
|
+
# when somebody pressed k, :budget when the token meter crossed the
|
|
44
|
+
# cap, :quit when the grace period after q ran out, nil otherwise
|
|
45
|
+
# @!attribute [r] pid
|
|
46
|
+
# @return [Integer, nil]
|
|
47
|
+
# @return [Data]
|
|
48
|
+
Result = Data.define(:ok, :note, :up, :down, :subagents, :delegated, :seconds, :killed, :pid) do
|
|
49
|
+
def initialize(killed: nil, pid: nil, **rest) = super
|
|
50
|
+
|
|
45
51
|
# @return [Array(Boolean, String)]
|
|
46
52
|
def to_ary = [ok, note]
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
# What the dispatcher holds on a running invocation, so a keypress can
|
|
56
|
+
# reach it: the process, its clock and its control file.
|
|
57
|
+
#
|
|
58
|
+
# Filled in by {#call} once the child exists. Every method tolerates the
|
|
59
|
+
# gap before that, because the keyboard does not wait.
|
|
60
|
+
class Handle
|
|
61
|
+
# @return [Agentilda::Child, nil]
|
|
62
|
+
attr_accessor :child
|
|
63
|
+
|
|
64
|
+
# @return [Agentilda::Clock, nil]
|
|
65
|
+
attr_accessor :clock
|
|
66
|
+
|
|
67
|
+
# @return [String, nil]
|
|
68
|
+
attr_accessor :control
|
|
69
|
+
|
|
70
|
+
# @return [Symbol, nil] why the process was killed, once it was
|
|
71
|
+
attr_reader :killed
|
|
72
|
+
|
|
73
|
+
# @return [Integer, nil]
|
|
74
|
+
def pid = child&.pid
|
|
75
|
+
|
|
76
|
+
# @return [Integer, nil]
|
|
77
|
+
def remaining = clock&.remaining
|
|
78
|
+
|
|
79
|
+
# @return [Symbol, nil]
|
|
80
|
+
def phase = clock&.phase
|
|
81
|
+
|
|
82
|
+
# STOP, a grace period, then SIGKILL if it is still there. The grace is
|
|
83
|
+
# what lets an agent mid-write finish the line.
|
|
84
|
+
#
|
|
85
|
+
# @param grace [Integer] seconds
|
|
86
|
+
# @param reason [Symbol] :key, :timeout, :budget or :quit
|
|
87
|
+
# @return [void]
|
|
88
|
+
def kill!(grace: 15, reason: :key)
|
|
89
|
+
@killed ||= reason
|
|
90
|
+
Control.write(control, Control::STOP) if control
|
|
91
|
+
sleep(grace) if grace.positive?
|
|
92
|
+
child&.kill("KILL") if child&.alive?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param seconds [Integer]
|
|
96
|
+
# @return [void]
|
|
97
|
+
def extend!(seconds) = clock&.extend!(seconds)
|
|
98
|
+
end
|
|
99
|
+
|
|
49
100
|
# Tools no agent may use under this autonomy level, whatever its definition
|
|
50
101
|
# asks for. Git itself is reachable through Bash, which is why the
|
|
51
102
|
# after-check exists as well.
|
|
@@ -81,15 +132,12 @@ module Agentilda
|
|
|
81
132
|
# `hansolo-reviewer` approving and `hansolo-reviewer` merging.
|
|
82
133
|
UNGRANTABLE = ["git push", "gh pr merge"].freeze
|
|
83
134
|
|
|
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
135
|
# How much of what the agent said survives into a one-line report.
|
|
91
136
|
REASON_LIMIT = 300
|
|
92
137
|
|
|
138
|
+
# Seconds an agent gets when neither its frontmatter nor `--timeout` says.
|
|
139
|
+
DEFAULT_TIMEOUT = 900
|
|
140
|
+
|
|
93
141
|
# Where the raw stream of each invocation is kept.
|
|
94
142
|
#
|
|
95
143
|
# Under `run -j` several agents work at once, and more than one
|
|
@@ -129,106 +177,11 @@ module Agentilda
|
|
|
129
177
|
CREDENTIAL_VARS.reject { |name| env[name].to_s.strip.empty? }
|
|
130
178
|
end
|
|
131
179
|
|
|
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
180
|
# @param root [String] the repository the agents work in
|
|
230
|
-
# @param
|
|
231
|
-
#
|
|
181
|
+
# @param spawn [Proc] `argv, chdir:` → {Agentilda::Child}, swappable so the
|
|
182
|
+
# suite can play back a fake process without spawning one
|
|
183
|
+
# @param timeout [Integer, nil] `--timeout`: a cap on every agent's clock,
|
|
184
|
+
# or nil to let each agent's own frontmatter clock stand
|
|
232
185
|
# @param dry_run [Boolean] plan the invocation, do not run it
|
|
233
186
|
# @param trace_dir [String] where each invocation's raw stream is kept
|
|
234
187
|
# @param instructions [String, nil] what `run --prompt` typed, appended
|
|
@@ -241,14 +194,14 @@ module Agentilda
|
|
|
241
194
|
# output, sub-agents included. The prompt states it so the agent can
|
|
242
195
|
# plan to finish inside it, and the meter enforces it so the statement
|
|
243
196
|
# is true. nil is unmetered.
|
|
244
|
-
# @param interactive [Boolean] whether someone is at the keyboard
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
def initialize(root:,
|
|
197
|
+
# @param interactive [Boolean] whether someone is at the keyboard, which
|
|
198
|
+
# the keyboard help reads. Every invocation gets a control file either
|
|
199
|
+
# way, because the clock writes its own warnings into it regardless of
|
|
200
|
+
# whether anyone is watching.
|
|
201
|
+
def initialize(root:, spawn: Child.method(:spawn), timeout: nil, dry_run: false,
|
|
249
202
|
trace_dir: TRACE_DIR, instructions: nil, model: nil, max_tokens: nil, interactive: false)
|
|
250
203
|
@root = File.expand_path(root)
|
|
251
|
-
@
|
|
204
|
+
@spawn = spawn
|
|
252
205
|
@timeout = timeout
|
|
253
206
|
@dry_run = dry_run
|
|
254
207
|
@trace_dir = trace_dir
|
|
@@ -261,73 +214,89 @@ module Agentilda
|
|
|
261
214
|
# @return [String]
|
|
262
215
|
attr_reader :root
|
|
263
216
|
|
|
264
|
-
# The
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
217
|
+
# The clock this agent runs against: the tighter of its own frontmatter
|
|
218
|
+
# and `--timeout`. A flag that could only loosen was useless the day
|
|
219
|
+
# somebody wanted a quick run.
|
|
220
|
+
#
|
|
221
|
+
# {DEFAULT_TIMEOUT} steps in only when neither names a figure. It used
|
|
222
|
+
# to be the flag's default instead, which silently cut every 1200-second
|
|
223
|
+
# agent to 900 on the runs where nobody had typed `--timeout` at all.
|
|
269
224
|
#
|
|
270
225
|
# @param agent [Agentilda::Agent]
|
|
271
226
|
# @return [Integer]
|
|
272
|
-
def timeout_for(agent) = agent.timeout ||
|
|
227
|
+
def timeout_for(agent) = [agent.timeout, @timeout].compact.min || DEFAULT_TIMEOUT
|
|
273
228
|
|
|
274
229
|
# @param agent [Agentilda::Agent]
|
|
275
230
|
# @param subject [Agentilda::Subject]
|
|
276
|
-
# @
|
|
277
|
-
#
|
|
278
|
-
#
|
|
279
|
-
# @
|
|
280
|
-
#
|
|
281
|
-
|
|
231
|
+
# @param root [String] the checkout the agent works in
|
|
232
|
+
# @param round [Integer] which attempt on this plan this is
|
|
233
|
+
# @param successor [String, nil] who the agent names on its `next:` line
|
|
234
|
+
# @param handle [Agentilda::Executor::Handle, nil] filled in for the caller
|
|
235
|
+
# @param partners [Array<Agentilda::Agent>] the other agents working this
|
|
236
|
+
# plan right now, named in the prompt beside the plan's mailbox
|
|
237
|
+
# @return [Agentilda::Executor::Result]
|
|
238
|
+
# @yieldparam progress [Agentilda::Transcript::Progress]
|
|
239
|
+
def call(agent, subject, root: @root, round: 1, successor: nil, handle: nil, partners: [], &on_progress)
|
|
282
240
|
started = UI.monotonic
|
|
283
241
|
if @dry_run
|
|
284
|
-
return Result.new(ok: true,
|
|
285
|
-
|
|
242
|
+
return Result.new(ok: true,
|
|
243
|
+
note: "dry run - would invoke #{agent.name}",
|
|
244
|
+
up: 0,
|
|
245
|
+
down: 0,
|
|
246
|
+
subagents: 0,
|
|
247
|
+
delegated: 0,
|
|
248
|
+
seconds: 0.0)
|
|
286
249
|
end
|
|
287
250
|
|
|
251
|
+
handle ||= Handle.new
|
|
288
252
|
before = head(root)
|
|
289
253
|
trace = trace_path(agent, subject)
|
|
290
254
|
transcript = Transcript.new(trace:, &on_progress)
|
|
291
|
-
control =
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
transcript
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
255
|
+
control = Control.register(@trace_dir, "#{subject.feature.ordinal}-#{agent.name}")
|
|
256
|
+
handle.control = control
|
|
257
|
+
argv = invocation(agent, subject, root:, control:, round:, successor:, partners:)
|
|
258
|
+
|
|
259
|
+
child = @spawn.call(argv, chdir: root)
|
|
260
|
+
handle.child = child
|
|
261
|
+
transcript.pid = child.pid
|
|
262
|
+
clock = Clock.new(seconds: timeout_for(agent),
|
|
263
|
+
control:,
|
|
264
|
+
on_expire: -> { handle.kill!(grace: 0, reason: :timeout) })
|
|
265
|
+
handle.clock = clock
|
|
266
|
+
clock.start
|
|
267
|
+
|
|
268
|
+
child.each_chunk do |out|
|
|
269
|
+
transcript.push(out)
|
|
270
|
+
abort_if_over(transcript, handle)
|
|
271
|
+
end
|
|
272
|
+
status = child.wait
|
|
273
|
+
transcript.finish
|
|
274
|
+
clock.stop
|
|
275
|
+
Control.release(control)
|
|
276
|
+
|
|
277
|
+
if handle.killed
|
|
278
|
+
return spent(transcript,
|
|
279
|
+
started,
|
|
280
|
+
ok: false,
|
|
281
|
+
pid: child.pid,
|
|
282
|
+
killed: handle.killed,
|
|
283
|
+
note: "#{killed_note(handle.killed, clock)}, last seen #{transcript.activity || "starting up"} - trace: #{trace}")
|
|
284
|
+
end
|
|
285
|
+
unless status.success?
|
|
286
|
+
return failure(transcript, started, "claude exited #{status.exitstatus}: #{said(transcript)} - trace: #{trace}", pid: child.pid)
|
|
320
287
|
end
|
|
321
|
-
|
|
322
288
|
if transcript.failed?
|
|
323
|
-
return failure(transcript, started, "claude reported: #{transcript.error}
|
|
289
|
+
return failure(transcript, started, "claude reported: #{transcript.error} - trace: #{trace}", pid: child.pid)
|
|
324
290
|
end
|
|
325
291
|
|
|
326
292
|
violation = boundary_violation(before, root)
|
|
327
|
-
return failure(transcript, started, violation) if violation
|
|
293
|
+
return failure(transcript, started, violation, pid: child.pid) if violation
|
|
328
294
|
|
|
329
|
-
spent(transcript,
|
|
330
|
-
|
|
295
|
+
spent(transcript,
|
|
296
|
+
started,
|
|
297
|
+
ok: true,
|
|
298
|
+
pid: child.pid,
|
|
299
|
+
note: "completed#{" - #{transcript.tools} tool calls" if transcript.tools.positive?}")
|
|
331
300
|
end
|
|
332
301
|
|
|
333
302
|
# A failed invocation still spent what it spent, and a run that burned two
|
|
@@ -335,21 +304,41 @@ module Agentilda
|
|
|
335
304
|
# that failed to authenticate and spent nothing. Both used to report the
|
|
336
305
|
# same thing.
|
|
337
306
|
#
|
|
338
|
-
# @param transcript [Agentilda::Transcript]
|
|
339
|
-
# @param started [Float]
|
|
340
|
-
# @param note [String]
|
|
341
307
|
# @return [Agentilda::Executor::Result]
|
|
342
|
-
def failure(transcript, started, note) = spent(transcript, started, ok: false, note:)
|
|
308
|
+
def failure(transcript, started, note, pid: nil) = spent(transcript, started, ok: false, note:, pid:)
|
|
343
309
|
|
|
344
|
-
# @param transcript [Agentilda::Transcript]
|
|
345
|
-
# @param started [Float]
|
|
346
|
-
# @param ok [Boolean]
|
|
347
|
-
# @param note [String]
|
|
348
310
|
# @return [Agentilda::Executor::Result]
|
|
349
|
-
def spent(transcript, started, ok:, note:)
|
|
350
|
-
Result.new(ok:,
|
|
351
|
-
|
|
352
|
-
|
|
311
|
+
def spent(transcript, started, ok:, note:, pid: nil, killed: nil)
|
|
312
|
+
Result.new(ok:,
|
|
313
|
+
note:,
|
|
314
|
+
up: transcript.up,
|
|
315
|
+
down: transcript.down,
|
|
316
|
+
subagents: transcript.spawned,
|
|
317
|
+
delegated: transcript.delegated,
|
|
318
|
+
seconds: UI.monotonic - started,
|
|
319
|
+
killed:,
|
|
320
|
+
pid:)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# @param transcript [Agentilda::Transcript]
|
|
324
|
+
# @return [String] the last few plain lines, clipped
|
|
325
|
+
def said(transcript)
|
|
326
|
+
text = (transcript.failed? ? transcript.error : transcript.plain.last(3).join(" ")).to_s.strip
|
|
327
|
+
return "said nothing" if text.empty?
|
|
328
|
+
|
|
329
|
+
text.length > REASON_LIMIT ? "#{text[0, REASON_LIMIT - 1]}..." : text
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# @param reason [Symbol]
|
|
333
|
+
# @param clock [Agentilda::Clock]
|
|
334
|
+
# @return [String]
|
|
335
|
+
def killed_note(reason, _clock)
|
|
336
|
+
case reason
|
|
337
|
+
when :timeout then "timed out (killed #{Clock::GRACE}s after STOP)"
|
|
338
|
+
when :budget then "token budget of #{@max_tokens} exceeded"
|
|
339
|
+
when :quit then "still running after #{Control.interrupted? ? "ctrl-c" : "q"}, killed once the grace period ran out"
|
|
340
|
+
else "killed from the keyboard"
|
|
341
|
+
end
|
|
353
342
|
end
|
|
354
343
|
|
|
355
344
|
# The exact argv, exposed so a spec can assert the boundary flags without
|
|
@@ -357,19 +346,23 @@ module Agentilda
|
|
|
357
346
|
#
|
|
358
347
|
# @param agent [Agentilda::Agent]
|
|
359
348
|
# @param subject [Agentilda::Subject]
|
|
349
|
+
# @param round [Integer] which attempt on this plan this is
|
|
350
|
+
# @param successor [String, nil] who the agent names on its `next:` line
|
|
351
|
+
# @param partners [Array<Agentilda::Agent>]
|
|
360
352
|
# @return [Array<String>]
|
|
361
|
-
def invocation(agent, subject, root: @root, control: nil)
|
|
353
|
+
def invocation(agent, subject, root: @root, control: nil, round: 1, successor: nil, partners: [])
|
|
362
354
|
# `--include-partial-messages` is what the token meter runs on. Without
|
|
363
355
|
# it the stream reports a settled input count and a placeholder output
|
|
364
356
|
# count — 2 for a four-thousand-token answer — and a spinner counting
|
|
365
357
|
# 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
|
-
|
|
358
|
+
argv = ["claude", "-p", prompt_for(agent, subject, root, control:, round:, successor:, partners:), "--add-dir", root,
|
|
359
|
+
"--output-format", "stream-json", "--verbose", "--include-partial-messages", "--brief"]
|
|
368
360
|
denied = denied_for(agent)
|
|
369
361
|
argv += ["--disallowedTools", denied.join(",")] unless denied.empty?
|
|
370
362
|
argv += ["--allowedTools", agent.allowed_tools.join(",")] unless agent.allowed_tools.empty?
|
|
371
363
|
model = @model || agent.model
|
|
372
364
|
argv += ["--model", model] if model
|
|
365
|
+
argv += ["--effort", agent.effort] if agent.effort
|
|
373
366
|
argv
|
|
374
367
|
end
|
|
375
368
|
|
|
@@ -404,15 +397,22 @@ module Agentilda
|
|
|
404
397
|
# @return [String]
|
|
405
398
|
def trace_path(agent, subject)
|
|
406
399
|
FileUtils.mkdir_p(@trace_dir)
|
|
407
|
-
name = format("%s-%s-%s-%d-%04x.ndjson",
|
|
408
|
-
|
|
400
|
+
name = format("%s-%s-%s-%d-%04x.ndjson",
|
|
401
|
+
Time.now.strftime("%Y%m%d-%H%M%S"),
|
|
402
|
+
subject.feature.ordinal,
|
|
403
|
+
agent.name,
|
|
404
|
+
Process.pid,
|
|
405
|
+
rand(0x10000))
|
|
409
406
|
File.join(@trace_dir, name)
|
|
410
407
|
end
|
|
411
408
|
|
|
412
409
|
# @param agent [Agentilda::Agent]
|
|
413
410
|
# @param subject [Agentilda::Subject]
|
|
411
|
+
# @param round [Integer]
|
|
412
|
+
# @param successor [String, nil]
|
|
413
|
+
# @param partners [Array<Agentilda::Agent>]
|
|
414
414
|
# @return [String]
|
|
415
|
-
def prompt_for(agent, subject, root = @root, control: nil)
|
|
415
|
+
def prompt_for(agent, subject, root = @root, control: nil, round: 1, successor: nil, partners: [])
|
|
416
416
|
<<~PROMPT
|
|
417
417
|
#{agent.prompt}
|
|
418
418
|
|
|
@@ -426,7 +426,7 @@ module Agentilda
|
|
|
426
426
|
Repository root: #{root}
|
|
427
427
|
|
|
428
428
|
#{"The folder's name is not currently justified: #{subject.violation}" if subject.violation}
|
|
429
|
-
#{operator_instructions}#{budget_section}#{control_section(control)}
|
|
429
|
+
#{operator_instructions}#{ledger_section(agent, round:, successor:)}#{budget_section}#{time_budget_section(agent)}#{control_section(control, agent, subject)}#{mailbox_section(agent, subject, partners)}
|
|
430
430
|
## Boundary — enforced, not requested
|
|
431
431
|
|
|
432
432
|
You may read anything, and write source, tests and the plan's own
|
|
@@ -441,24 +441,70 @@ module Agentilda
|
|
|
441
441
|
moved it is reported as a failure and rolled into the report. A prompt
|
|
442
442
|
is a request; a check is a guarantee.
|
|
443
443
|
|
|
444
|
-
Claim
|
|
445
|
-
|
|
444
|
+
Claim each directory or file before you write it, and release it when
|
|
445
|
+
that write is done. Name yourself on every call, since `alo` would
|
|
446
|
+
otherwise sign with a fingerprint your sub-agents share; give each
|
|
447
|
+
sub-agent its own suffix, e.g. `AGENT_ID=#{agent.name}-schema`:
|
|
448
|
+
|
|
449
|
+
AGENT_ID=#{agent.name} alo acquire <path> "<why>"
|
|
450
|
+
AGENT_ID=#{agent.name} alo release <path>
|
|
451
|
+
|
|
452
|
+
A refused `acquire` means another agent holds it: work on something
|
|
453
|
+
else, never write it anyway. Before your closing ledger line, run
|
|
454
|
+
`AGENT_ID=#{agent.name} alo release-all`.
|
|
446
455
|
PROMPT
|
|
447
456
|
end
|
|
448
457
|
|
|
449
|
-
# The
|
|
450
|
-
#
|
|
451
|
-
#
|
|
458
|
+
# The one paragraph every agent gets, identically, about the ledger. It
|
|
459
|
+
# lives here rather than in seven definition files so the wording cannot
|
|
460
|
+
# drift between agents, and so the names, the round and the successor are
|
|
461
|
+
# the harness's facts rather than the agent's guesses.
|
|
462
|
+
#
|
|
463
|
+
# @param agent [Agentilda::Agent]
|
|
464
|
+
# @param round [Integer]
|
|
465
|
+
# @param successor [String, nil]
|
|
466
|
+
# @return [String]
|
|
467
|
+
def ledger_section(agent, round:, successor:)
|
|
468
|
+
documents = agent.ledger.map { |f| "`#{f}`" }.join(", then ")
|
|
469
|
+
handoff = successor ? "\n > [<now>] [ next: #{successor} ]" : ""
|
|
470
|
+
<<~SECTION
|
|
471
|
+
|
|
472
|
+
## The ledger - write this at the start and at the end
|
|
473
|
+
|
|
474
|
+
You sign the document you are working in: #{documents}. Get `<now>` from
|
|
475
|
+
`date "+%Y-%m-%d %I:%M:%S %p %Z"`. Before you do any work, append:
|
|
476
|
+
|
|
477
|
+
> [!NOTE]
|
|
478
|
+
>
|
|
479
|
+
> [<now>] [ agent: #{agent.name} status: Started, round #{round} ]
|
|
480
|
+
|
|
481
|
+
When you finish, append:
|
|
482
|
+
|
|
483
|
+
> [!NOTE]
|
|
484
|
+
>
|
|
485
|
+
> [<now>] [ agent: #{agent.name} status: Completed, round #{round} ]#{handoff}
|
|
486
|
+
|
|
487
|
+
Write `Completed` only if your assignment is genuinely done. Otherwise write
|
|
488
|
+
`Almost completed`, `Interrupted` or `Blocked` in its place and NO `next:` line.
|
|
489
|
+
The harness renames the plan folder and starts the next agent from these
|
|
490
|
+
lines; you never rename the folder yourself. A short note in parentheses
|
|
491
|
+
after the round is welcome, e.g. `Completed, round 1 (approved)`.
|
|
492
|
+
SECTION
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# The token budget crossed, or the grace period after `q` spent. Both go
|
|
496
|
+
# through the handle so the kill is the same kill a keypress makes.
|
|
452
497
|
#
|
|
453
498
|
# @param transcript [Agentilda::Transcript]
|
|
499
|
+
# @param handle [Agentilda::Executor::Handle]
|
|
454
500
|
# @return [void]
|
|
455
|
-
|
|
456
|
-
def abort_if_over(transcript)
|
|
501
|
+
def abort_if_over(transcript, handle)
|
|
457
502
|
spent = transcript.up + transcript.down
|
|
458
503
|
if @max_tokens&.positive? && spent > @max_tokens
|
|
459
|
-
|
|
504
|
+
handle.kill!(grace: 0, reason: :budget)
|
|
505
|
+
elsif Control.overdue?
|
|
506
|
+
handle.kill!(grace: 0, reason: :quit)
|
|
460
507
|
end
|
|
461
|
-
raise Aborted, "still running #{Control::GRACE}s after q" if Control.overdue?
|
|
462
508
|
end
|
|
463
509
|
|
|
464
510
|
# The section `run --max-tokens` adds. Stating the number is what lets
|
|
@@ -477,20 +523,132 @@ module Agentilda
|
|
|
477
523
|
"out. Anything unwritten at the cap is lost.\n"
|
|
478
524
|
end
|
|
479
525
|
|
|
480
|
-
# The section
|
|
481
|
-
#
|
|
526
|
+
# The section describing the advisory clock this invocation runs against,
|
|
527
|
+
# stated in the prompt so an agent can pace itself. The number is
|
|
528
|
+
# whatever {#timeout_for} will actually enforce, so the prompt and the
|
|
529
|
+
# clock can never disagree — an agent whose prose names its own figure
|
|
530
|
+
# goes stale the first time someone passes `--timeout`, and stale is
|
|
531
|
+
# worse than silent.
|
|
532
|
+
#
|
|
533
|
+
# @param agent [Agentilda::Agent]
|
|
534
|
+
# @return [String]
|
|
535
|
+
def time_budget_section(agent)
|
|
536
|
+
seconds = timeout_for(agent)
|
|
537
|
+
return "" unless seconds&.positive?
|
|
538
|
+
|
|
539
|
+
minutes = (seconds / 60.0).round
|
|
540
|
+
"\n## Time budget - #{seconds} seconds\n\n" \
|
|
541
|
+
"You have about #{minutes} minute#{"s" unless minutes == 1} of wall clock. The control " \
|
|
542
|
+
"file below tells you how it is going: `WARN: 10 minutes left`, `WARN: 5 minutes left`, " \
|
|
543
|
+
"`WRAP_UP: 1 minute left, write to disk now`, then `STOP`. Sixty seconds after STOP " \
|
|
544
|
+
"the process is killed, and anything unwritten is lost. Write each result to disk as " \
|
|
545
|
+
"you reach it, and write your closing ledger line before anything else once you see " \
|
|
546
|
+
"WRAP_UP.#{concurrency_advice(agent)}\n"
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Only worth saying to an agent that can actually do it. Telling an agent
|
|
550
|
+
# without `Task` to parallelise is telling it to feel bad about a tool it
|
|
551
|
+
# was not given.
|
|
552
|
+
#
|
|
553
|
+
# @param agent [Agentilda::Agent]
|
|
554
|
+
# @return [String]
|
|
555
|
+
def concurrency_advice(agent)
|
|
556
|
+
return "" unless agent.allowed_tools.include?("Task")
|
|
557
|
+
|
|
558
|
+
" Where the work divides into parts that do not read each other's output, run them " \
|
|
559
|
+
"as one wave of concurrent sub-agents rather than in series: the wave costs one " \
|
|
560
|
+
"part's wall clock, and the series costs the sum of all of them."
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Two agents on one plan are two processes. Naming the partner and the
|
|
564
|
+
# exact commands is what replaced guessing the partner's session from
|
|
565
|
+
# every Claude session on the machine.
|
|
566
|
+
#
|
|
567
|
+
# @param agent [Agentilda::Agent]
|
|
568
|
+
# @param subject [Agentilda::Subject]
|
|
569
|
+
# @param partners [Array<Agentilda::Agent>]
|
|
570
|
+
# @return [String]
|
|
571
|
+
def mailbox_section(agent, subject, partners)
|
|
572
|
+
return "" if partners.empty?
|
|
573
|
+
|
|
574
|
+
plans_dir = File.dirname(subject.feature.path)
|
|
575
|
+
plan = subject.feature.ordinal
|
|
576
|
+
names = partners.map { |p| "`#{p.name}`" }
|
|
577
|
+
who = names.size == 1 ? "Your partner on this plan is #{names.first}" : "Your partners on this plan are #{names.join(" and ")}"
|
|
578
|
+
|
|
579
|
+
<<~SECTION
|
|
580
|
+
|
|
581
|
+
## Mailbox - poll it between steps
|
|
582
|
+
|
|
583
|
+
#{who}. You share one worktree and one branch, but not a process, so
|
|
584
|
+
the only way to reach each other is the plan's mailbox:
|
|
585
|
+
|
|
586
|
+
#{File.join(subject.feature.path, Mailbox::FILENAME)}
|
|
587
|
+
|
|
588
|
+
Read it before each significant step and whenever you finish a unit:
|
|
589
|
+
|
|
590
|
+
agentilda mail read --dir "#{plans_dir}" --plan #{plan} --for #{agent.name}
|
|
591
|
+
|
|
592
|
+
Pass `--after N`, with the number of the last message you have read,
|
|
593
|
+
to see only what is new. Write to it when you land an interface your
|
|
594
|
+
partner is waiting on, when you amend the contract, when you need
|
|
595
|
+
something from their half, and when you finish:
|
|
596
|
+
|
|
597
|
+
agentilda mail send --dir "#{plans_dir}" --plan #{plan} --from #{agent.name} --to #{partners.first.name} "what you need them to know"
|
|
598
|
+
|
|
599
|
+
Every message is appended with a number and a timestamp and never
|
|
600
|
+
edited, so a person can read the exchange after the round. A question
|
|
601
|
+
your partner has not answered within a few steps is not a reason to
|
|
602
|
+
stop: write your assumption into implementation-plan.md and carry on.
|
|
603
|
+
SECTION
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# The section a control file adds. Present on every invocation, because
|
|
607
|
+
# the advisory clock writes into it whether or not anyone is watching.
|
|
608
|
+
#
|
|
609
|
+
# INTERRUPT is what makes a restart after Ctrl-C safe: the agent that
|
|
610
|
+
# was cut off leaves a mailbox note to whoever picks the plan up, and
|
|
611
|
+
# every agent is told to read its mail first, so the next run resumes
|
|
612
|
+
# from the note rather than redoing work already on disk.
|
|
482
613
|
#
|
|
483
614
|
# @param control [String, nil]
|
|
615
|
+
# @param agent [Agentilda::Agent]
|
|
616
|
+
# @param subject [Agentilda::Subject]
|
|
484
617
|
# @return [String]
|
|
485
|
-
def control_section(control)
|
|
618
|
+
def control_section(control, agent, subject)
|
|
486
619
|
return "" if control.nil?
|
|
487
620
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
621
|
+
plans_dir = File.dirname(subject.feature.path)
|
|
622
|
+
plan = subject.feature.ordinal
|
|
623
|
+
<<~SECTION
|
|
624
|
+
|
|
625
|
+
## Control file — poll it between steps
|
|
626
|
+
|
|
627
|
+
#{control}
|
|
628
|
+
|
|
629
|
+
Read this file before each significant step. Empty means carry on.
|
|
630
|
+
A line starting WARN: tells you how much time is left. WRAP_UP: means
|
|
631
|
+
finish the essential remainder now. STOP means write what you have,
|
|
632
|
+
write your ledger line, and end your turn.
|
|
633
|
+
|
|
634
|
+
INTERRUPT means the operator pressed Ctrl-C and the run will be started
|
|
635
|
+
again later. Start nothing new. Finish the write you are in the middle
|
|
636
|
+
of, so no file is left half-written, then leave a resume note for
|
|
637
|
+
whoever runs next — most likely you — saying what is done, what is
|
|
638
|
+
not, and the exact next step:
|
|
639
|
+
|
|
640
|
+
agentilda mail send --dir "#{plans_dir}" --plan #{plan} --from #{agent.name} --to #{agent.name} "RESUME: ..."
|
|
641
|
+
|
|
642
|
+
Then write your ledger line as `Interrupted` with NO `next:` line, and
|
|
643
|
+
end your turn.
|
|
644
|
+
|
|
645
|
+
Before you start, check for such a note from an earlier run:
|
|
646
|
+
|
|
647
|
+
agentilda mail read --dir "#{plans_dir}" --plan #{plan} --for #{agent.name}
|
|
648
|
+
|
|
649
|
+
If there is a RESUME note, continue from it and do not redo what it
|
|
650
|
+
says is done; check the files it names rather than taking it on trust.
|
|
651
|
+
SECTION
|
|
494
652
|
end
|
|
495
653
|
|
|
496
654
|
# The section `run --prompt` adds, labelled as coming from the person who
|