From c3c6ab0ecf422f947d40f201e837b39e625f0dfe Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sat, 19 Sep 2026 19:30:35 -0400 Subject: [PATCH] Steer a llama turn at its next tool boundary A message typed into a running llama session waited for the turn to end and then opened one of its own, so a turn spending minutes on a chain of tool calls read nothing sent during it -- which is the one moment steering is for. It now goes into the request the loop is about to build, prefixed with the same note every other driver's steer carries. The boundary being ours rather than the CLI's has two consequences worth keeping: a waiting message can be taken back right up to the moment it is read, and an interrupted turn deliberately takes nothing, since a request that is not going out must not record a message as read. Co-Authored-By: Claude Opus 5 --- PLAN.md | 27 ++++++++ server/src/session/llama/mod.rs | 108 +++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/PLAN.md b/PLAN.md index 4e18973..160e59f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -679,6 +679,33 @@ It is prefixed on every steer rather than only on the ones that land late, because the two are the same message until the CLI reads it, and the note is true either way: a steer never saw the rest of the turn it was typed into. +### A llama session steers at its own tool boundary (2026-09-19) + +A message typed into a running llama session is handed to the model at that +turn's **next tool call**, in the request the driver is about to build +(`session/llama/mod.rs`'s `take_steers`), rather than waiting for the turn to +end and opening one of its own. Before this it waited: a turn that spent two +minutes on a chain of tool calls read nothing sent during it, which is the +one moment steering is for. + +This is the same landing place as the Claude CLI's, reached the other way +round. Claude's steer is written into stdin on arrival and *the CLI* decides +it lands at the next model call; here the loop is ours, so nothing is handed +over until the boundary is reached. Two things follow that the CLI cannot +offer: a waiting message can still be taken back right up to the moment it is +read, so `Driver::unqueue` keeps answering `Dropped` rather than +`AlreadySent`; and an interrupted turn deliberately takes nothing, leaving +the message in the queue to open the next turn, because a request that is not +going out must not record a message as read. + +A turn with no tool call left still has no boundary to interject at -- the +server is generating until it returns -- so such a message opens the next +turn as it always did. The `STEERING_NOTE` is on the model's copy only, so a +steer is folded back out of the transcript as the words that were typed, and +`a_steer_taken_mid_turn_folds_back_between_the_two_replies` is what keeps the +fold matching what the turn built: the prompt cache depends on the next turn +rendering this one byte for byte. + ### Session processes outlive the backend (2026-08-29) A session's process is **left running when the backend stops and adopted diff --git a/server/src/session/llama/mod.rs b/server/src/session/llama/mod.rs index 89553f3..b95496c 100644 --- a/server/src/session/llama/mod.rs +++ b/server/src/session/llama/mod.rs @@ -46,6 +46,11 @@ //! [`tools`] is the catalog and the running; [`mcp`] is the half of it that //! this backend reaches rather than the model's machine. //! +//! Steering falls out of owning the loop: a message typed during a turn is +//! handed to the model at that turn's next tool call rather than opening one +//! of its own -- [`take_steers`], and it is why a waiting message here can be +//! taken back right up to the boundary that reads it. +//! //! **Loading is a state, not a fast bit of starting.** A multi-gigabyte model //! takes a while to reach memory, and for that while the server refuses //! everything. It is [`SessionStatus::Loading`] on screen and a message sent @@ -290,6 +295,10 @@ impl Serves { /// Whether a turn is running, and the messages written during it -- each with /// the id of the `MessageQueued` that announced it, so the `UserMessage` can /// say which bubble it resolves. +/// +/// `waiting` is drained by whichever comes first: the running turn's next tool +/// boundary, which steers them into it, or the turn ending, which starts one +/// with the first of them. #[derive(Default)] struct Turns { running: bool, @@ -955,6 +964,7 @@ fn converse( let output = run_call(shared, tools, call); messages.push(Message::result_of(&call.id, output)); } + take_steers(shared, &mut messages); } // Said rather than left as a turn that simply stopped: a reply that ends // here and one that ends because the model was finished look identical on @@ -969,6 +979,49 @@ fn converse( Ok(()) } +/// Moves whatever was typed during this turn into the request about to be +/// built, which is what steering a llama session is. +/// +/// Called at a tool boundary and nowhere else, because that is the only place +/// this driver can interject: `llama-server` is generating until it returns, +/// and the next thing it is asked is assembled here. A message typed during a +/// turn with no tool call left therefore still opens the next turn, exactly as +/// it did before -- [`LlamaDriver::take_next`] is that path. +/// +/// What differs from the Claude CLI, which writes a steer into stdin on +/// arrival, is that the boundary is ours: nothing is handed over until one is +/// reached, so a waiting message can still be taken back right up to it and +/// [`Driver::unqueue`] keeps answering [`Unqueued::Dropped`]. +/// +/// The note saying this is a steer goes only into the copy the model is +/// handed; the transcript keeps the words that were typed. So it is gone by +/// the next turn, which rebuilds the conversation out of the transcript -- +/// which is what the note says anyway, since by then the turn it was typed +/// into is over. +fn take_steers(shared: &Arc, messages: &mut Vec) { + // An interrupted turn takes nothing: this request is not going out, so a + // message taken here would be recorded as read and then never answered. + // Left in the queue it opens the next turn instead. + if shared.cancel.load(Ordering::SeqCst) { + return; + } + let waiting = std::mem::take(&mut shared.turns.lock().unwrap().waiting); + for (id, text) in waiting { + // The message enters the transcript here, below the calls that had + // not read it and above the ones that will -- the same rule the + // Claude driver announces a steer by. + shared.emit(Event::MessageTaken { + id: Some(id), + text: text.clone(), + attachments: Vec::new(), + }); + messages.push(Message::new( + "user", + super::driver::message_body(&text, &[], true), + )); + } +} + /// One tool call: announced, asked about if it has to be, run, and reported. /// /// Always returns something for the model to read, including when it was @@ -1075,8 +1128,10 @@ impl Driver for LlamaDriver { message: "this model can't be sent attachments or files".to_string(), }); } - // A message written during a turn waits for it, rather than starting a - // second conversation against the same server. Nothing was queued here + // A message written during a turn does not start a second + // conversation against the same server: it waits for the turn's next + // tool boundary and steers it from there (`take_steers`), or for the + // turn to end if there is no boundary left. Nothing was queued here // until tools arrived and turns grew long enough for it to matter -- // two turns interleaving their deltas into one transcript is what that // looked like. @@ -1993,6 +2048,55 @@ mod tests { assert_eq!(messages[1].content, "one two"); } + #[test] + /// A steer taken at a tool boundary has to fold back the way `converse` + /// built it: the call and its result belong to the reply that asked, and + /// the message sits between that and the reply that read it. Getting this + /// wrong is not visible on screen -- the transcript renders the same + /// either way -- and costs the whole prompt cache on the next turn. The + /// steering note is not here because it never was: only the copy the model + /// was handed carried it. + fn a_steer_taken_mid_turn_folds_back_between_the_two_replies() { + let (_dir, path) = transcript_with(&[ + Event::UserMessage { + id: None, + text: "read it".into(), + attachments: Vec::new(), + }, + Event::ToolStart { + id: "c1".into(), + tool: "read_file".into(), + input: json!({"path": "x"}), + }, + Event::ToolEnd { + id: "c1".into(), + output: "contents".into(), + }, + Event::UserMessage { + id: Some("q1".into()), + text: "the other one actually".into(), + attachments: Vec::new(), + }, + Event::AssistantText { + delta: "right".into(), + }, + ]); + let messages = conversation(&path); + assert_eq!( + messages + .iter() + .map(|m| (m.role.as_str(), m.content.as_str())) + .collect::>(), + [ + ("user", "read it"), + ("assistant", ""), + ("tool", "contents"), + ("user", "the other one actually"), + ("assistant", "right"), + ], + ); + } + #[test] /// The interrupted case, which decides what a resumed conversation is built /// from: whatever the phone was shown. The deltas that arrived before the