1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
//! The in-process root-conversation driver — `litany prompt`'s step loop
//! (ARCH §2.3–§2.10, §6).
//!
//! [`run_exchange`] executes a single root conversation: spawn branch
//! `agents/<conv-id>` off the start's fork point (§2.2–§2.3 — a config
//! lineage's head by default, any ref with `--from`); seed the working
//! directory the start named (§3.3, `--cwd`); write the step-1 dispatch
//! commit (§2.2, §2.10); then the step loop (§2.5), whose acts are
//! [`super`]'s modules in the order named there. The loop ends on the
//! `Epitaph` its exit breaks with, and every terminal runs the shared
//! §2.11 tail (`terminal::conclude`).
//!
//! **Config is consulted at every step boundary, not once per exchange**
//! (bl-e580). Follow-the-tip (§2.2, bl-403b) and the workflow mark (§6,
//! bl-f928) are both "changeable at any time, on any turn", and a root's
//! first exchange is an unbounded number of steps long — so the loop
//! re-resolves at each boundary, through the same
//! [`crate::prompt::resolve::resolve_worker`] a `litany advance` hop
//! calls and against the same source (§6 *one struct, two drivers*).
use super::model_call::ModelCall;
use super::step_commit::{
commit_dispatch, compose_system, read_branch_tip, refresh_descriptors, spawn_branch,
write_dispatch_files,
};
use super::tool_step::{self, run_tool_calls};
use super::{
Resolved, assembler, canonical, child_result, drain, driver, one_call, prefix, result_deposit,
stop_signal, terminal, tools, transcript,
};
use crate::prompt::inbox::{self, Epitaph};
use crate::prompt::resolve::{ConfigSource, WorkerConfig, resolve_worker};
use crate::prompt::step::STAGING_FILE;
use crate::prompt::{Deps, Error};
use brazen::Content;
use std::path::Path;
/// Drive one root conversation, forked off `fork_point` (§2.3 — the ref
/// the start named, resolved by [`crate::prompt::fork_point`]). `first`
/// is the caller's own resolution: it decided this branch could be
/// spawned at all, and it is step 1's boundary answer — taken against
/// the fork point moments before the branch existed. Every later step
/// re-resolves. Returns the branch name so the caller can surface it on
/// stdout.
#[allow(clippy::too_many_arguments)]
pub(in crate::prompt) fn run_exchange(
repo: &Path,
user_message: &str,
fork_point: &str,
name: Option<&str>,
pins: &crate::prompt::PinnedDocs,
cwd: Option<&Path>,
first: &Resolved<'_>,
deps: &Deps<'_>,
) -> Result<String, Error> {
let ts = deps.clock.now_compact();
let short_id = deps.id_gen.short();
let conv_id = format!("{ts}-{short_id}");
let branch_name = conv_id.clone();
let worktree_path = crate::workspace::agent_worktree(repo, &conv_id);
// Executor lock (§2.11): acquire the branch's inbox lease before any
// work, held for the whole loop and kernel-released on exit. Losing
// the acquire means another driver owns this branch — clean no-op
// (Writer/driver totality); a fresh root always wins (unique conv-id).
let inbox = inbox::inbox_dir(repo, &conv_id);
let executor_lock = match inbox::try_acquire(&inbox).map_err(|source| Error::ExecutorLock {
path: inbox.clone(),
source,
})? {
Some(guard) => guard,
None => return Ok(branch_name),
};
crate::prompt::seed_cwd(repo, &conv_id, cwd, deps.git)?;
spawn_branch(repo, &worktree_path, &conv_id, fork_point, deps)?;
// The initial user message enters through the front door (§2.4,
// §2.11): deposited into this agent's own inbox, delivered by the
// step-1 drain — the same path any reprompt takes.
let sender = inbox::USER_SENDER;
inbox::deposit(repo, &conv_id, sender, user_message, deps.clock, deps.git)?;
let mut step_seq: u32 = 1;
// What the latest drain deliberately left pending — the §2.11
// release rule's diff base at the tail (every loop exit follows an
// assignment, so this is never unset).
let mut seen;
// The boundary resolution of every step past the first, kept across
// iterations so the terminal tail below reads the workflow that
// governed the last step rather than the one the exchange opened on.
let mut followed: Option<WorkerConfig> = None;
// The loop's value is how it ended (§2.11): each exit breaks with its
// own epitaph rather than setting a flag a match downstream re-reads.
let epitaph = loop {
// The step boundary IS the resolution point (§2.2 follow-the-tip,
// §6 the workflow mark): a `litany config` edit or a `litany
// workflow` mark that lands while this exchange is running governs
// the next step, not the next exchange. Step 1's boundary is the
// fork the caller already resolved against — re-asking there would
// be the same answer at the cost of a second load-time guard.
if step_seq > 1 {
followed = Some(resolve_worker(repo, ConfigSource::Agent(&conv_id), deps)?);
}
let boundary = followed.as_ref().map(WorkerConfig::as_resolved);
let resolved: &Resolved<'_> = boundary.as_ref().unwrap_or(first);
if step_seq == 1 {
write_dispatch_files(&worktree_path, user_message, &resolved.soul, pins)?;
commit_dispatch(&worktree_path, &conv_id, name, pins, resolved, deps)?;
}
// Step-boundary drain (§2.11 *Delivery*): move each pending inbox
// message into the transcript ahead of this step's read-state
// capture — after the prior step's tool entries, so a message
// never wedges between paired tool blocks (§2.3).
seen = drain::drain(&worktree_path, &inbox, &conv_id, deps.git)?.left;
// §6 prompt→advance collapse: interpret delivered child results
// (deliver_result / land_compaction / verifier gate) at the same
// boundary `litany advance` does — empty-inputs no-op for a root.
child_result::interpret_pending(repo, &conv_id, &worktree_path, resolved.workflow, deps)?;
// §3.3 × §2.2: re-derive the descriptor cut against what just
// resolved, ahead of the read-state capture (bl-37cd). Under the
// same boundary rule as the resolution above — step 1's boundary
// is the fork the caller already resolved against, and the
// dispatch commit made this very cut from that answer moments
// earlier, so asking again there is a provable no-op paid for in
// git forks.
if step_seq > 1 {
refresh_descriptors(&worktree_path, &conv_id, &resolved.grant, deps.git)?;
}
let commit_sha = read_branch_tip(&worktree_path, deps)?;
// §2.9 step 3 check point: a stop between steps (or during a prior
// step's tool work) is caught here, before the next model call.
if stop_signal::stopped(deps.stop) {
break Epitaph::Stopped;
}
// §6 budget check (deposits + marks the ref on exhaustion, §2.9).
if terminal::budget_exhausted(
repo,
&conv_id,
&branch_name,
&worktree_path,
&resolved.budgets,
deps,
)? {
break Epitaph::BudgetExhausted;
}
// The system slot (§2.3, §5.2): goal, identity, soul — composed
// here rather than once, because the soul is config and config
// follows the tip. The goal and the name are the ones this start
// was given — the same values the dispatch commit wrote to
// `goal.md` and `name`, so the slot and the tree state one fact,
// not two (§2.10 replay re-reads the tree and reproduces this
// byte-for-byte).
let system_with_goal = compose_system(user_message, name, &resolved.soul);
let call = ModelCall {
adapter: deps.adapter,
sleeper: deps.sleeper,
binary: &resolved.binary,
provider_row: resolved.provider_row,
retry: resolved.retry,
stop: deps.stop,
expect_handshake: resolved.expect_handshake,
};
// §2.3 / §5: assemble the model-facing history from the read-state
// commit's tree — §5.2 head/body under the role's manifest rules,
// then the transcript tail — one path for running, retry, replay.
let messages = assembler::assemble(&worktree_path, resolved.manifest)?;
let tools = tools::compose(
&worktree_path,
&resolved.grant,
&messages,
&tools::injected(resolved.grant.role, deps.tool_executor, repo, &conv_id),
)?;
let request = canonical::build_request(
resolved.model_id,
&conv_id,
&system_with_goal,
messages,
tools,
resolved.max_output_tokens,
resolved.effort,
resolved.priority,
);
// §5.5: a subtraction from the declared surface is held until a
// miss is paid anyway — the previous step's record is the queue.
let request = prefix::hold(
prefix::previous(repo, &conv_id, step_seq)?.as_ref(),
request,
)?;
let step = one_call::Step {
conv_repo: repo,
conv_id: &conv_id,
seq: step_seq,
tip: commit_sha,
};
let Some(step_dir_rel_str) = one_call::issue(step, &request, &call, resolved, deps)? else {
break Epitaph::Stopped;
};
// Transcript writer (§2.3): seal-and-rename the staging entry to
// `messages/NNN-<model-id>.json` (origin = authoring model) + commit.
let staging_path = repo.join(&step_dir_rel_str).join(STAGING_FILE);
let assistant_content = transcript::commit_assistant(
&worktree_path,
&conv_id,
resolved.model_id,
&staging_path,
deps.git,
)?;
// No `tool_use` block is terminal (§2.5): deposit a `final-response`
// result, response body iff the agent spoke (§2.6). No-op for a root.
if !assistant_content
.iter()
.any(|b| matches!(b, Content::ToolUse { .. }))
{
let response = result_deposit::terminal_text(&assistant_content);
result_deposit::deposit_terminal(
repo,
&conv_id,
&worktree_path,
Epitaph::FinalResponse,
response.as_deref(),
deps,
)?;
break Epitaph::FinalResponse;
}
// §2.5 pairing: run each tool_use, committing its tool_result as
// a transcript entry (§2.3). A stop felling the window breaks for
// the same stopped-deposit exit as the model-call window (§2.9
// step 3); a window the configured control held (§3.3 *Tool
// control*) parks instead — no terminal, no deposit, the hold
// mark and the unpaired tail the whole state, the lease released
// through the §2.11 release rule for a later `litany advance` to
// resume by re-adjudication.
let window = run_tool_calls(
repo,
&worktree_path,
&conv_id,
resolved,
&step_dir_rel_str,
&assistant_content,
deps,
)?;
match window {
tool_step::ToolWindow::Stopped => break Epitaph::Stopped,
tool_step::ToolWindow::Held => {
driver::release_then_reprobe(executor_lock, repo, &conv_id, &seen, deps.launcher);
return Ok(branch_name);
}
tool_step::ToolWindow::Completed => {}
}
// §6 collapse: the `compaction:` checkpoint clock, same seam as
// `litany advance` (`worker_flush` → dispatch a compactor off C).
child_result::run_flush(repo, &conv_id, &worktree_path, resolved.workflow, deps)?;
step_seq += 1;
};
// The shared §2.11 terminal tail ([`terminal::conclude`] — the same
// sequence as the `litany advance` hop's): finish by epitaph value
// (a stopped branch deposits its result on the way out; a final
// response deposited in the loop; an exhausted branch at the boundary
// check), terminal-lifecycle bindings (§6), release through the
// release rule (a deposit that raced this loop's last drain launches
// whatever the epitaph), then the epitaph-valued exit launches. No
// terminal compaction (§2.7 — the stage is deleted). The bindings are
// the last boundary's, not the exchange's opening ones.
let workflow = followed.as_ref().map_or(first.workflow, |c| &c.workflow);
terminal::conclude(
repo,
&conv_id,
epitaph,
workflow,
executor_lock,
&seen,
deps,
)?;
Ok(branch_name)
}