From 0a32df13828384a10e1f21499959b751b3e6b213 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:23:48 +0000 Subject: [PATCH 01/22] apply_patch: Codex's patch tool beside edit, freeform on Responses The daemon gets apply_patch beside edit and write: Codex's patch format, checked whole before any file is written, with Codex's lenient context matching. On Responses it is a custom tool constrained by Codex's Lark grammar, its call stored as sent and answered with a custom_tool_call_output (tools.custom, schema 55); on Anthropic a function taking the patch as input. New bots get apply_patch on a Responses model and edit otherwise; the approver judges a patch by every file it names, and Sonny draws one as a diff. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- client/src/approver.rs | 8 +- client/src/lib.rs | 31 +++ client/src/project.rs | 17 +- docs/CLI.md | 4 +- docs/RUST_PROTOTYPE.md | 19 +- docs/sonny/apply-patch.png | Bin 0 -> 106794 bytes sonny/src/diff.rs | 22 +- sonny/src/main.rs | 33 ++- sonny/src/project.rs | 12 +- sonny/src/transcript.rs | 176 +++++++++++- sonny/src/trigger.rs | 2 +- src/approver.rs | 113 +++++++- src/cli.rs | 2 +- src/client.rs | 45 ++- src/codec.rs | 59 +++- src/provider.rs | 6 + src/provider/anthropic.rs | 1 + src/provider/responses.rs | 38 ++- src/server/mod.rs | 4 + src/server/turn.rs | 6 +- src/store/context.rs | 28 +- src/store/db.rs | 68 +++-- src/tools.rs | 34 ++- src/tools/patch.rs | 552 +++++++++++++++++++++++++++++++++++++ tests/store_contract.rs | 18 ++ tests/store_payloads.rs | 1 + tests/test_daemon.py | 50 ++++ 27 files changed, 1266 insertions(+), 83 deletions(-) create mode 100644 docs/sonny/apply-patch.png create mode 100644 src/tools/patch.rs diff --git a/client/src/approver.rs b/client/src/approver.rs index f2d63d1d0..b2d9e338b 100644 --- a/client/src/approver.rs +++ b/client/src/approver.rs @@ -894,12 +894,16 @@ pub fn build(intent: &Intent, redactor: &Redactor) -> State { } /// The whole arguments of `call_id` in a planning item, in either family's -/// encoding: a Responses `function_call` item, or an Anthropic assistant -/// message holding `tool_use` blocks. +/// encoding: a Responses `function_call` item, or a `custom_tool_call` +/// whose free-form input reads as `{"input": ...}`, as the daemon runs it, +/// or an Anthropic assistant message holding `tool_use` blocks. pub fn call_arguments(item: &Value, call_id: &str) -> Option { if item["type"] == "function_call" && item["call_id"] == call_id { return serde_json::from_str(item["arguments"].as_str()?).ok(); } + if item["type"] == "custom_tool_call" && item["call_id"] == call_id { + return Some(json!({"input":item["input"].as_str()?})); + } item["content"] .as_array()? .iter() diff --git a/client/src/lib.rs b/client/src/lib.rs index 485e5749d..03e1be905 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -66,6 +66,22 @@ impl From for Error { } pub type Result = std::result::Result; +/// A new bot's tools when its client names none: the daemon's own, with +/// the file editor its model family is trained on, Codex's `apply_patch` +/// for a Responses model and Claude Code's `edit` for an Anthropic one. +/// `providers` is `ready.providers`; a model whose provider it does not +/// name gets `edit`. +pub fn default_tools(providers: &Value, model: &str) -> [&'static str; 6] { + let provider = model + .split_once('/') + .map_or(model, |(provider, _)| provider); + let editor = match providers[provider]["family"].as_str() { + Some("responses") => "apply_patch", + _ => "edit", + }; + ["shell", "read", "write", editor, "wait", "history"] +} + /// The socket protocol's version in `ready`. The daemon and its clients ship /// together, so a client refuses a daemon of any other version. pub const PROTOCOL: u64 = 21; @@ -370,6 +386,21 @@ mod tests { assert_ne!(theirs, ours); } + #[test] + fn a_new_bot_edits_files_the_way_its_family_is_trained_to() { + let providers = json!({"chatgpt": {"family": "responses"}, + "anthropic": {"family": "anthropic"}}); + assert_eq!( + default_tools(&providers, "chatgpt/gpt-6-sol"), + ["shell", "read", "write", "apply_patch", "wait", "history"] + ); + assert_eq!( + default_tools(&providers, "anthropic/claude-opus-5-5")[3], + "edit" + ); + assert_eq!(default_tools(&providers, "unknown/model")[3], "edit"); + } + #[tokio::test] async fn canceled_waits_release_pending_requests_without_closing_the_session() { let (stream, peer) = UnixStream::pair().unwrap(); diff --git a/client/src/project.rs b/client/src/project.rs index fdf89559c..efc0d4d7a 100644 --- a/client/src/project.rs +++ b/client/src/project.rs @@ -319,12 +319,13 @@ impl Requests for crate::Client { /// the coordinator. A project that already exists in this folder is /// returned with `created: false`, its coordinator's row as the record, and /// nothing changed; a name another -/// folder's coordinator holds is refused. +/// folder's coordinator holds is refused. A role that names no tools gets +/// the default set for its model, from `providers` (`ready.providers`). pub async fn create( daemon: &impl Requests, dir: &Path, policy: &Value, - tools: &Value, + providers: &Value, picks: &Picks, copies: &str, after: Option<&str>, @@ -391,7 +392,10 @@ pub async fn create( "bot": coordinator, "workspace": at, "model": model, "instructions": instructions, "compaction_instructions": policy["compaction_instructions"], - "tools": if policy["tools"].is_null() { tools } else { &policy["tools"] }, + "tools": match &policy["tools"] { + Value::Null => json!(crate::default_tools(providers, model)), + tools => tools.clone(), + }, }); if let Some(effort) = effort { params["effort"] = json!(effort); @@ -502,7 +506,7 @@ mod tests { daemon, dir, policy, - &json!(["shell", "read"]), + &json!({"alpha": {"family": "responses"}}), picks, "worktree", None, @@ -558,7 +562,10 @@ mod tests { ); assert_eq!( (&create["tools"], &create["compaction_instructions"]), - (&json!(["shell", "read"]), &json!("keep")) + ( + &json!(["shell", "read", "write", "apply_patch", "wait", "history"]), + &json!("keep") + ) ); let file = read(&dir).unwrap(); assert_eq!( diff --git a/docs/CLI.md b/docs/CLI.md index 125c1cde6..be2518ab9 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -296,7 +296,9 @@ on its creator's model thinks as hard as its creator; a bot given `run --instructions` and `--instructions-file` set a new bot's instructions, with the CLI's built-in text as the default; the daemon has none. `run --tools LIST` chooses a new bot's tools from those the daemon registers, -default `shell,read,write,edit,wait,history`; an existing bot keeps its own, +default `shell,read,write,wait,history` with the file editor its model +family is trained on: `apply_patch` for a Responses model, else `edit`. An +existing bot keeps its own, so `--tools` while continuing one is a usage error. Other client commands require an already running daemon. diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index f676fee61..40b45fe53 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -77,7 +77,8 @@ It supplies no agent behavior: `create` names the bot's model, instructions, and tools, the bot keeps all three, a fork inherits them, and a later client's environment changes nothing about an existing bot. The CLI resolves `--model` or `AGENT_MODEL`, `--instructions` or its built-in text, and -`--tools` or `shell,read,write,edit,wait,history` before it asks. A bot's +`--tools` or `shell,read,write,wait,history` with `apply_patch` for a +Responses model and `edit` otherwise before it asks. A bot's tools are what the model is shown and what dispatch allows: a call to any other tool is answered with a `tool_not_available` result, whatever the model was told. Tool definitions live once in the daemon and their request @@ -107,7 +108,8 @@ of the well-known key variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`) are set. Like `AGENT_MODEL`, `AGENT_PROVIDER` is a default for starting a daemon: a running daemon is not checked against it, since every bot's shell inherits the daemon's environment. No other credential discovery happens. `--no-spawn` refuses to start a -daemon. Default tools are `shell,read,write,edit,wait,history`; `note`, the +daemon. Default tools are `shell,read,write,wait,history` and the file editor +the model's family is trained on, `apply_patch` (Responses) or `edit`; `note`, the carry-forward note, is in the universe and chosen per bot. If concurrent clients race to start the daemon, losing `serve` processes exit @@ -2029,8 +2031,8 @@ which is future work, or deleting the bot. ## Tools and validation scope -The daemon registers `echo`, `shell`, `read`, `write`, `edit`, `wait`, and -`history`; each bot is created with the subset it may call (`run --tools`), +The daemon registers `echo`, `shell`, `read`, `write`, `edit`, `apply_patch`, +`wait`, `history` and `note`; each bot is created with the subset it may call (`run --tools`), which is what its model is shown and what dispatch allows. The registry validates tool names and arguments before execution; a tool that fails (unknown tool, invalid arguments, missing file, ambiguous edit, output @@ -2778,7 +2780,14 @@ but has more than 64 megapixels is `image_limit`, refused before it is decoded; scaling runs off the daemon's thread, one picture at a time. `write` creates parent directories and writes up to 1 MiB. `edit` replaces an exact string that must occur once unless `replace_all` -is set. Paths are relative to the workspace unless absolute; nothing confines a +is set. `apply_patch` takes Codex's patch format: one patch adds, deletes, +updates and moves any number of files, every change checked before any file is +written, so a patch that does not apply changes nothing. Its context lines +match exactly, then ignoring trailing and surrounding whitespace, then with +typographic punctuation read as ASCII. On Responses it is a custom tool whose +call is the patch itself, constrained by its Lark grammar, and its result a +`custom_tool_call_output`; on Anthropic, a function taking the patch as +`input`. Paths are relative to the workspace unless absolute; nothing confines a tool to the workspace, and tools run with the service's OS permissions. Native file tools accept regular files, including symlinks to regular files. Known pipes, devices, and other special files return `file_not_regular`. Opens diff --git a/docs/sonny/apply-patch.png b/docs/sonny/apply-patch.png new file mode 100644 index 0000000000000000000000000000000000000000..e1ee379626682ea267d3744f4040008ec91e63b0 GIT binary patch literal 106794 zcmeF2^F%LBM?ku}L7Jh47;0c< z&T{Yl?z7MTaQJ}_46K>8p0%DkuIqb;sw&G8;*;awx^;_C?t_&2ty{Paw{G28dvNFP zD-II2w{AVYB_}1J>6yB}gyTbOMScIM;PB8`9w&^HL3?rO#i5{G?|xb{$1H9D=X0*b zr8Me}8`dKgWLj{|Eg4Rr2L zB^xX2lGv;7%qhvw4fOP?f=H>JJRwW`6>m^qH8VYJiREl-^V6FXKzTR0yVpTYX$x}i z;RJ?gxjP@eWsi>L6OTRGskD-lD{4$C3Jesu`>)6D^Za)@9`LfL=qVZNX1~HS*2|4` zBHd0wr=#BCC2+&Z@{$nh9NvXd$j&zJR!Y`4G`zVmF(4OZKzbfIZ5Cg2F=cp(OioUI zx;1)_MYo2BS?IczbYQy7xbNRjb#finL3L+u&tu>$N;l8n-@h#COOCm%E4PNWhrGP} zGd5#wT}>YEjiVxsWz(a&%d-omxTo%jX}8`#U!m=A`0hCf-X0$t6LUAf|M_I?__%s~ z^TpjecRM?1o<4m_NJvPze~_<|28BXd>b876qUnv7HFaw~riX=v&5q~vsL;KAyR*AH zoGPldOKf3jc{u0s1)qzHYhc-kg`S??jrY*Gki&EI*K{dmE}Q*KnLW16_icS%;>F?& zI}eMnprDMb?81D1Ok31LhZtbFOZXj!R+;+x`&(9%$taVlN#2xXX4*NH zjQ1Pc_TEB0Gbdzge&w-;MmrkXe>2C{b)P@{yY5U!D}?xHV-&W|*;QI0o`lqDD7DSy zs##xOAIdh$dvGA+py{+b8r8nwZ8;xl&rLw�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 gi^rt+mA+p*H Option> = Vec::new(); let mut files: Vec<(String, Vec)> = Vec::new(); + // Files a diff deletes: named by their old path, with no lines shown. + let mut deleted: Vec = Vec::new(); + let mut old_path = ""; let mut lines = text.lines().peekable(); while let Some(line) = lines.next() { - if let Some(path) = line.strip_prefix("+++ ") { - let path = path.strip_prefix("b/").unwrap_or(path).to_owned(); - files.push((path, Vec::new())); + if let Some(path) = line.strip_prefix("--- ") { + old_path = path.strip_prefix("a/").unwrap_or(path); + } else if let Some(path) = line.strip_prefix("+++ ") { + let path = match path { + "/dev/null" => { + deleted.push(files.len()); + old_path + } + path => path.strip_prefix("b/").unwrap_or(path), + }; + files.push((path.to_owned(), Vec::new())); } else if let Some(header) = line.strip_prefix("@@ ") && let Some((_, hunks)) = files.last_mut() { @@ -251,6 +262,11 @@ pub fn unified(text: &str, width: usize, most: usize) -> Option Result<(), Str agent_client::policy::instructions(&workspace, None, &memory).map_err(|e| e.to_string())?; let instructions = project::as_task(&workspace, &composed.text)?; let declare = json!(declared::tools(false)); + let tools = agent_client::default_tools(client.providers(), &model); let mut asked = json!({"bot": bot, "workspace": workspace, "model": model, - "instructions": instructions, "tools": DEFAULT_TOOLS, "declare": declare, + "instructions": instructions, "tools": tools, "declare": declare, "compaction_instructions": agent_client::policy::DEFAULT_COMPACTION_INSTRUCTIONS, "request_id": unique("tui-create")}); if let Some(ttl) = agent_client::policy::cache_ttl(&model) { asked["settings"] = json!({"cache_ttl": ttl}); } - let tools = approvals::gateable(&DEFAULT_TOOLS.map(String::from), &declare); + let tools = approvals::gateable(&tools.map(String::from), &declare); if let Value::Object(fields) = &mut asked { fields.extend(approvals::gate(approvals::MODES[0].0, &tools)); } @@ -6689,8 +6689,22 @@ fn output(item: &Value) -> (String, bool) { /// An item as the Responses family shapes it, which the transcript reads. /// An Anthropic message's `tool_use` blocks become function calls after -/// its text, and a user message's `tool_result` blocks their outputs. +/// its text, and a user message's `tool_result` blocks their outputs. A +/// custom tool's call reads as a function's, its input as `{"input": ...}` +/// as the daemon runs it, and its output as a function's. fn responses(item: &Value) -> Vec { + match item["type"].as_str() { + Some("custom_tool_call") => { + return vec![json!({"type": "function_call", "call_id": item["call_id"], + "name": item["name"], "arguments": json!({"input": item["input"]}).to_string()})]; + } + Some("custom_tool_call_output") => { + let mut output = item.clone(); + output["type"] = json!("function_call_output"); + return vec![output]; + } + _ => {} + } let Some(parts) = item["content"].as_array() else { return Vec::from_iter((!item.is_null()).then(|| item.clone())); }; @@ -8110,5 +8124,16 @@ mod tests { let call = json!({"type": "function_call", "call_id": "c"}); assert_eq!(responses(&call), [call]); assert!(responses(&Value::Null).is_empty()); + // A custom tool's call and output read as a function's. + let patch = json!({"type": "custom_tool_call", "call_id": "p", "name": "apply_patch", + "input": "*** Begin Patch\n*** End Patch"}); + let items = responses(&patch); + assert_eq!(items[0]["type"], "function_call"); + assert_eq!( + serde_json::from_str::(items[0]["arguments"].as_str().unwrap()).unwrap(), + json!({"input": "*** Begin Patch\n*** End Patch"}) + ); + let done = json!({"type": "custom_tool_call_output", "call_id": "p", "output": "Success."}); + assert_eq!(output(&responses(&done)[0]), ("Success.".into(), false)); } } diff --git a/sonny/src/project.rs b/sonny/src/project.rs index 5de93d1a0..b7e9396b7 100644 --- a/sonny/src/project.rs +++ b/sonny/src/project.rs @@ -12,7 +12,6 @@ use std::path::{Path, PathBuf}; /// `~/.agents/agents/lead.md` to edit; used when neither the folder nor /// the person has a `lead.md`. pub const LEAD: &str = include_str!("../agents/lead.md"); -pub const TOOLS: [&str; 6] = ["shell", "read", "write", "edit", "wait", "history"]; /// What Sonny ships for every agent it starts other than a lead (tasks, /// a trigger's agents and the ones opened with `--new`), written to the @@ -504,7 +503,16 @@ async fn make( "declare": crate::declared::tools(true), }); let copies = crate::settings::Settings::load().copies; - agent_client::project::create(client, dir, &policy, &json!(TOOLS), picks, &copies, None).await + agent_client::project::create( + client, + dir, + &policy, + client.providers(), + picks, + &copies, + None, + ) + .await } /// A `new_project` call of `caller`'s: the project Sonny makes for a diff --git a/sonny/src/transcript.rs b/sonny/src/transcript.rs index ffc0c4cfa..5321f7b63 100644 --- a/sonny/src/transcript.rs +++ b/sonny/src/transcript.rs @@ -1114,6 +1114,14 @@ fn drawn( "read" => ("Read", shown(&field("path"), workspace)), "write" => ("Wrote", shown(&field("path"), workspace)), "edit" => ("Edited", shown(&field("path"), workspace)), + "apply_patch" => ( + "Patched", + patch_files(&field("input")) + .iter() + .map(|path| shown(path, workspace)) + .collect::>() + .join(", "), + ), "wait" => ( "Waited for", arguments["handles"] @@ -1135,7 +1143,7 @@ fn drawn( ]); // A file tool that failed changed nothing: what it was asked to do is // not drawn, its error is. - if failed && matches!(name, "edit" | "write" | "read") { + if failed && matches!(name, "edit" | "write" | "read" | "apply_patch") { let mut out = wrap(header, width, bullet, Span::raw(" ")); out.extend(preview(output.unwrap_or(""), width)); return out; @@ -1146,9 +1154,30 @@ fn drawn( header.spans.push(Span::raw(" ")); header.spans.extend(crate::diff::counts(removed, added)); } + let patch = (name == "apply_patch").then(|| as_unified(&field("input"), workspace)); + if let Some(patch) = &patch { + let (removed, added) = patch.lines().fold((0, 0), |(removed, added), line| { + match ( + line.starts_with("--- "), + line.starts_with("+++ "), + line.chars().next(), + ) { + (false, false, Some('-')) => (removed + 1, added), + (false, false, Some('+')) => (removed, added + 1), + _ => (removed, added), + } + }); + header.spans.push(Span::raw(" ")); + header.spans.extend(crate::diff::counts(removed, added)); + } let mut out = wrap(header, width, bullet, Span::raw(" ")); match name { "edit" => out.extend(edit.map(|e| e.lines(width, EDIT_ROWS)).unwrap_or_default()), + "apply_patch" => out.extend( + patch + .and_then(|text| crate::diff::unified(&text, width, EDIT_ROWS)) + .unwrap_or_default(), + ), "write" => { let lines = field("content").lines().count(); out.push(Line::from(vec![ @@ -1360,6 +1389,114 @@ fn diff(arguments: &Value, workspace: &str) -> crate::diff::Edit { } } +/// The files a patch changes, each once: where an update moves a file, +/// the file it becomes. +fn patch_files(patch: &str) -> Vec { + let mut files: Vec = Vec::new(); + for line in patch.lines() { + let named = ["*** Add File: ", "*** Delete File: ", "*** Update File: "] + .iter() + .find_map(|marker| line.strip_prefix(marker)); + if let Some(path) = named { + files.push(path.trim().to_owned()); + } else if let Some(path) = line.strip_prefix("*** Move to: ") + && let Some(last) = files.last_mut() + { + *last = path.trim().to_owned(); + } + } + files.dedup(); + files +} + +/// A patch (Codex's format) as a unified diff, to draw as one. Its chunks +/// carry no line numbers: each is placed where its new side is in the +/// file as it is now, after the patch; a deleted file shows no lines. +fn as_unified(patch: &str, workspace: &str) -> String { + let mut out = String::new(); + let mut lines = patch.lines().peekable(); + let ends = |line: &str| line.starts_with("*** ") && line != "*** End of File"; + while let Some(line) = lines.next() { + if let Some(path) = line.strip_prefix("*** Add File: ") { + let added: Vec<&str> = std::iter::from_fn(|| lines.next_if(|l| !ends(l))) + .map(|l| l.strip_prefix('+').unwrap_or(l)) + .collect(); + out.push_str(&format!("--- /dev/null\n+++ {}\n", path.trim())); + out.push_str(&format!("@@ -0,0 +1,{} @@\n", added.len())); + for line in added { + out.push_str(&format!("+{line}\n")); + } + } else if let Some(path) = line.strip_prefix("*** Delete File: ") { + out.push_str(&format!( + "--- {}\n+++ /dev/null\n@@ -1,0 +0,0 @@\n", + path.trim() + )); + } else if let Some(path) = line.strip_prefix("*** Update File: ") { + let mut path = path.trim().to_owned(); + if let Some(to) = lines.next_if(|l| l.starts_with("*** Move to: ")) { + path = to["*** Move to: ".len()..].trim().to_owned(); + } + let now = std::fs::read_to_string(std::path::Path::new(workspace).join(&path)) + .unwrap_or_default(); + out.push_str(&format!("--- {path}\n+++ {path}\n")); + // Each chunk's lines, then its place: the line where its new + // side starts now, and that less what earlier chunks added. + let mut chunks: Vec> = Vec::new(); + while let Some(line) = lines.next_if(|l| !ends(l)) { + if line.starts_with("@@") || chunks.is_empty() { + chunks.push(Vec::new()); + } + if line.starts_with("@@") || line == "*** End of File" { + continue; + } + let line = if line.is_empty() { " " } else { line }; + if let Some(chunk) = chunks.last_mut() { + chunk.push(line.to_owned()); + } + } + let (mut grown, mut from) = (0isize, 0); + for chunk in chunks.into_iter().filter(|c| !c.is_empty()) { + let new: Vec<&str> = chunk + .iter() + .filter(|l| !l.starts_with('-')) + .map(|l| &l[1..]) + .collect(); + let old = chunk.iter().filter(|l| !l.starts_with('+')).count(); + let start = find_lines(&now, &new, from).unwrap_or(from); + from = start + new.len(); + let old_start = (start as isize - grown).max(0) as usize; + grown += new.len() as isize - old as isize; + out.push_str(&format!( + "@@ -{},{old} +{},{} @@\n", + old_start + 1, + start + 1, + new.len() + )); + for line in chunk { + out.push_str(&line); + out.push('\n'); + } + } + } + } + out +} + +/// The first line, at or after `from`, where `text` holds `wanted` in a +/// row, by content with ends trimmed. +fn find_lines(text: &str, wanted: &[&str], from: usize) -> Option { + let lines: Vec<&str> = text.lines().collect(); + if wanted.is_empty() || wanted.len() > lines.len() { + return None; + } + (from..=lines.len() - wanted.len()).find(|&at| { + wanted + .iter() + .zip(&lines[at..]) + .all(|(want, have)| want.trim_end() == have.trim_end()) + }) +} + /// The rule under a finished turn: how it ended, how long it took, what it cost. /// A path as the person would say it: inside the bot's folder, from /// there (macOS's /tmp is /private/tmp, either way round); else from ~. @@ -1640,6 +1777,43 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn a_patch_shows_each_file_as_a_diff_at_its_lines() { + let dir = std::env::temp_dir().join(format!("sonny-patch-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("calc.py"), + "def average(values):\n return sum(values) / len(values)\n\nprint(average([2, 4]))\n", + ) + .unwrap(); + let patch = "*** Begin Patch\n*** Update File: calc.py\n@@ def average(values):\n- return sum(values) / (len(values) - 1)\n+ return sum(values) / len(values)\n \n*** Add File: notes.md\n+# Notes\n*** Delete File: old.py\n*** End Patch"; + let rows = tool( + "apply_patch", + &serde_json::json!({"input": patch}), + None, + false, + dir.to_str().unwrap(), + 80, + ); + let rows = text(&rows); + assert_eq!(rows[0], "• Patched calc.py, notes.md, old.py -1 +2"); + assert!( + rows.contains(&" 2 â–Ž return sum(values) / (len(values) - 1)".to_owned()), + "{rows:#?}" + ); + assert!( + rows.contains(&" 2 â–Ž return sum(values) / len(values)".to_owned()), + "{rows:#?}" + ); + assert!( + rows.iter() + .any(|row| row.contains("notes.md") && row.contains("+1")), + "{rows:#?}" + ); + assert_eq!(rows.last().unwrap(), " old.py deleted"); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_wait_reads_as_each_turns_ending() { let output = r#"{"pending":[],"results":{"turn:c.fix/2":{"bot":"c.fix","status":"completed","text":"\nFixed it.\nMore."}}}"#; diff --git a/sonny/src/trigger.rs b/sonny/src/trigger.rs index 7bea79593..dc7f2a490 100644 --- a/sonny/src/trigger.rs +++ b/sonny/src/trigger.rs @@ -2606,7 +2606,7 @@ async fn start(client: &Client, trigger: &Trigger) -> Result { json!({"bot": name, "workspace": dir, "model": model, "effort": effort, "instructions": instructions, "compaction_instructions": agent_client::policy::DEFAULT_COMPACTION_INSTRUCTIONS, - "tools": crate::project::TOOLS, + "tools": agent_client::default_tools(client.providers(), model), "created_by": by.as_ref().map(|(bot, _)| bot), "created_by_id": by.as_ref().map(|(_, id)| id), "settings": agent_client::policy::cache_ttl(model) diff --git a/src/approver.rs b/src/approver.rs index 862b10f42..684d11098 100644 --- a/src/approver.rs +++ b/src/approver.rs @@ -658,6 +658,67 @@ fn prompt_bytes(prompts: &[Value]) -> usize { .sum() } +/// Whether a tool changes files: its calls are judged by the files they +/// change, not their contents. +fn changes_files(tool: &str) -> bool { + matches!(tool, "write" | "edit" | "apply_patch") +} + +/// The files a call changes: a write's or edit's `path`, or every file a +/// patch adds, deletes, updates or moves to. +fn changed_paths(tool: &str, arguments: &Value) -> Vec { + match tool { + "write" | "edit" => arguments["path"] + .as_str() + .map(str::to_owned) + .into_iter() + .collect(), + "apply_patch" => patch_paths(arguments["input"].as_str().unwrap_or_default()), + _ => Vec::new(), + } +} + +/// The files a patch names on its file lines. Also read from a cut +/// arguments preview, where the patch is still JSON-escaped, so a literal +/// `\n` ends a line too. +fn patch_paths(text: &str) -> Vec { + const MARKERS: [&str; 4] = [ + "*** Add File: ", + "*** Delete File: ", + "*** Update File: ", + "*** Move to: ", + ]; + let mut paths: Vec = Vec::new(); + for line in text.split('\n').flat_map(|line| line.split("\\n")) { + let line = line.trim(); + if let Some(path) = MARKERS.iter().find_map(|marker| line.strip_prefix(marker)) + && !path.is_empty() + && !paths.iter().any(|known| known == path) + { + paths.push(path.to_owned()); + } + } + paths +} + +/// What is shown of a file-changing call too large to read back: the +/// files it changes, when its preview names them. +fn cut_change(tool: &str, preview: &str) -> std::result::Result { + match tool { + "apply_patch" => { + // The line the cut falls in may hold part of a path: only + // lines it ended are read. + let ended = preview.rfind("\\n").map_or("", |end| &preview[..end]); + let paths = patch_paths(ended); + if paths.is_empty() { + return Err(Unjudged::TooLong); + } + Ok(json!({"paths":paths})) + } + _ => Ok(json!({"path":preview_path(preview).ok_or(Unjudged::TooLong)?})), + } +} + /// The `path` of a call whose arguments preview was cut, when the preview /// holds it whole: the fields before the cut parse, the one it falls in /// does not. @@ -808,7 +869,7 @@ async fn intent( }; let tool = call["name"].as_str().unwrap_or_default().to_owned(); let mut unseen = false; - if matches!(tool.as_str(), "write" | "edit") { + if changes_files(&tool) { (arguments, unseen) = policy::shown_change(&arguments, &shared.redactor); } size += arguments.to_string().len(); @@ -832,7 +893,7 @@ async fn intent( for call in read["calls"].as_array().into_iter().flatten() { let tool = call["name"].as_str().unwrap_or_default().to_owned(); let preview = call["arguments"].as_str().unwrap_or_default(); - let writes = matches!(tool.as_str(), "write" | "edit"); + let writes = changes_files(&tool); let cut = call["arguments_truncated"] == true; // Whether a write's size is known: an ungated call has no planning // node to read its whole arguments from. @@ -852,7 +913,7 @@ async fn intent( // Too large to read back: as a call with no node. Err(Unjudged::TooLong) if writes => { sized = false; - json!({"path":preview_path(preview).ok_or(Unjudged::TooLong)?}) + cut_change(&tool, preview)? } Err(Unjudged::TooLong) => { allowed_cut = true; @@ -864,7 +925,7 @@ async fn intent( // no file this turn wrote could be matched. None if writes => { sized = false; - json!({"path":preview_path(preview).ok_or(Unjudged::TooLong)?}) + cut_change(&tool, preview)? } None => { allowed_cut = true; @@ -874,18 +935,20 @@ async fn intent( }; // A file's content is not what consent is about: its path and size. if writes && let Value::Object(fields) = &arguments { + let paths = changed_paths(&tool, &arguments); // A failed write changed nothing this turn: its file is not shown. - if let Some(path) = fields.get("path").and_then(Value::as_str) - && call["failed"] != true - { - written.push(path.to_owned()); + if call["failed"] != true { + written.extend(paths.iter().cloned()); } let bytes: usize = fields .iter() - .filter(|(name, _)| name.as_str() != "path") + .filter(|(name, _)| !matches!(name.as_str(), "path" | "paths")) .map(|(_, value)| value.as_str().map_or(0, str::len)) .sum(); - arguments = json!({"path":fields.get("path"),"bytes":sized.then_some(bytes)}); + arguments = match tool.as_str() { + "apply_patch" => json!({"paths":paths,"bytes":sized.then_some(bytes)}), + _ => json!({"path":fields.get("path"),"bytes":sized.then_some(bytes)}), + }; } if cut { expanded += arguments.to_string().len(); @@ -943,10 +1006,8 @@ async fn intent( None => {} } } - if matches!(call.tool.as_str(), "write" | "edit") - && let Some(path) = call.arguments["path"].as_str() - { - changing.push((path.to_owned(), call.id.clone())); + for path in changed_paths(&call.tool, &call.arguments) { + changing.push((path, call.id.clone())); } } let earlier = read["earlier"] @@ -1313,6 +1374,30 @@ mod tests { assert_eq!(cut("echo"), None); } + #[test] + fn a_patch_is_judged_by_every_file_it_names() { + let patch = "*** Begin Patch\n*** Update File: a.rs\n*** Move to: b.rs\n@@\n-x\n+y\n*** Add File: c.rs\n+z\n*** Delete File: a.rs\n*** End Patch"; + assert_eq!( + changed_paths("apply_patch", &json!({"input": patch})), + ["a.rs", "b.rs", "c.rs"] + ); + // Cut short, still JSON-escaped: the files named before the cut. + let preview = &json!({"input": patch}).to_string()[..72]; + assert_eq!( + cut_change("apply_patch", preview).ok(), + Some(json!({"paths":["a.rs", "b.rs"]})) + ); + // A path the cut falls in is not read as a shorter one. + let preview = &json!({"input": patch}).to_string()[..65]; + assert_eq!( + cut_change("apply_patch", preview).ok(), + Some(json!({"paths":["a.rs"]})) + ); + assert!(cut_change("apply_patch", r#"{"input":"*** Begin Pa"#).is_err()); + assert_eq!(changed_paths("edit", &json!({"path": "x"})), ["x"]); + assert!(changed_paths("shell", &json!({"command": "*** Add File: x"})).is_empty()); + } + #[test] fn a_file_is_named_only_as_a_whole_word() { let mut text = String::new(); diff --git a/src/cli.rs b/src/cli.rs index 3c583aa4e..fd6e747fd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -303,7 +303,7 @@ fn print_flags(flags: &str) { ), "--tools" => ( "LIST", - "A new bot's tools; default shell,read,write,edit,wait,history", + "A new bot's tools; default shell,read,write,wait,history and apply_patch for a Responses model, else edit", ), "--allow" => ( "LIST", diff --git a/src/client.rs b/src/client.rs index 5d43f2157..13fa8412b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -13,7 +13,6 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -const DEFAULT_TOOLS: &str = "shell,read,write,edit,wait,history"; use agent_client::approver::{AUTO_EXPIRE_MS, AUTO_UNGATED, UNGATED}; use agent_client::policy::DEFAULT_COMPACTION_INSTRUCTIONS; @@ -144,7 +143,7 @@ fn parse(args: &[String]) -> Result { providers: Vec::new(), providers_explicit: false, tools_explicit: false, - tools: DEFAULT_TOOLS.into(), + tools: String::new(), allow: None, model: None, instructions: None, @@ -1376,9 +1375,14 @@ fn run(options: &Options) -> Result { }; let instructions = composed_instructions(options, workspace, role.as_ref())?; let (created_by, created_by_id) = created_by()?; - // --tools, else the role's, else the default set. + // --tools, else the role's, else the default set for its model. let tools: Vec = match role.as_ref().and_then(|r| r.tools.clone()) { Some(tools) if !options.tools_explicit => tools, + _ if !options.tools_explicit => { + agent_client::default_tools(&connection.ready["providers"], &model) + .map(str::to_owned) + .to_vec() + } _ => options .tools .split(',') @@ -2465,6 +2469,7 @@ fn summary_keys(name: &str) -> &'static [&'static str] { "shell" => &["command"], "read" => &["path", "artifact"], "write" | "edit" => &["path"], + "apply_patch" => &["input"], _ => &[], } } @@ -2515,11 +2520,35 @@ fn summary(name: &str, arguments: &str) -> String { Some(args) => args[key].as_str().map(str::to_owned), None => preview_field(arguments, key), }) + .map(|text| match name { + "apply_patch" => patch_files(&text), + _ => text, + }) .unwrap_or_else(|missing| format!("[no {missing} in the arguments shown]")) }; one_line(&text, false) } +/// The files a patch changes, from its file lines: `+path` added, +/// `-path` deleted, `path` updated, `path → new` moved. +fn patch_files(patch: &str) -> String { + let mut files: Vec = Vec::new(); + for line in patch.lines().map(str::trim) { + if let Some(path) = line.strip_prefix("*** Add File: ") { + files.push(format!("+{path}")); + } else if let Some(path) = line.strip_prefix("*** Delete File: ") { + files.push(format!("-{path}")); + } else if let Some(path) = line.strip_prefix("*** Update File: ") { + files.push(path.to_owned()); + } else if let Some(path) = line.strip_prefix("*** Move to: ") + && let Some(last) = files.last_mut() + { + last.push_str(&format!(" → {path}")); + } + } + files.join(", ") +} + /// The first line of `text`, marked when more lines follow or it was cut, /// with anything a terminal would act on shown escaped instead. fn one_line(text: &str, cut: bool) -> String { @@ -3113,6 +3142,16 @@ mod tests { ), "edit a.txt\n │ old:\n │ gone\n │ new:" ); + // A patch shows every line of itself; started, only its files. + let patch = "*** Begin Patch\n*** Update File: a.rs\n*** Move to: b.rs\n@@\n-x\n+y\n*** Add File: c.rs\n+z\n*** Delete File: d.rs\n*** End Patch"; + assert_eq!( + line("apply_patch", json!({"arguments":{"input":patch}})), + format!("apply_patch {}", patch.replace('\n', "\n │ ")) + ); + assert_eq!( + tool_line("apply_patch", &json!({"input":patch}).to_string()), + "â–¸ apply_patch a.rs → b.rs, +c.rs, -d.rs" + ); assert_eq!( line( "shell", diff --git a/src/codec.rs b/src/codec.rs index 0ed4add97..8b9d6c809 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -75,6 +75,10 @@ pub struct ToolSchema { pub name: std::borrow::Cow<'static, str>, pub description: std::borrow::Cow<'static, str>, pub parameters: Value, + /// A Lark grammar its input follows. On Responses such a tool is a + /// custom tool whose call is the free-form text itself; elsewhere a + /// function taking that text as `input`. + pub grammar: Option<&'static str>, } impl Family { @@ -157,14 +161,16 @@ impl Family { &json!({"role":"user","content":content}), )?) } - pub fn tool_result_item(self, call_id: &str, output: &str) -> Result> { - self.tool_result_item_with(call_id, output, &[]) + /// The item answering a call; `custom` for a Responses custom tool's. + pub fn tool_result_item(self, call_id: &str, custom: bool, output: &str) -> Result> { + self.tool_result_item_with(call_id, custom, output, &[]) } /// A tool's result with pictures after its text, as both families take /// them in a result: a list of parts in place of the text alone. pub fn tool_result_item_with( self, call_id: &str, + custom: bool, output: &str, images: &[Image], ) -> Result> { @@ -180,6 +186,9 @@ impl Family { } }; let item = match self { + Family::Responses if custom => { + json!({"type":"custom_tool_call_output","call_id":call_id,"output":output}) + } Family::Responses => { json!({"type":"function_call_output","call_id":call_id,"output":output}) } @@ -200,6 +209,9 @@ impl Family { let encoded: Vec = tools .iter() .map(|tool| match self { + Family::Responses if let Some(grammar) = tool.grammar => json!({"type":"custom", + "name":tool.name,"description":tool.description, + "format":{"type":"grammar","syntax":"lark","definition":grammar}}), Family::Responses => json!({"type":"function","name":tool.name, "description":tool.description,"parameters":tool.parameters,"strict":false}), Family::Anthropic => json!({"name":tool.name,"description":tool.description, @@ -435,7 +447,7 @@ mod tests { let result = |family: Family| -> Value { serde_json::from_slice( &family - .tool_result_item_with("c1", "a.png", &images) + .tool_result_item_with("c1", false, "a.png", &images) .unwrap(), ) .unwrap() @@ -500,13 +512,42 @@ mod tests { } #[test] fn families_encode_the_same_facts_differently() { - let responses: Value = - serde_json::from_slice(&Family::Responses.tool_result_item("c1", "out").unwrap()) - .unwrap(); + let responses: Value = serde_json::from_slice( + &Family::Responses + .tool_result_item("c1", false, "out") + .unwrap(), + ) + .unwrap(); assert_eq!(responses["type"], "function_call_output"); - let anthropic: Value = - serde_json::from_slice(&Family::Anthropic.tool_result_item("c1", "out").unwrap()) - .unwrap(); + let anthropic: Value = serde_json::from_slice( + &Family::Anthropic + .tool_result_item("c1", false, "out") + .unwrap(), + ) + .unwrap(); assert_eq!(anthropic["content"][0]["tool_use_id"], "c1"); + // A custom tool is answered and declared in its own shape on + // Responses, and as a function taking its text elsewhere. + let custom: Value = serde_json::from_slice( + &Family::Responses + .tool_result_item("c1", true, "out") + .unwrap(), + ) + .unwrap(); + assert_eq!(custom["type"], "custom_tool_call_output"); + let patch = ToolSchema { + name: "apply_patch".into(), + description: "d".into(), + parameters: json!({"type":"object","properties":{"input":{"type":"string"}}}), + grammar: Some("start: \"x\""), + }; + let declared = Family::Responses.tools(std::slice::from_ref(&patch)); + assert_eq!(declared[0]["type"], "custom"); + assert_eq!(declared[0]["format"]["definition"], "start: \"x\""); + let declared = Family::Anthropic.tools(std::slice::from_ref(&patch)); + assert_eq!( + declared[0]["input_schema"]["properties"]["input"]["type"], + "string" + ); } } diff --git a/src/provider.rs b/src/provider.rs index 46f7f962e..3b6a6c75a 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -297,7 +297,13 @@ pub struct Completion { pub struct ToolCall { pub name: String, pub call_id: String, + /// A JSON object. A custom tool's call carries its free-form input as + /// `{"input": ...}`, so every tool reads its arguments one way. pub arguments: String, + /// A Responses custom tool's call (`custom_tool_call`), answered with a + /// `custom_tool_call_output`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub custom: bool, } pub type ItemStream = futures_util::stream::BoxStream<'static, std::io::Result>; /// The conversation items of a request: pre-encoded, comma-separated items diff --git a/src/provider/anthropic.rs b/src/provider/anthropic.rs index 53934f1be..f0257a9b7 100644 --- a/src/provider/anthropic.rs +++ b/src/provider/anthropic.rs @@ -404,6 +404,7 @@ impl State { return fail("invalid_tool_call_id"); } calls.push(ToolCall { + custom: false, name: name.clone(), call_id: id.clone(), arguments, diff --git a/src/provider/responses.rs b/src/provider/responses.rs index 89ef2cf95..54e480af3 100644 --- a/src/provider/responses.rs +++ b/src/provider/responses.rs @@ -231,8 +231,25 @@ fn parse_completion_with_usage( } } } - Some("function_call") => { - let call: ToolCall = serde_json::from_value(item)?; + Some(kind @ ("function_call" | "custom_tool_call")) => { + let call = match kind { + "function_call" => serde_json::from_value(item)?, + _ => { + #[derive(Deserialize)] + struct Custom { + name: String, + call_id: String, + input: String, + } + let custom: Custom = serde_json::from_value(item)?; + ToolCall { + name: custom.name, + call_id: custom.call_id, + arguments: serde_json::json!({"input":custom.input}).to_string(), + custom: true, + } + } + }; if calls.iter().any(|c: &ToolCall| c.call_id == call.call_id) || !valid_call_id(&call.call_id) { @@ -287,6 +304,23 @@ mod tests { assert!(parse_completion(&calls, "").is_err()); } #[test] + fn a_custom_tool_call_reads_as_its_input() { + let raw = r#"{"status":"completed","output":[{"type":"custom_tool_call","id":"ctc_1","call_id":"c1","name":"apply_patch","input":"*** Begin Patch\n*** End Patch"}]}"#; + let completion = parse_completion(&RawValue::from_string(raw.into()).unwrap(), "").unwrap(); + assert_eq!( + completion.calls, + [ToolCall { + name: "apply_patch".into(), + call_id: "c1".into(), + arguments: json!({"input":"*** Begin Patch\n*** End Patch"}).to_string(), + custom: true, + }] + ); + // Stored as the provider sent it, so it replays unchanged. + let item = &raw[raw.find("[{").unwrap() + 1..raw.len() - 2]; + assert_eq!(completion.items[0], item.as_bytes()); + } + #[test] fn a_call_id_over_the_bound_fails_by_its_encoded_size() { let completion = |id: &str| { let call = json!({"type":"function_call","name":"echo","call_id":id,"arguments":"{}"}); diff --git a/src/server/mod.rs b/src/server/mod.rs index 4c40624cd..e62070164 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -3981,6 +3981,7 @@ mod tests { )? .turn; let call = agent_runtime::provider::ToolCall { + custom: false, name: "wait".into(), call_id: "wait-1".into(), arguments: r#"{"handles":["proc:1"]}"#.into(), @@ -4845,6 +4846,7 @@ mod tests { store .call(move |db| { let call = agent_runtime::provider::ToolCall { + custom: false, name: "wait".into(), call_id: "wait-1".into(), arguments: json!({"handles":[parked]}).to_string(), @@ -5859,6 +5861,7 @@ mod tests { store .call(move |db| { let call = agent_runtime::provider::ToolCall { + custom: false, name: "wait".into(), call_id: "wait-1".into(), arguments: r#"{"handles":["proc:1"]}"#.into(), @@ -5929,6 +5932,7 @@ mod tests { )? .turn; let call = agent_runtime::provider::ToolCall { + custom: false, name: "shell".into(), call_id: "shell-1".into(), arguments: r#"{"command":"true"}"#.into(), diff --git a/src/server/turn.rs b/src/server/turn.rs index 63a6482b1..9f752e243 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -3857,7 +3857,7 @@ impl Turn { // all count: shrink the page by what the item overshoots until // the item itself fits. let output = self.registry.redact_text(page); - let item = family.tool_result_item(call_id, &output)?.len(); + let item = family.tool_result_item(call_id, false, &output)?.len(); if item <= budget { return Ok(Outcome::text(output)); } @@ -3888,7 +3888,7 @@ impl Turn { }) .await?; let output = self.registry.redact_text(listed.to_string()); - if family.tool_result_item(call_id, &output)?.len() > budget { + if family.tool_result_item(call_id, false, &output)?.len() > budget { return fail("history_context_exhausted"); } return Ok(Outcome::text(output)); @@ -3904,7 +3904,7 @@ impl Turn { // Page metadata, JSON escaping, redaction, and the provider's tool // result envelope all count. Shrink in memory, without rereading // history, until the actual encoded item fits the reserved budget. - if family.tool_result_item(call_id, &output)?.len() <= budget { + if family.tool_result_item(call_id, false, &output)?.len() <= budget { return Ok(Outcome::text(output)); } let text = page["text"].as_str().unwrap(); diff --git a/src/store/context.rs b/src/store/context.rs index df71e93e2..711d545c5 100644 --- a/src/store/context.rs +++ b/src/store/context.rs @@ -337,6 +337,7 @@ pub const ELISION_MIN_SAVING: usize = 1024; pub fn stub( family: Family, call_id: &str, + custom: bool, output: &str, node: i64, item: usize, @@ -358,7 +359,7 @@ pub fn stub( &output[..head], &output[tail..] ); - let stub = family.tool_result_item(call_id, &text)?; + let stub = family.tool_result_item(call_id, custom, &text)?; Ok((stub.len() + ELISION_MIN_SAVING <= item).then_some(stub)) } @@ -377,7 +378,7 @@ pub fn picture_stub( let text = format!( "[tool result's picture elided from this request; read the file again to see it. Its text:]\n{output}" ); - let stub = family.tool_result_item(call_id, &text)?; + let stub = family.tool_result_item(call_id, false, &text)?; Ok((stub.len() < weight).then_some(stub)) } @@ -442,7 +443,7 @@ pub fn tool_result(item: &[u8]) -> Option<(Family, String, String)> { content: Text, } if let Ok(result) = serde_json::from_slice::(item) { - return (result.kind == "function_call_output").then_some(( + return RESULT_KINDS.contains(&result.kind.as_str()).then_some(( Family::Responses, result.call_id, result.output.text(), @@ -454,8 +455,13 @@ pub fn tool_result(item: &[u8]) -> Option<(Family, String, String)> { .then_some((Family::Anthropic, block.tool_use_id, block.content.text())) } +/// The Responses items that answer a call: a function's, or a custom +/// tool's. +pub const RESULT_KINDS: [&str; 2] = ["function_call_output", "custom_tool_call_output"]; + /// Whether a stored item is a tool result, in either family: a Responses -/// function call output, or a Messages user turn of tool result blocks. +/// function or custom tool call output, or a Messages user turn of tool +/// result blocks. pub fn is_tool_result(item: &[u8]) -> bool { #[derive(serde::Deserialize)] struct Item<'a> { @@ -474,7 +480,11 @@ pub fn is_tool_result(item: &[u8]) -> bool { let Ok(item) = serde_json::from_slice::>(item) else { return false; }; - if item.kind.as_deref() == Some("function_call_output") { + if item + .kind + .as_deref() + .is_some_and(|kind| RESULT_KINDS.contains(&kind)) + { return true; } item.role.as_deref() == Some("user") @@ -497,7 +507,11 @@ pub fn model_output(item: &[u8]) -> bool { kind: Option>, } serde_json::from_slice::>(item).is_ok_and(|item| { - item.role.as_deref() != Some("user") && item.kind.as_deref() != Some("function_call_output") + item.role.as_deref() != Some("user") + && !item + .kind + .as_deref() + .is_some_and(|kind| RESULT_KINDS.contains(&kind)) }) } @@ -523,7 +537,7 @@ mod tests { }]; for family in [Family::Responses, Family::Anthropic] { let item = family - .tool_result_item_with("c1", "a.png: a PNG image", &images) + .tool_result_item_with("c1", false, "a.png: a PNG image", &images) .unwrap(); assert!(is_tool_result(&item)); let (read, call_id, text) = tool_result(&item).unwrap(); diff --git a/src/store/db.rs b/src/store/db.rs index 0e3e48c42..af05560db 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -981,7 +981,7 @@ pub struct Answered { pub resume: bool, } /// The fields of a stored item that say which calls it plans, in either -/// encoding: a Responses `function_call`, or an Anthropic message's +/// encoding: a Responses `function_call` or `custom_tool_call`, or an Anthropic message's /// `tool_use` blocks. Everything else is skipped. #[derive(serde::Deserialize)] struct Plans<'a> { @@ -1005,7 +1005,10 @@ fn planned_calls(item: &[u8]) -> Vec> { let Ok(item) = serde_json::from_slice::(item) else { return Vec::new(); }; - if item.kind.as_deref() == Some("function_call") { + if matches!( + item.kind.as_deref(), + Some("function_call" | "custom_tool_call") + ) { return item.call_id.into_iter().collect(); } item.content @@ -1465,7 +1468,7 @@ impl Database { /// Stored schema version, kept in `PRAGMA user_version`. Stores created /// before versioning and stores from newer binaries are rejected; an older /// versioned store is migrated forward, one version at a time, at open. - pub const SCHEMA: i32 = 58; + pub const SCHEMA: i32 = 59; /// Verbatim user prompts a compaction keeps: per-prompt text, and the /// total text plus `(ordinal, String)` entry metadata. Empty entries cost /// space too, so the retained list cannot grow with conversation length. @@ -1646,7 +1649,7 @@ impl Database { CREATE TABLE IF NOT EXISTS checkpoints(bot TEXT NOT NULL REFERENCES bots(name), head INTEGER NOT NULL REFERENCES nodes(id), PRIMARY KEY(bot,head)); CREATE TABLE IF NOT EXISTS tools(turn INTEGER NOT NULL REFERENCES turns(id), call_id TEXT NOT NULL, - status TEXT NOT NULL, PRIMARY KEY(turn,call_id)); + status TEXT NOT NULL, custom INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(turn,call_id)); CREATE TABLE IF NOT EXISTS approvals(id INTEGER PRIMARY KEY AUTOINCREMENT, turn INTEGER NOT NULL REFERENCES turns(id), call_id TEXT NOT NULL, name TEXT NOT NULL, node INTEGER NOT NULL, request INTEGER NOT NULL, announced_ms INTEGER NOT NULL, gates TEXT NOT NULL, verdicts TEXT, arguments TEXT NOT NULL, asked TEXT); @@ -4066,7 +4069,10 @@ impl Database { outcome.note.clone().ok_or(Error::new("missing_note"))?, ); let (family, mut used) = self.minimum_context_usage(name, turn, Some(¬e))?; - used.bytes += family.tool_result_item(call_id, &outcome.output)?.len() + 1; + used.bytes += family + .tool_result_item(call_id, false, &outcome.output)? + .len() + + 1; used.items += 1; if !used.fits(limit) { return crate::fail_with( @@ -5086,8 +5092,11 @@ impl Database { // the same way, and the round commits nothing. for call in calls { let planned = tx - .prepare_cached("INSERT INTO tools VALUES (?,?,'planned') ON CONFLICT DO NOTHING")? - .execute(params![turn, call.call_id])?; + .prepare_cached( + "INSERT INTO tools(turn,call_id,status,custom) VALUES (?,?,'planned',?) + ON CONFLICT DO NOTHING", + )? + .execute(params![turn, call.call_id, call.custom])?; if planned == 0 { return Err(Error::new("invalid_tool_call_id")); } @@ -5296,7 +5305,17 @@ impl Database { ) -> Result<(Bytes, Value)> { let bot = self.active(turn)?; let family = bot.family()?; - let item = family.tool_result_item_with(call_id, &outcome.output, &outcome.images)?; + let custom: bool = self + .conn + .query_row( + "SELECT custom FROM tools WHERE turn=? AND call_id=?", + params![turn, call_id], + |r| r.get(0), + ) + .optional()? + .unwrap_or(false); + let item = + family.tool_result_item_with(call_id, custom, &outcome.output, &outcome.images)?; // Verdicts are for the round as planned: after a failure the gated // calls still to run are announced again, in this same commit. // Unless one of them lapsed while this call ran: a new request would @@ -5328,7 +5347,7 @@ impl Database { let stub = if !outcome.images.is_empty() { super::context::picture_stub(family, call_id, &outcome.output, weight)? } else if bot.tools.iter().any(|tool| tool == "read") { - super::context::stub(family, call_id, &outcome.output, next, weight)? + super::context::stub(family, call_id, custom, &outcome.output, next, weight)? } else { None }; @@ -6618,12 +6637,12 @@ impl Database { |r| r.get(0), )?; if pending { - let unanswered: Vec<(String, String)> = tx - .prepare("SELECT call_id,status FROM tools WHERE turn=? AND status IN ('planned','executing') ORDER BY rowid")? - .query_map([turn], |r| Ok((r.get(0)?, r.get(1)?)))? + let unanswered: Vec<(String, String, bool)> = tx + .prepare("SELECT call_id,status,custom FROM tools WHERE turn=? AND status IN ('planned','executing') ORDER BY rowid")? + .query_map([turn], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? .collect::>()?; let family = bot.family()?; - for (call_id, status) in unanswered { + for (call_id, status, custom) in unanswered { let unknown = status == "executing" && waiting.is_none(); let detail = if unknown { "turn ended before this tool's result was recorded; it may have had effects and may still be running. Inspect the current state before retrying" @@ -6633,7 +6652,7 @@ impl Database { "turn ended before this call ran" }; let output = json!({"error":if unknown { "tool_outcome_unknown" } else { "cancelled" },"detail":detail}).to_string(); - let item = family.tool_result_item(&call_id, &output)?; + let item = family.tool_result_item(&call_id, custom, &output)?; let id = node(&tx, head, &item)?; head = Some(id); tx.execute( @@ -7244,12 +7263,12 @@ impl Database { }; let item: Value = serde_json::from_slice(&raw)?; match item["type"].as_str() { - Some("function_call") => { + Some("function_call" | "custom_tool_call") => { if !answered.remove(item["call_id"].as_str().unwrap_or("")) { return fail("fork_point_has_open_tool_calls"); } } - Some("function_call_output") => { + Some("function_call_output" | "custom_tool_call_output") => { answered.insert(item["call_id"].as_str().unwrap_or("").to_owned()); } _ => { @@ -8288,7 +8307,8 @@ impl Database { SELECT 1 FROM chain c JOIN nodes n ON n.id=c.id WHERE c.id>(SELECT id FROM chain WHERE turn=?2) AND c.id?2),9223372036854775807) - AND ((json_extract(CAST(n.item AS TEXT),'$.type')='function_call_output' + AND ((json_extract(CAST(n.item AS TEXT),'$.type') + IN ('function_call_output','custom_tool_call_output') AND json_extract(CAST(n.item AS TEXT),'$.call_id')=?3) OR EXISTS(SELECT 1 FROM json_each(CAST(n.item AS TEXT),'$.content') WHERE json_extract(value,'$.type')='tool_result' @@ -8506,7 +8526,9 @@ fn deny( ) -> Result<()> { let call_id = call.call_id.as_str(); let output = json!({"error":"approval_denied","detail":reason}).to_string(); - let item = bot.family()?.tool_result_item(call_id, &output)?; + let item = bot + .family()? + .tool_result_item(call_id, call.custom, &output)?; if tx.execute( "UPDATE tools SET status='completed' WHERE turn=? AND call_id=? AND status='planned'", params![turn, call_id], @@ -9765,6 +9787,16 @@ fn migrate(conn: &Connection, from: i32) -> Result<()> { // Those older nodes that have any count none and keep them. conn.execute_batch("ALTER TABLE nodes ADD COLUMN pictures INTEGER NOT NULL DEFAULT 0;")?; } + if conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='tools') + AND NOT EXISTS(SELECT 1 FROM pragma_table_info('tools') WHERE name='custom')", + [], + |r| r.get::<_, bool>(0), + )? { + // 58 -> 59: a Responses custom tool's call is answered in its own + // kind of item. No custom tool was declared before. + conn.execute_batch("ALTER TABLE tools ADD COLUMN custom INTEGER NOT NULL DEFAULT 0;")?; + } Ok(()) } /// 53 -> 54: a turn's denial count became a window of its last decided diff --git a/src/tools.rs b/src/tools.rs index cce3917e9..5cb0adfba 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -22,8 +22,10 @@ use tokio::{ }; mod files; +mod patch; mod picture; use files::read_bounded; +pub use patch::Patch; /// Model-facing text per tool result stream. pub const PREVIEW_BYTES: usize = 64 * 1024; @@ -52,6 +54,7 @@ enum Tool { Read, Write, Edit, + ApplyPatch, Wait, History, Note, @@ -64,6 +67,7 @@ impl Tool { "read" => Tool::Read, "write" => Tool::Write, "edit" => Tool::Edit, + "apply_patch" => Tool::ApplyPatch, "wait" => Tool::Wait, "history" => Tool::History, "note" => Tool::Note, @@ -77,6 +81,7 @@ impl Tool { Tool::Read => "read", Tool::Write => "write", Tool::Edit => "edit", + Tool::ApplyPatch => "apply_patch", Tool::Wait => "wait", Tool::History => "history", Tool::Note => "note", @@ -113,6 +118,11 @@ impl Tool { "new":{"type":"string"},"replace_all":{"type":"boolean"}}, "required":["path","old","new"],"additionalProperties":false}), ), + Tool::ApplyPatch => ( + "Edit files with a patch: add, update, move and delete any number of files in one call. The patch is checked against every file first, so one that does not apply changes nothing. Format: `*** Begin Patch`, then for each file `*** Add File: PATH` followed by its lines each starting with +, or `*** Delete File: PATH`, or `*** Update File: PATH` with an optional `*** Move to: NEW_PATH` and chunks of lines starting with a space (kept), - (removed) or + (added); an `@@ LINE` before a chunk names a line above it, such as a function's, to place it, and `*** End of File` after a chunk anchors it at the end. Show about three kept lines around each change. Finish with `*** End Patch`. Paths are relative to the workspace unless absolute.", + json!({"type":"object","properties":{"input":{"type":"string","description":"The whole patch, from *** Begin Patch to *** End Patch."}}, + "required":["input"],"additionalProperties":false}), + ), Tool::History => ( "Read a conversation turn: one of yours the context left out (numbered from 1; see the context note), or with bot, another agent's, as it stands, including one still running. With bot and no turn, lists that agent's newest turns, newest first: each one's number, handle, status and prompt. Returns provider items as JSONL, omitting only encrypted_content from reasoning items; summaries remain. Pages contain at most 64 KiB and may shrink to fit context. Optional limit: 4 to 65536 bytes. Start at offset 0; use next_offset until done. Offsets count UTF-8 bytes of the filtered view. Pages may split records; concatenate text before decoding.", json!({"type":"object","properties":{"bot":{"type":"string","minLength":1,"maxLength":128}, @@ -135,6 +145,7 @@ impl Tool { name: self.name().into(), description: description.into(), parameters, + grammar: (self == Tool::ApplyPatch).then_some(patch::GRAMMAR), } } } @@ -639,6 +650,7 @@ pub enum Prepared { path: String, content: String, }, + ApplyPatch(Patch), Edit { path: String, old: String, @@ -731,9 +743,9 @@ impl Registry { /// of them a bot may call is the bot's own, chosen at creation. pub fn all() -> Result { Self::new(if cfg!(unix) { - "echo,shell,read,write,edit,wait,history,note" + "echo,shell,read,write,edit,apply_patch,wait,history,note" } else { - "echo,read,write,edit,wait,history,note" + "echo,read,write,edit,apply_patch,wait,history,note" }) } pub fn new(names: &str) -> Result { @@ -809,6 +821,7 @@ impl Registry { name: tool.name.clone().into(), description: tool.description.clone().into(), parameters: tool.parameters.clone(), + grammar: None, })) .collect(); let encoded: Arc = Arc::from(RawValue::from_string(serde_json::to_string( @@ -962,6 +975,11 @@ impl Registry { } #[derive(Deserialize)] #[serde(deny_unknown_fields)] + struct ApplyPatch { + input: String, + } + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] struct Edit { path: String, old: String, @@ -1117,6 +1135,17 @@ impl Registry { content: args.content, } } + Tool::ApplyPatch => { + let args: ApplyPatch = serde_json::from_str(args).map_err(invalid)?; + if args.input.len() > WRITE_BYTES { + return fail("tool_input_limit"); + } + let patch = Patch::parse(&args.input)?; + for target in patch.paths() { + path(target)?; + } + Prepared::ApplyPatch(patch) + } Tool::Edit => { let args: Edit = serde_json::from_str(args).map_err(invalid)?; path(&args.path)?; @@ -1214,6 +1243,7 @@ impl Registry { path ))) } + Prepared::ApplyPatch(patch) => Ok(Outcome::text(patch.apply(workspace).await?)), Prepared::Edit { path, old, diff --git a/src/tools/patch.rs b/src/tools/patch.rs new file mode 100644 index 000000000..9d2725207 --- /dev/null +++ b/src/tools/patch.rs @@ -0,0 +1,552 @@ +//! `apply_patch`: the patch format OpenAI's models are trained to edit +//! with. A patch is parsed whole, then every change is computed against +//! the files as they are, and only then is anything written, so a patch +//! that does not apply changes nothing. +//! +//! ```text +//! *** Begin Patch +//! *** Add File: path +//! +line +//! *** Update File: path +//! *** Move to: new/path +//! @@ fn context +//! kept +//! -removed +//! +added +//! *** End of File +//! *** Delete File: path +//! *** End Patch +//! ``` +use super::{FILE_BYTES, files}; +use crate::{Error, Result}; +use std::path::{Path, PathBuf}; + +/// The Lark grammar OpenAI models are given for this tool, as Codex gives +/// it. A model sends a patch that fits it as the call's raw input. +pub const GRAMMAR: &str = r#"start: begin_patch hunk+ end_patch +begin_patch: "*** Begin Patch" LF +end_patch: "*** End Patch" LF? + +hunk: add_hunk | delete_hunk | update_hunk +add_hunk: "*** Add File: " filename LF add_line+ +delete_hunk: "*** Delete File: " filename LF +update_hunk: "*** Update File: " filename LF change_move? change? + +filename: /(.+)/ +add_line: "+" /(.*)/ LF -> line + +change_move: "*** Move to: " filename LF +change: (change_context | change_line)+ eof_line? +change_context: ("@@" | "@@ " /(.+)/) LF +change_line: ("+" | "-" | " ") /(.*)/ LF +eof_line: "*** End of File" LF + +%import common.LF +"#; + +/// One file's change. +#[derive(Debug, PartialEq)] +pub enum Change { + Add { + path: String, + contents: String, + }, + Delete { + path: String, + }, + Update { + path: String, + move_to: Option, + chunks: Vec, + }, +} + +/// A run of lines to find and replace, after an optional `@@` line that +/// places it. +#[derive(Debug, Default, PartialEq)] +pub struct Chunk { + context: Option, + old: Vec, + new: Vec, + end_of_file: bool, +} + +/// A parsed patch: its changes, in order. +#[derive(Debug, PartialEq)] +pub struct Patch { + pub changes: Vec, +} + +fn invalid(line: usize, detail: impl std::fmt::Display) -> Error { + Error::with("invalid_patch", format!("line {line}: {detail}")) +} + +impl Patch { + /// Parse a patch. A heredoc around it, as a model writes when it + /// thinks it is running a command, is taken off first. + pub fn parse(text: &str) -> Result { + let mut lines: Vec<&str> = text.trim().lines().collect(); + if lines.len() >= 2 + && lines[0].trim_start().starts_with("<<") + && lines[lines.len() - 1].trim() == "EOF" + { + lines = lines[1..lines.len() - 1].to_vec(); + } + let first = lines.first().map(|line| line.trim()); + if first != Some("*** Begin Patch") { + return Err(invalid(1, "a patch starts with *** Begin Patch")); + } + if lines.len() < 2 || lines[lines.len() - 1].trim() != "*** End Patch" { + return Err(invalid(lines.len(), "a patch ends with *** End Patch")); + } + let body = &lines[1..lines.len() - 1]; + let mut changes = Vec::new(); + let mut at = 0; + while at < body.len() { + let number = at + 2; + let line = body[at].trim_end(); + at += 1; + if let Some(path) = line.strip_prefix("*** Add File: ") { + let mut contents = String::new(); + while at < body.len() && !body[at].starts_with("*** ") { + let Some(text) = body[at].strip_prefix('+') else { + return Err(invalid(at + 2, "every line of an added file starts with +")); + }; + contents.push_str(text); + contents.push('\n'); + at += 1; + } + changes.push(Change::Add { + path: path_of(path, number)?, + contents, + }); + } else if let Some(path) = line.strip_prefix("*** Delete File: ") { + changes.push(Change::Delete { + path: path_of(path, number)?, + }); + } else if let Some(path) = line.strip_prefix("*** Update File: ") { + let path = path_of(path, number)?; + let mut move_to = None; + if let Some(to) = body.get(at).and_then(|l| l.strip_prefix("*** Move to: ")) { + move_to = Some(path_of(to, at + 2)?); + at += 1; + } + let chunks = chunks(body, &mut at)?; + if chunks.is_empty() && move_to.is_none() { + return Err(invalid( + number, + format!("{path}: an update changes nothing"), + )); + } + changes.push(Change::Update { + path, + move_to, + chunks, + }); + } else if line.is_empty() { + continue; + } else { + return Err(invalid( + number, + format!( + "expected *** Add File, *** Update File or *** Delete File, found {line:?}" + ), + )); + } + } + if changes.is_empty() { + return Err(invalid(1, "a patch changes at least one file")); + } + Ok(Patch { changes }) + } + + /// Every path the patch writes or removes, as written in it: each + /// added, updated and deleted file, and both ends of a move. Nothing + /// is touched before a caller has seen them all. + pub fn paths(&self) -> impl Iterator { + self.changes + .iter() + .flat_map(|change| match change { + Change::Add { path, .. } | Change::Delete { path } => [Some(path.as_str()), None], + Change::Update { path, move_to, .. } => [Some(path.as_str()), move_to.as_deref()], + }) + .flatten() + } + + /// Apply the patch under `workspace`: read and check every file first, + /// then write. The summary lists each file as added (A), modified (M) + /// or deleted (D). + pub async fn apply(&self, workspace: &Path) -> Result { + enum Write { + Put(PathBuf, String), + Remove(PathBuf), + } + let mut writes = Vec::new(); + let mut summary = String::from("Success. Updated the following files:\n"); + for change in &self.changes { + match change { + Change::Add { path, contents } => { + writes.push(Write::Put(workspace.join(path), contents.clone())); + summary.push_str(&format!("A {path}\n")); + } + Change::Delete { path } => { + let target = workspace.join(path); + // Only a regular file is removed. + match tokio::fs::symlink_metadata(&target).await { + Ok(metadata) if metadata.is_file() || metadata.is_symlink() => {} + Ok(_) => return Err(Error::with("file_not_regular", path.clone())), + Err(_) => return Err(Error::with("file_unreadable", path.clone())), + } + writes.push(Write::Remove(target)); + summary.push_str(&format!("D {path}\n")); + } + Change::Update { + path, + move_to, + chunks, + } => { + let source = workspace.join(path); + let bytes = files::read_bounded(&source).await?; + let text = String::from_utf8(bytes) + .map_err(|_| Error::with("file_not_utf8", path.clone()))?; + let updated = update(&text, chunks, path)?; + if updated.len() > FILE_BYTES { + return Err(Error::with("tool_output_limit", path.clone())); + } + match move_to { + Some(to) => { + writes.push(Write::Put(workspace.join(to), updated)); + writes.push(Write::Remove(source)); + summary.push_str(&format!("M {to}\n")); + } + None => { + writes.push(Write::Put(source, updated)); + summary.push_str(&format!("M {path}\n")); + } + } + } + } + } + for write in writes { + match write { + Write::Put(target, contents) => { + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await?; + } + files::write(&target, contents.as_bytes()).await?; + } + Write::Remove(target) => tokio::fs::remove_file(&target).await?, + } + } + Ok(summary) + } +} + +fn path_of(path: &str, line: usize) -> Result { + let path = path.trim(); + if path.is_empty() || path.len() > 4096 || path.contains('\0') { + return Err(invalid(line, "a file path is 1 to 4096 bytes")); + } + Ok(path.to_owned()) +} + +/// An update's chunks, up to the next file header. +fn chunks(body: &[&str], at: &mut usize) -> Result> { + let mut chunks: Vec = Vec::new(); + let mut current: Option = None; + while *at < body.len() && !is_header(body[*at]) { + let line = body[*at]; + let number = *at + 2; + *at += 1; + if line.trim_end() == "*** End of File" { + let Some(chunk) = current.as_mut() else { + return Err(invalid(number, "*** End of File ends a chunk")); + }; + chunk.end_of_file = true; + chunks.extend(current.take()); + continue; + } + if let Some(rest) = line.strip_prefix("@@") { + chunks.extend(current.take()); + let context = rest.trim(); + current = Some(Chunk { + context: (!context.is_empty()) + .then(|| rest.strip_prefix(' ').unwrap_or(rest).to_owned()), + ..Chunk::default() + }); + continue; + } + let chunk = current.get_or_insert_with(Chunk::default); + match line.chars().next() { + Some('+') => chunk.new.push(line[1..].to_owned()), + Some('-') => chunk.old.push(line[1..].to_owned()), + Some(' ') => { + chunk.old.push(line[1..].to_owned()); + chunk.new.push(line[1..].to_owned()); + } + // An empty line is an empty context line whose space was lost. + None => { + chunk.old.push(String::new()); + chunk.new.push(String::new()); + } + Some(_) => { + return Err(invalid( + number, + format!("a change line starts with +, - or a space, found {line:?}"), + )); + } + } + } + chunks.extend(current); + Ok(chunks) +} + +fn is_header(line: &str) -> bool { + ["*** Add File: ", "*** Delete File: ", "*** Update File: "] + .iter() + .any(|header| line.starts_with(header)) +} + +/// `text` with every chunk applied in order, each found at or after the +/// end of the one before it. +fn update(text: &str, chunks: &[Chunk], path: &str) -> Result { + let crlf = text.contains("\r\n"); + let mut lines: Vec<&str> = text + .split('\n') + .map(|line| line.strip_suffix('\r').unwrap_or(line)) + .collect(); + // The empty piece after a final newline is not a line. + if lines.last() == Some(&"") { + lines.pop(); + } + let mut replacements: Vec<(usize, usize, &[String])> = Vec::new(); + let mut cursor = 0; + for chunk in chunks { + if let Some(context) = &chunk.context { + let found = + seek(&lines, std::slice::from_ref(context), cursor, false).ok_or_else(|| { + Error::with( + "patch_context_not_found", + format!("{path}: no line matches @@ {context}"), + ) + })?; + cursor = found + 1; + } + if chunk.old.is_empty() { + let at = if chunk.context.is_some() { + cursor + } else { + lines.len() + }; + replacements.push((at, 0, &chunk.new)); + continue; + } + let (mut old, mut new) = (&chunk.old[..], &chunk.new[..]); + let mut found = seek(&lines, old, cursor, chunk.end_of_file); + // A trailing empty line can stand for the file's final newline. + if found.is_none() && old.last().is_some_and(String::is_empty) { + old = &old[..old.len() - 1]; + if new.last().is_some_and(String::is_empty) { + new = &new[..new.len() - 1]; + } + found = seek(&lines, old, cursor, chunk.end_of_file); + } + let at = found.ok_or_else(|| { + Error::with( + "patch_context_not_found", + format!( + "{path}: these lines were not found{}:\n{}", + if cursor > 0 { + " after the previous chunk" + } else { + "" + }, + chunk.old.join("\n") + ), + ) + })?; + replacements.push((at, old.len(), new)); + cursor = at + old.len(); + } + let mut out: Vec<&str> = Vec::with_capacity(lines.len()); + let mut next = 0; + for (at, removed, added) in replacements { + out.extend(&lines[next..at]); + out.extend(added.iter().map(String::as_str)); + next = at + removed; + } + out.extend(&lines[next..]); + let eol = if crlf { "\r\n" } else { "\n" }; + let mut updated = out.join(eol); + if !updated.is_empty() { + updated.push_str(eol); + } + Ok(updated) +} + +/// Where `pattern` first occurs in `lines` at or after `start`, matched +/// exactly, then ignoring trailing whitespace, then surrounding +/// whitespace, then common typographic punctuation. With `end_of_file` +/// a match at the very end is preferred. +fn seek(lines: &[&str], pattern: &[String], start: usize, end_of_file: bool) -> Option { + if pattern.len() > lines.len() { + return None; + } + let last = lines.len() - pattern.len(); + let from = if end_of_file { last.max(start) } else { start }; + let passes: [fn(&str) -> String; 4] = [ + str::to_owned, + |line| line.trim_end().to_owned(), + |line| line.trim().to_owned(), + |line| line.trim().chars().map(plain).collect(), + ]; + for norm in passes { + let wanted: Vec = pattern.iter().map(|line| norm(line)).collect(); + for at in (from..=last).chain(start..from.min(last + 1)) { + if lines[at..at + pattern.len()] + .iter() + .zip(&wanted) + .all(|(line, wanted)| norm(line) == *wanted) + { + return Some(at); + } + } + } + None +} + +/// Typographic dashes, quotes and spaces as their ASCII forms. +fn plain(c: char) -> char { + match c { + '\u{2010}'..='\u{2015}' | '\u{2212}' => '-', + '\u{2018}'..='\u{201B}' => '\'', + '\u{201C}'..='\u{201F}' => '"', + '\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ', + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn patch(body: &str) -> Patch { + Patch::parse(&format!("*** Begin Patch\n{body}*** End Patch\n")).unwrap() + } + + #[test] + fn parses_every_kind_of_change_and_lists_its_paths() { + let parsed = patch( + "*** Add File: a.txt\n+one\n+two\n*** Delete File: b.txt\n\ + *** Update File: c.txt\n*** Move to: d/c.txt\n@@ fn main\n keep\n-old\n+new\n", + ); + assert_eq!( + parsed.paths().collect::>(), + ["a.txt", "b.txt", "c.txt", "d/c.txt"] + ); + assert_eq!( + parsed.changes[0], + Change::Add { + path: "a.txt".into(), + contents: "one\ntwo\n".into() + } + ); + let Change::Update { chunks, .. } = &parsed.changes[2] else { + panic!("an update") + }; + assert_eq!(chunks[0].context.as_deref(), Some("fn main")); + assert_eq!((chunks[0].old.len(), chunks[0].new.len()), (2, 2)); + // A heredoc around the patch is taken off. + let wrapped = "<<'EOF'\n*** Begin Patch\n*** Delete File: x\n*** End Patch\nEOF\n"; + assert_eq!( + Patch::parse(wrapped).unwrap().paths().collect::>(), + ["x"] + ); + } + + #[test] + fn malformed_patches_say_where() { + for (text, wanted) in [ + ("*** Delete File: x\n", "line 1"), + ("*** Begin Patch\n*** Delete File: x\n", "End Patch"), + ( + "*** Begin Patch\n*** Add File: x\nnot plus\n*** End Patch", + "line 3", + ), + ("*** Begin Patch\nrandom\n*** End Patch", "line 2"), + ( + "*** Begin Patch\n*** Update File: x\n*** End Patch", + "changes nothing", + ), + ("*** Begin Patch\n*** End Patch", "at least one file"), + ] { + let error = Patch::parse(text).unwrap_err(); + assert_eq!(error.code, "invalid_patch", "{text}"); + assert!( + error.detail.as_deref().unwrap().contains(wanted), + "{error:?}" + ); + } + } + + #[test] + fn chunks_apply_in_order_with_lenient_matching() { + let text = "fn a() {\n x();\n}\nfn b() {\n x();\n}\n"; + let parsed = patch("*** Update File: f\n@@ fn b() {\n- x();\n+ y();\n"); + let Change::Update { chunks, .. } = &parsed.changes[0] else { + panic!() + }; + // The @@ line places the chunk: the second x() changes. + assert_eq!( + update(text, chunks, "f").unwrap(), + "fn a() {\n x();\n}\nfn b() {\n y();\n}\n" + ); + // Trailing spaces and typographic quotes still match. + let parsed = patch("*** Update File: f\n-say(\"hi\")\n+say(\"bye\")\n"); + let Change::Update { chunks, .. } = &parsed.changes[0] else { + panic!() + }; + assert_eq!( + update("say(\u{201C}hi\u{201D}) \r\nend\r\n", chunks, "f").unwrap(), + "say(\"bye\")\r\nend\r\n" + ); + // Lines that are not there fail with the lines it looked for. + let error = update("other\n", chunks, "f").unwrap_err(); + assert_eq!(error.code, "patch_context_not_found"); + assert!(error.detail.unwrap().contains("say(\"hi\")")); + } + + #[tokio::test] + async fn a_patch_that_fails_anywhere_writes_nothing() { + let dir = std::env::temp_dir().join(format!("patch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("keep.txt"), "a\nb\n").unwrap(); + std::fs::write(dir.join("gone.txt"), "x\n").unwrap(); + let failing = + patch("*** Add File: new.txt\n+n\n*** Update File: keep.txt\n-missing\n+b2\n"); + assert_eq!( + failing.apply(&dir).await.unwrap_err().code, + "patch_context_not_found" + ); + assert!(!dir.join("new.txt").exists()); + let good = patch( + "*** Add File: sub/new.txt\n+n\n*** Update File: keep.txt\n*** Move to: moved.txt\n a\n-b\n+b2\n\ + *** Delete File: gone.txt\n", + ); + assert_eq!( + good.apply(&dir).await.unwrap(), + "Success. Updated the following files:\nA sub/new.txt\nM moved.txt\nD gone.txt\n" + ); + assert_eq!( + std::fs::read_to_string(dir.join("sub/new.txt")).unwrap(), + "n\n" + ); + assert_eq!( + std::fs::read_to_string(dir.join("moved.txt")).unwrap(), + "a\nb2\n" + ); + assert!(!dir.join("keep.txt").exists() && !dir.join("gone.txt").exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/tests/store_contract.rs b/tests/store_contract.rs index 734d9334c..e67e8a5e5 100644 --- a/tests/store_contract.rs +++ b/tests/store_contract.rs @@ -374,6 +374,7 @@ fn unfinished_tools_are_answered_truthfully_without_disabling_the_bot() { .unwrap() .turn; let calls = ["executing", "planned"].map(|id| ToolCall { + custom: false, name: "write".into(), call_id: id.into(), arguments: r#"{"path":"file","content":"hello"}"#.into(), @@ -483,6 +484,7 @@ fn restart_repairs_unanswered_tools_once_including_previously_blocked_bots() { .unwrap() .turn; let call = ToolCall { + custom: false, name: "write".into(), call_id: "c1".into(), arguments: "{}".into(), @@ -491,6 +493,7 @@ fn restart_repairs_unanswered_tools_once_including_previously_blocked_bots() { // must repair only the unanswered suffix, not duplicate results. for id in ["done", "c1"] { let call = ToolCall { + custom: false, call_id: id.into(), ..call.clone() }; @@ -576,6 +579,7 @@ fn tool_results_and_cursor_events_commit_together() { .unwrap() .turn; let call = ToolCall { + custom: false, name: "echo".into(), call_id: "c1".into(), arguments: "{}".into(), @@ -633,6 +637,7 @@ fn shell_call(item_id: &str, call_id: &str, command: &str) -> (Bytes, ToolCall) let item = json!({"type":"function_call","id":item_id,"call_id":call_id, "name":"shell","arguments":arguments}); let call = ToolCall { + custom: false, name: "shell".into(), call_id: call_id.into(), arguments, @@ -1044,6 +1049,7 @@ fn lapsing_round(db: &mut Database, calls: &[(&str, &str)]) -> (i64, Vec (Bytes, ToolCall) { .unwrap() .into(); let call = ToolCall { + custom: false, name: "echo".into(), call_id: call_id.into(), arguments: "{}".into(), @@ -5364,6 +5374,7 @@ fn forks_preserve_reasoning_pairs_and_require_every_parallel_result() { let calls: Vec = ["c1", "c2"] .into_iter() .map(|id| ToolCall { + custom: false, name: "echo".into(), call_id: id.into(), arguments: "{}".into(), @@ -5489,6 +5500,7 @@ fn anthropic_forks_check_the_whole_tool_batch_after_a_checkpoint() { let calls: Vec = ["a", "b"] .into_iter() .map(|id| ToolCall { + custom: false, name: "echo".into(), call_id: id.into(), arguments: "{}".into(), @@ -6559,6 +6571,7 @@ fn ready_turns_wait_for_a_slot_and_survive_interrupted_predecessors() { .begin("Carol", "c2", "more", true, &queue, allow_provider) .unwrap(); let call = ToolCall { + custom: false, name: "echo".into(), call_id: "call-1".into(), arguments: "{}".into(), @@ -6678,6 +6691,7 @@ fn retention_preserves_unfinished_turns_and_their_completed_prefix() { .unwrap(); // A newer terminal row must not move retention past live tool intents. let call = ToolCall { + custom: false, name: "echo".into(), call_id: "live".into(), arguments: "{}".into(), @@ -8566,6 +8580,7 @@ fn carry_forward_notes_are_versioned_by_result_node_and_forks_bind_by_checkpoint .unwrap() .turn; let call = ToolCall { + custom: false, name: "note".into(), call_id: format!("note-{n}"), arguments: json!({"text":text}).to_string(), @@ -9585,6 +9600,7 @@ fn a_store_keeps_its_identity_and_forks_inherit_fallbacks() { /// result. Returns the call's node and the result's. fn exchange(db: &mut Database, turn: i64, call_id: &str, output: &str) -> (i64, i64) { let call = ToolCall { + custom: false, name: "shell".into(), call_id: call_id.into(), arguments: "{}".into(), @@ -9952,6 +9968,7 @@ fn a_current_turn_over_budget_fits_once_its_answered_results_are_elided() { .turn; for n in 0..8 { let call = ToolCall { + custom: false, name: "shell".into(), call_id: format!("c{n}"), arguments: "{}".into(), @@ -11527,6 +11544,7 @@ fn a_fork_narrows_what_it_may_call_and_never_widens() { let (shell, s) = shell_call("fc_2", "s2", "true"); let arguments = json!({"path":"x"}).to_string(); let read = ToolCall { + custom: false, name: "read".into(), call_id: "r2".into(), arguments: arguments.clone(), diff --git a/tests/store_payloads.rs b/tests/store_payloads.rs index a05b8b773..ee58ab144 100644 --- a/tests/store_payloads.rs +++ b/tests/store_payloads.rs @@ -253,6 +253,7 @@ fn large_artifacts_remain_exact_after_reopen_and_background_completion() { .unwrap() .turn; let call = ToolCall { + custom: false, name: "shell".into(), call_id: "call".into(), arguments: "{}".into(), diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 02573d226..45d9a5373 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -78,6 +78,56 @@ def do_POST(self): model.server_close() worker.join(timeout=2) + def test_a_patch_is_a_custom_tool_call_answered_in_kind(self): + patch = ('*** Begin Patch\n*** Add File: notes.md\n+# Notes\n' + '*** Update File: calc.py\n@@\n-x = 1\n+x = 2\n*** End Patch') + call = dict(type='custom_tool_call', id='ctc_1', call_id='p1', name='apply_patch', input=patch) + requests = [] + class PatchModel(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + def do_POST(self): + request = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + requests.append(request) + output = [call] if len(requests) == 1 else [ + dict(type='message', role='assistant', content=[dict(type='output_text', text='')])] + event = dict(type='response.completed', response=dict(status='completed', output=output)) + body = ('data: '+json.dumps(event)+'\n\n').encode() + self.send_response(200) + self.send_header('Content-Type', 'text/event-stream') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + model = http.server.ThreadingHTTPServer(('127.0.0.1', 0), PatchModel) + worker = threading.Thread(target=model.serve_forever, daemon=True) + worker.start() + with tempfile.TemporaryDirectory(dir='/tmp') as directory: + root = Path(directory) + (root/'calc.py').write_text('x = 1\n') + client = None + try: + client = Client(self.binary, root/'state.db', f'http://127.0.0.1:{model.server_port}/v1', 'apply_patch') + client.request('create', bot='Bob', workspace=str(root)) + turn = client.request('submit', bot='Bob', request_id='patch', prompt='patch it')['result']['turn'] + self.assertEqual(client.finished(turn)['data']['status'], 'completed') + # Declared as a grammar-constrained custom tool. + tool, = requests[0]['tools'] + self.assertEqual((tool['type'], tool['name'], tool['format']['syntax']), ('custom', 'apply_patch', 'lark')) + # The call goes back as the model made it, answered by a + # custom tool call output. + *_, sent, answer = requests[1]['input'] + self.assertEqual(sent, call) + self.assertEqual(answer, dict(type='custom_tool_call_output', call_id='p1', + output='Success. Updated the following files:\nA notes.md\nM calc.py\n')) + self.assertEqual((root/'notes.md').read_text(), '# Notes\n') + self.assertEqual((root/'calc.py').read_text(), 'x = 2\n') + finally: + if client is not None: + client.close(kill=True) + model.shutdown() + model.server_close() + worker.join(timeout=2) + def test_one_socket_follows_every_bot_and_stats_report_the_fleet(self): client = SocketClient(self.binary, self.path/'state.db', self.url, 'echo') self.addCleanup(client.close) From 314409b3cc2734b42d2901c76a1a41428ab872cb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:33:25 +0000 Subject: [PATCH 02/22] apply_patch: end-of-file insertions sort, moves keep their mode, cut patch paths kept An insertion with no context goes at the end of the file, so a later chunk can change an earlier line; replacements are applied in file order and overlapping chunks fail instead of panicking. A moved file keeps the source's permissions. The approver reads the paths recovered from a cut patch preview. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/approver.rs | 17 ++++++++++++++-- src/tools/patch.rs | 51 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/approver.rs b/src/approver.rs index 684d11098..c061de8d4 100644 --- a/src/approver.rs +++ b/src/approver.rs @@ -665,7 +665,8 @@ fn changes_files(tool: &str) -> bool { } /// The files a call changes: a write's or edit's `path`, or every file a -/// patch adds, deletes, updates or moves to. +/// patch adds, deletes, updates or moves to, or the `paths` read from a +/// cut patch. fn changed_paths(tool: &str, arguments: &Value) -> Vec { match tool { "write" | "edit" => arguments["path"] @@ -673,7 +674,14 @@ fn changed_paths(tool: &str, arguments: &Value) -> Vec { .map(str::to_owned) .into_iter() .collect(), - "apply_patch" => patch_paths(arguments["input"].as_str().unwrap_or_default()), + "apply_patch" => match arguments["paths"].as_array() { + Some(paths) => paths + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + None => patch_paths(arguments["input"].as_str().unwrap_or_default()), + }, _ => Vec::new(), } } @@ -1394,6 +1402,11 @@ mod tests { Some(json!({"paths":["a.rs"]})) ); assert!(cut_change("apply_patch", r#"{"input":"*** Begin Pa"#).is_err()); + // What a cut preview yields still names its files. + assert_eq!( + changed_paths("apply_patch", &json!({"paths":["a.rs", "b.rs"]})), + ["a.rs", "b.rs"] + ); assert_eq!(changed_paths("edit", &json!({"path": "x"})), ["x"]); assert!(changed_paths("shell", &json!({"command": "*** Add File: x"})).is_empty()); } diff --git a/src/tools/patch.rs b/src/tools/patch.rs index 9d2725207..581db8675 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -178,7 +178,9 @@ impl Patch { /// or deleted (D). pub async fn apply(&self, workspace: &Path) -> Result { enum Write { - Put(PathBuf, String), + /// A file's new contents, and for a move the permissions of the + /// file it came from. + Put(PathBuf, String, Option), Remove(PathBuf), } let mut writes = Vec::new(); @@ -186,7 +188,7 @@ impl Patch { for change in &self.changes { match change { Change::Add { path, contents } => { - writes.push(Write::Put(workspace.join(path), contents.clone())); + writes.push(Write::Put(workspace.join(path), contents.clone(), None)); summary.push_str(&format!("A {path}\n")); } Change::Delete { path } => { @@ -215,12 +217,14 @@ impl Patch { } match move_to { Some(to) => { - writes.push(Write::Put(workspace.join(to), updated)); + // A moved script stays executable. + let permissions = tokio::fs::metadata(&source).await?.permissions(); + writes.push(Write::Put(workspace.join(to), updated, Some(permissions))); writes.push(Write::Remove(source)); summary.push_str(&format!("M {to}\n")); } None => { - writes.push(Write::Put(source, updated)); + writes.push(Write::Put(source, updated, None)); summary.push_str(&format!("M {path}\n")); } } @@ -229,11 +233,14 @@ impl Patch { } for write in writes { match write { - Write::Put(target, contents) => { + Write::Put(target, contents, permissions) => { if let Some(parent) = target.parent() { tokio::fs::create_dir_all(parent).await?; } files::write(&target, contents.as_bytes()).await?; + if let Some(permissions) = permissions { + tokio::fs::set_permissions(&target, permissions).await?; + } } Write::Remove(target) => tokio::fs::remove_file(&target).await?, } @@ -368,9 +375,18 @@ fn update(text: &str, chunks: &[Chunk], path: &str) -> Result { replacements.push((at, old.len(), new)); cursor = at + old.len(); } + // An insertion with no context goes at the end of the file, so it can + // come before chunks that are found earlier in it. + replacements.sort_by_key(|&(at, _, _)| at); let mut out: Vec<&str> = Vec::with_capacity(lines.len()); let mut next = 0; for (at, removed, added) in replacements { + if at < next { + return Err(Error::with( + "patch_context_not_found", + format!("{path}: two chunks change the same lines"), + )); + } out.extend(&lines[next..at]); out.extend(added.iter().map(String::as_str)); next = at + removed; @@ -514,6 +530,16 @@ mod tests { let error = update("other\n", chunks, "f").unwrap_err(); assert_eq!(error.code, "patch_context_not_found"); assert!(error.detail.unwrap().contains("say(\"hi\")")); + // An insertion with no context lands at the end even when a later + // chunk changes an earlier line. + let parsed = patch("*** Update File: f\n@@\n+tail\n@@\n-first\n+changed\n"); + let Change::Update { chunks, .. } = &parsed.changes[0] else { + panic!() + }; + assert_eq!( + update("first\nsecond\n", chunks, "f").unwrap(), + "changed\nsecond\ntail\n" + ); } #[tokio::test] @@ -523,6 +549,12 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("keep.txt"), "a\nb\n").unwrap(); std::fs::write(dir.join("gone.txt"), "x\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir.join("keep.txt"), std::fs::Permissions::from_mode(0o755)) + .unwrap(); + } let failing = patch("*** Add File: new.txt\n+n\n*** Update File: keep.txt\n-missing\n+b2\n"); assert_eq!( @@ -547,6 +579,15 @@ mod tests { "a\nb2\n" ); assert!(!dir.join("keep.txt").exists() && !dir.join("gone.txt").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.join("moved.txt")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o755, "a move keeps the file's mode"); + } std::fs::remove_dir_all(&dir).unwrap(); } } From a179357ab619d5ea0f4c152f8b3ca03d1acb49c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:40:11 +0000 Subject: [PATCH 03/22] apply_patch: schema 56 after declared answers took 55 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- tests/store_contract.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/store_contract.rs b/tests/store_contract.rs index e67e8a5e5..31d9f5b59 100644 --- a/tests/store_contract.rs +++ b/tests/store_contract.rs @@ -3365,6 +3365,7 @@ fn waiting_calls_page_past_held_answers_and_an_answer_out_of_time_is_refused() { name: "start".into(), call_id: format!("c{n}"), arguments: "{}".into(), + custom: false, }) .collect(); db.append(turn, vec![], &calls, None).unwrap(); From c5e5161e7f4f6906d64493dd262515bfb5a12b10 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:46:36 +0000 Subject: [PATCH 04/22] apply_patch: each file once, a move to itself is an update, staging bounded, matching off the runtime A patch that changes one file twice is refused at parse, since the second change would be computed from the file as it was. A move to the same file (./f for f) is an ordinary update instead of a delete. The files a patch stages hold at most 64 MiB together. Matching runs on the blocking pool, normalizes each line once per pass and checks a place's last line first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/tools/patch.rs | 121 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 106 insertions(+), 15 deletions(-) diff --git a/src/tools/patch.rs b/src/tools/patch.rs index 581db8675..92b3b6de4 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -63,7 +63,7 @@ pub enum Change { /// A run of lines to find and replace, after an optional `@@` line that /// places it. -#[derive(Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct Chunk { context: Option, old: Vec, @@ -81,6 +81,38 @@ fn invalid(line: usize, detail: impl std::fmt::Display) -> Error { Error::with("invalid_patch", format!("line {line}: {detail}")) } +/// The most a patch's changed files may hold together while it is staged. +const STAGED_BYTES: usize = 16 * FILE_BYTES; + +/// A path as the file it names: `./f` and `d/../f` are `f`. +fn file_key(path: &str) -> PathBuf { + let mut key = PathBuf::new(); + for part in Path::new(path).components() { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir if key.file_name().is_some() => { + key.pop(); + } + part => key.push(part), + } + } + key +} + +/// Note that the patch changes `path`. Each file is changed once: a +/// second change would be computed from the file as it was. +fn claim(seen: &mut Vec, path: &str, line: usize) -> Result<()> { + let key = file_key(path); + if seen.contains(&key) { + return Err(invalid( + line, + format!("{path} is changed twice; a patch changes each file once"), + )); + } + seen.push(key); + Ok(()) +} + impl Patch { /// Parse a patch. A heredoc around it, as a model writes when it /// thinks it is running a command, is taken off first. @@ -101,6 +133,7 @@ impl Patch { } let body = &lines[1..lines.len() - 1]; let mut changes = Vec::new(); + let mut seen = Vec::new(); let mut at = 0; while at < body.len() { let number = at + 2; @@ -116,19 +149,24 @@ impl Patch { contents.push('\n'); at += 1; } - changes.push(Change::Add { - path: path_of(path, number)?, - contents, - }); + let path = path_of(path, number)?; + claim(&mut seen, &path, number)?; + changes.push(Change::Add { path, contents }); } else if let Some(path) = line.strip_prefix("*** Delete File: ") { - changes.push(Change::Delete { - path: path_of(path, number)?, - }); + let path = path_of(path, number)?; + claim(&mut seen, &path, number)?; + changes.push(Change::Delete { path }); } else if let Some(path) = line.strip_prefix("*** Update File: ") { let path = path_of(path, number)?; + claim(&mut seen, &path, number)?; let mut move_to = None; if let Some(to) = body.get(at).and_then(|l| l.strip_prefix("*** Move to: ")) { - move_to = Some(path_of(to, at + 2)?); + let to = path_of(to, at + 2)?; + // A move to the file itself is no move. + if file_key(&to) != file_key(&path) { + claim(&mut seen, &to, at + 2)?; + move_to = Some(to); + } at += 1; } let chunks = chunks(body, &mut at)?; @@ -184,10 +222,12 @@ impl Patch { Remove(PathBuf), } let mut writes = Vec::new(); + let mut staged = 0; let mut summary = String::from("Success. Updated the following files:\n"); for change in &self.changes { match change { Change::Add { path, contents } => { + staged += contents.len(); writes.push(Write::Put(workspace.join(path), contents.clone(), None)); summary.push_str(&format!("A {path}\n")); } @@ -211,10 +251,28 @@ impl Patch { let bytes = files::read_bounded(&source).await?; let text = String::from_utf8(bytes) .map_err(|_| Error::with("file_not_utf8", path.clone()))?; - let updated = update(&text, chunks, path)?; + // Matching can take a while on a long file: off the + // runtime's thread. + let (chunks, name) = (chunks.clone(), path.clone()); + let updated = + tokio::task::spawn_blocking(move || update(&text, &chunks, &name)) + .await + .map_err(|_| { + Error::with( + "invalid_patch", + format!("{path}: could not be matched"), + ) + })??; if updated.len() > FILE_BYTES { return Err(Error::with("tool_output_limit", path.clone())); } + staged += updated.len(); + if staged > STAGED_BYTES { + return Err(Error::with( + "tool_output_limit", + "the files a patch changes hold at most 64 MiB together", + )); + } match move_to { Some(to) => { // A moved script stays executable. @@ -418,12 +476,16 @@ fn seek(lines: &[&str], pattern: &[String], start: usize, end_of_file: bool) -> ]; for norm in passes { let wanted: Vec = pattern.iter().map(|line| norm(line)).collect(); + let normed: Vec = lines[start.min(from)..] + .iter() + .map(|line| norm(line)) + .collect(); + let base = start.min(from); for at in (from..=last).chain(start..from.min(last + 1)) { - if lines[at..at + pattern.len()] - .iter() - .zip(&wanted) - .all(|(line, wanted)| norm(line) == *wanted) - { + let lines = &normed[at - base..at - base + pattern.len()]; + // The ends first: most places differ there, and a long run of + // like lines is then not compared whole at every place. + if lines.last() == wanted.last() && lines == &wanted[..] { return Some(at); } } @@ -495,6 +557,14 @@ mod tests { "changes nothing", ), ("*** Begin Patch\n*** End Patch", "at least one file"), + ( + "*** Begin Patch\n*** Update File: f\n-a\n+b\n*** Update File: ./f\n-c\n+d\n*** End Patch", + "line 5: ./f is changed twice", + ), + ( + "*** Begin Patch\n*** Update File: f\n*** Move to: g\n-a\n+b\n*** Delete File: d/../g\n*** End Patch", + "changed twice", + ), ] { let error = Patch::parse(text).unwrap_err(); assert_eq!(error.code, "invalid_patch", "{text}"); @@ -505,6 +575,15 @@ mod tests { } } + #[test] + fn a_move_to_the_file_itself_is_an_update() { + let parsed = patch("*** Update File: f\n*** Move to: ./f\n-a\n+b\n"); + let Change::Update { move_to, .. } = &parsed.changes[0] else { + panic!() + }; + assert_eq!(move_to, &None); + } + #[test] fn chunks_apply_in_order_with_lenient_matching() { let text = "fn a() {\n x();\n}\nfn b() {\n x();\n}\n"; @@ -530,6 +609,18 @@ mod tests { let error = update("other\n", chunks, "f").unwrap_err(); assert_eq!(error.code, "patch_context_not_found"); assert!(error.detail.unwrap().contains("say(\"hi\")")); + // A long run of like lines whose last line is not there fails fast. + let text = "x\n".repeat(20_000); + let mut long = "*** Update File: f\n".to_owned(); + long.push_str(&"-x\n".repeat(9_999)); + long.push_str("-y\n+z\n"); + let parsed = patch(&long); + let Change::Update { chunks, .. } = &parsed.changes[0] else { + panic!() + }; + let started = std::time::Instant::now(); + assert!(update(&text, chunks, "f").is_err()); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); // An insertion with no context lands at the end even when a later // chunk changes an earlier line. let parsed = patch("*** Update File: f\n@@\n+tail\n@@\n-first\n+changed\n"); From fbfe174fb885c8b0e65d06049c0fbcd5251fbfac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:52:45 +0000 Subject: [PATCH 05/22] apply_patch: destinations checked before writing, a lone empty line kept, a cut patch names all its files An added or moved-to path that is a folder, or sits below a file, fails before any file is written. A result of one empty line keeps its newline. tool_started names every file a cut patch changes, and the approver uses those over what the preview holds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/approver.rs | 23 +++++++++++++++++++-- src/store/db.rs | 46 +++++++++++++++++++++++++++++++++++++++-- src/tools/patch.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/approver.rs b/src/approver.rs index c061de8d4..6a998a816 100644 --- a/src/approver.rs +++ b/src/approver.rs @@ -709,6 +709,15 @@ fn patch_paths(text: &str) -> Vec { paths } +/// The files a file-changing call too large to read back changes: a +/// patch's as the daemon named them whole, else as its preview names them. +fn cut_files(tool: &str, call: &Value, preview: &str) -> std::result::Result { + match call["paths"].as_array() { + Some(paths) if tool == "apply_patch" => Ok(json!({"paths":paths})), + _ => cut_change(tool, preview), + } +} + /// What is shown of a file-changing call too large to read back: the /// files it changes, when its preview names them. fn cut_change(tool: &str, preview: &str) -> std::result::Result { @@ -921,7 +930,7 @@ async fn intent( // Too large to read back: as a call with no node. Err(Unjudged::TooLong) if writes => { sized = false; - cut_change(&tool, preview)? + cut_files(&tool, call, preview)? } Err(Unjudged::TooLong) => { allowed_cut = true; @@ -933,7 +942,7 @@ async fn intent( // no file this turn wrote could be matched. None if writes => { sized = false; - cut_change(&tool, preview)? + cut_files(&tool, call, preview)? } None => { allowed_cut = true; @@ -1402,6 +1411,16 @@ mod tests { Some(json!({"paths":["a.rs"]})) ); assert!(cut_change("apply_patch", r#"{"input":"*** Begin Pa"#).is_err()); + // The files the daemon named whole win over a cut preview's. + let call = json!({"paths":["a.rs", "b.rs", "c.rs", "run.sh"]}); + assert_eq!( + cut_files("apply_patch", &call, preview).ok(), + Some(json!({"paths":["a.rs", "b.rs", "c.rs", "run.sh"]})) + ); + assert_eq!( + cut_files("apply_patch", &json!({}), preview).ok(), + Some(json!({"paths":["a.rs"]})) + ); // What a cut preview yields still names its files. assert_eq!( changed_paths("apply_patch", &json!({"paths":["a.rs", "b.rs"]})), diff --git a/src/store/db.rs b/src/store/db.rs index af05560db..a98be25d8 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -6396,6 +6396,14 @@ impl Database { truncated |= cut(&mut arguments, &mut calls_left); let mut call = json!({"call_id":call_id,"name":data["name"],"arguments":arguments, "arguments_truncated":truncated,"done":done.contains(&call_id)}); + // The files a cut patch changes, when they fit what is left. + if let Some(paths) = data.get("paths") { + let size = paths.to_string().len(); + if size <= calls_left { + calls_left -= size; + call["paths"] = paths.clone(); + } + } if failed.contains(&call_id) { call["failed"] = json!(true); } @@ -8480,8 +8488,19 @@ fn epoch_ms() -> i64 { /// A `tool_started` event's data: the call with its arguments previewed. fn started(call: &ToolCall) -> Value { let preview: String = call.arguments.chars().take(2048).collect(); - json!({"call_id":call.call_id,"name":call.name,"arguments":preview, - "arguments_truncated":preview.len() < call.arguments.len()}) + let truncated = preview.len() < call.arguments.len(); + let mut data = json!({"call_id":call.call_id,"name":call.name,"arguments":preview, + "arguments_truncated":truncated}); + // A cut patch still names every file it changes, for a judge of a later + // call that runs one. + if truncated + && call.name == "apply_patch" + && let Ok(arguments) = serde_json::from_str::(&call.arguments) + && let Some(Ok(patch)) = arguments["input"].as_str().map(crate::tools::Patch::parse) + { + data["paths"] = json!(patch.paths().collect::>()); + } + data } /// In an answered call's start, note it as waiting for its client, and /// name that client and the call's deadline in `tool_started`. A call @@ -10256,6 +10275,29 @@ impl Database { mod tests { use super::*; + #[test] + fn a_cut_patch_still_names_every_file_it_changes() { + let input = format!( + "*** Begin Patch\n*** Add File: notes.txt\n+{}\n*** Add File: run.sh\n+ls\n*** End Patch", + "x".repeat(3000) + ); + let call = ToolCall { + name: "apply_patch".into(), + call_id: "c".into(), + arguments: json!({"input":input}).to_string(), + custom: true, + }; + let data = started(&call); + assert_eq!(data["arguments_truncated"], true); + assert_eq!(data["paths"], json!(["notes.txt", "run.sh"])); + let short = ToolCall { + arguments: json!({"input":"*** Begin Patch\n*** Add File: a\n+b\n*** End Patch"}) + .to_string(), + ..call + }; + assert!(started(&short).get("paths").is_none()); + } + #[test] fn compaction_follows_the_window_as_codex_and_claude_code_do() { assert_eq!(compact_threshold(1_000_000), 900_000); diff --git a/src/tools/patch.rs b/src/tools/patch.rs index 92b3b6de4..2d7f2f7a8 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -227,6 +227,7 @@ impl Patch { for change in &self.changes { match change { Change::Add { path, contents } => { + writable(workspace, path).await?; staged += contents.len(); writes.push(Write::Put(workspace.join(path), contents.clone(), None)); summary.push_str(&format!("A {path}\n")); @@ -275,6 +276,7 @@ impl Patch { } match move_to { Some(to) => { + writable(workspace, to).await?; // A moved script stays executable. let permissions = tokio::fs::metadata(&source).await?.permissions(); writes.push(Write::Put(workspace.join(to), updated, Some(permissions))); @@ -307,6 +309,30 @@ impl Patch { } } +/// Fail unless `path` can be written as a regular file: what is there, if +/// anything, is one, and every folder above it is a folder or missing. +/// Checked before anything is written, so a patch that cannot apply +/// changes nothing. +async fn writable(workspace: &Path, path: &str) -> Result<()> { + let target = workspace.join(path); + if let Ok(metadata) = tokio::fs::metadata(&target).await + && !metadata.is_file() + { + return Err(Error::with("file_not_regular", path.to_owned())); + } + for above in target.ancestors().skip(1) { + if above == workspace { + break; + } + if let Ok(metadata) = tokio::fs::metadata(above).await + && !metadata.is_dir() + { + return Err(Error::with("file_not_regular", path.to_owned())); + } + } + Ok(()) +} + fn path_of(path: &str, line: usize) -> Result { let path = path.trim(); if path.is_empty() || path.len() > 4096 || path.contains('\0') { @@ -452,7 +478,8 @@ fn update(text: &str, chunks: &[Chunk], path: &str) -> Result { out.extend(&lines[next..]); let eol = if crlf { "\r\n" } else { "\n" }; let mut updated = out.join(eol); - if !updated.is_empty() { + // Every line ends: a file of one empty line is a newline. + if !out.is_empty() { updated.push_str(eol); } Ok(updated) @@ -609,6 +636,12 @@ mod tests { let error = update("other\n", chunks, "f").unwrap_err(); assert_eq!(error.code, "patch_context_not_found"); assert!(error.detail.unwrap().contains("say(\"hi\")")); + // One empty line is still a line. + let parsed = patch("*** Update File: f\n-a\n+\n"); + let Change::Update { chunks, .. } = &parsed.changes[0] else { + panic!() + }; + assert_eq!(update("a\n", chunks, "f").unwrap(), "\n"); // A long run of like lines whose last line is not there fails fast. let text = "x\n".repeat(20_000); let mut long = "*** Update File: f\n".to_owned(); @@ -653,6 +686,22 @@ mod tests { "patch_context_not_found" ); assert!(!dir.join("new.txt").exists()); + // So does one that adds a file where a folder is, or below a file. + std::fs::create_dir_all(dir.join("folder")).unwrap(); + for target in ["folder", "keep.txt/inner"] { + let failing = patch(&format!( + "*** Update File: keep.txt\n-b\n+b2\n*** Add File: {target}\n+n\n" + )); + assert_eq!( + failing.apply(&dir).await.unwrap_err().code, + "file_not_regular" + ); + assert_eq!( + std::fs::read_to_string(dir.join("keep.txt")).unwrap(), + "a\nb\n" + ); + } + std::fs::remove_dir(dir.join("folder")).unwrap(); let good = patch( "*** Add File: sub/new.txt\n+n\n*** Update File: keep.txt\n*** Move to: moved.txt\n a\n-b\n+b2\n\ *** Delete File: gone.txt\n", From b8c00131fc790b23054de9b92f8f3a19ffbc0b39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:53:47 +0000 Subject: [PATCH 06/22] apply_patch: a patch's paths deduplicated through a set Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/approver.rs | 3 ++- src/tools/patch.rs | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/approver.rs b/src/approver.rs index 6a998a816..6f47f7844 100644 --- a/src/approver.rs +++ b/src/approver.rs @@ -697,11 +697,12 @@ fn patch_paths(text: &str) -> Vec { "*** Move to: ", ]; let mut paths: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); for line in text.split('\n').flat_map(|line| line.split("\\n")) { let line = line.trim(); if let Some(path) = MARKERS.iter().find_map(|marker| line.strip_prefix(marker)) && !path.is_empty() - && !paths.iter().any(|known| known == path) + && seen.insert(path) { paths.push(path.to_owned()); } diff --git a/src/tools/patch.rs b/src/tools/patch.rs index 2d7f2f7a8..926f02c0e 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -19,6 +19,7 @@ //! ``` use super::{FILE_BYTES, files}; use crate::{Error, Result}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; /// The Lark grammar OpenAI models are given for this tool, as Codex gives @@ -101,15 +102,14 @@ fn file_key(path: &str) -> PathBuf { /// Note that the patch changes `path`. Each file is changed once: a /// second change would be computed from the file as it was. -fn claim(seen: &mut Vec, path: &str, line: usize) -> Result<()> { +fn claim(seen: &mut HashSet, path: &str, line: usize) -> Result<()> { let key = file_key(path); - if seen.contains(&key) { + if !seen.insert(key) { return Err(invalid( line, format!("{path} is changed twice; a patch changes each file once"), )); } - seen.push(key); Ok(()) } @@ -133,7 +133,7 @@ impl Patch { } let body = &lines[1..lines.len() - 1]; let mut changes = Vec::new(); - let mut seen = Vec::new(); + let mut seen = HashSet::new(); let mut at = 0; while at < body.len() { let number = at + 2; @@ -602,6 +602,17 @@ mod tests { } } + #[test] + fn many_files_parse_quickly() { + let mut body = String::new(); + for n in 0..40_000 { + body.push_str(&format!("*** Delete File: dir/file{n}.txt\n")); + } + let started = std::time::Instant::now(); + assert_eq!(patch(&body).paths().count(), 40_000); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + } + #[test] fn a_move_to_the_file_itself_is_an_update() { let parsed = patch("*** Update File: f\n*** Move to: ./f\n-a\n+b\n"); From 4029c0bfcfc106de7d256f2ea42615f13b5bd4bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:23:47 +0000 Subject: [PATCH 07/22] apply_patch: a cut patch's started event names at most 64 KiB of paths Past it the event says paths_cut, and a later call the approver judges is marked unseen, as when a written file is too long to show. A patch read from its cut preview alone counts the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/approver.rs | 7 +++++++ src/store/db.rs | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/approver.rs b/src/approver.rs index 6f47f7844..dda65a01f 100644 --- a/src/approver.rs +++ b/src/approver.rs @@ -905,6 +905,9 @@ async fn intent( let mut allowed = Vec::new(); let mut allowed_cut = read["calls_more"] == true; let mut written = Vec::new(); + // Whether an earlier call changed files not all of which are named: a + // later call may run one unseen. + let mut written_cut = false; // Arguments read whole for calls whose preview was cut; past the text // limit only a write's or edit's path is still read, and kept small. let mut expanded = 0; @@ -957,6 +960,9 @@ async fn intent( // A failed write changed nothing this turn: its file is not shown. if call["failed"] != true { written.extend(paths.iter().cloned()); + // A patch read from its cut preview alone may name more. + written_cut |= call["paths_cut"] == true + || (tool == "apply_patch" && !sized && !call["paths"].is_array()); } let bytes: usize = fields .iter() @@ -988,6 +994,7 @@ async fn intent( let workspace = read["workspace"].as_str().map(PathBuf::from); let mut changing: Vec<(String, String)> = Vec::new(); for call in &mut planned { + call.unseen |= written_cut; let mut text = String::new(); strings(&call.arguments, &mut text); let names = |path: &str| { diff --git a/src/store/db.rs b/src/store/db.rs index a98be25d8..5c5f91e48 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -6399,10 +6399,14 @@ impl Database { // The files a cut patch changes, when they fit what is left. if let Some(paths) = data.get("paths") { let size = paths.to_string().len(); - if size <= calls_left { + let fits = size <= calls_left; + if fits { calls_left -= size; call["paths"] = paths.clone(); } + if !fits || data["paths_cut"] == true { + call["paths_cut"] = json!(true); + } } if failed.contains(&call_id) { call["failed"] = json!(true); @@ -8498,10 +8502,23 @@ fn started(call: &ToolCall) -> Value { && let Ok(arguments) = serde_json::from_str::(&call.arguments) && let Some(Ok(patch)) = arguments["input"].as_str().map(crate::tools::Patch::parse) { - data["paths"] = json!(patch.paths().collect::>()); + // Within what an event page carries: past it, the rest is marked + // as left out. + let (mut paths, mut size) = (Vec::new(), 0); + for path in patch.paths() { + size += path.len() + 3; + if size > STARTED_PATHS_BYTES { + data["paths_cut"] = json!(true); + break; + } + paths.push(path); + } + data["paths"] = json!(paths); } data } +/// The most of a cut patch's paths a `tool_started` event names. +const STARTED_PATHS_BYTES: usize = 64 * 1024; /// In an answered call's start, note it as waiting for its client, and /// name that client and the call's deadline in `tool_started`. A call /// whose arguments do not fit is refused at once, so it waits for none. @@ -10296,6 +10313,19 @@ mod tests { ..call }; assert!(started(&short).get("paths").is_none()); + // Past what an event carries, the paths named stop and say so. + let mut input = "*** Begin Patch\n".to_owned(); + for n in 0..3000 { + input.push_str(&format!("*** Delete File: {}{n}\n", "d/".repeat(100))); + } + input.push_str("*** End Patch"); + let many = ToolCall { + arguments: json!({"input":input}).to_string(), + ..short + }; + let data = started(&many); + assert_eq!(data["paths_cut"], true); + assert!(data.to_string().len() < 80 * 1024); } #[test] From 030be8a68539a624b5f9546d4510b772a5a0b493 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 02:29:11 +0000 Subject: [PATCH 08/22] Sonny: a patch line with no change mark draws as context Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- sonny/src/transcript.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/sonny/src/transcript.rs b/sonny/src/transcript.rs index 5321f7b63..2f5e9c38d 100644 --- a/sonny/src/transcript.rs +++ b/sonny/src/transcript.rs @@ -1449,9 +1449,14 @@ fn as_unified(patch: &str, workspace: &str) -> String { if line.starts_with("@@") || line == "*** End of File" { continue; } - let line = if line.is_empty() { " " } else { line }; + // A line with no change mark, as in a patch the daemon + // refused, is drawn as context. + let line = match line.chars().next() { + Some('+' | '-' | ' ') => line.to_owned(), + _ => format!(" {line}"), + }; if let Some(chunk) = chunks.last_mut() { - chunk.push(line.to_owned()); + chunk.push(line); } } let (mut grown, mut from) = (0isize, 0); @@ -1777,6 +1782,15 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn a_refused_patch_with_an_unmarked_line_still_draws() { + let unified = as_unified( + "*** Begin Patch\n*** Update File: f\n@@\nα\n-a\n+b\n*** End Patch", + "/nonexistent", + ); + assert!(unified.contains("\n α\n-a\n+b\n"), "{unified}"); + } + #[test] fn a_patch_shows_each_file_as_a_diff_at_its_lines() { let dir = std::env::temp_dir().join(format!("sonny-patch-{}", std::process::id())); From 5ccc5c3848140df28353910020e3bc50b6b6298a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:14:43 +0000 Subject: [PATCH 09/22] apply_patch: runs in a bot's sandbox, as write and edit do A sandboxed bot's patch is confined like its writes: each file it changes is checked against the bot's roots before any is written, and the files are written through the sandbox's helper. A patch that changes a file outside them escapes, and is gated as an escaping write. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- client/src/approver.rs | 2 +- src/sandbox.rs | 66 ++++++++++++++++++++++++++++++----------- src/tools.rs | 9 +++++- src/tools/patch.rs | 22 +++++++------- tests/store_contract.rs | 3 ++ tests/tool_contract.rs | 33 ++++++++++++++++++++- 6 files changed, 104 insertions(+), 31 deletions(-) diff --git a/client/src/approver.rs b/client/src/approver.rs index b2d9e338b..eabbbf8fa 100644 --- a/client/src/approver.rs +++ b/client/src/approver.rs @@ -140,7 +140,7 @@ macro_rules! policy { - If important context is missing and cannot be verified, lean conservative and explain the uncertainty in your rationale. # Execution environment -- A sandboxed agent's commands, writes, and edits inside its sandbox run without review. What comes to you leaves it: a `shell` call with `sandbox_permissions` `require_escalated`, which runs unconfined, or a write or edit outside the folders it may write. An agent without a sandbox sends you every gated call. +- A sandboxed agent's commands, writes, edits, and patches inside its sandbox run without review. What comes to you leaves it: a `shell` call with `sandbox_permissions` `require_escalated`, which runs unconfined, or a write, edit, or patch outside the folders it may write. An agent without a sandbox sends you every gated call. - A sandbox escalation, or a retry after a sandbox denial, is not suspicious in itself. Evaluate the risk of the unsandboxed action, not the escalation. - You share the agent's machine and start in its workspace. When you have `shell` and `read` tools, they only run read-only commands, with no network access. Do not provide `sandbox_permissions` for any reason: such commands are rejected. Without tools, judge from the state alone. diff --git a/src/sandbox.rs b/src/sandbox.rs index 99203b505..2ca59392c 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -211,12 +211,12 @@ pub fn write(path: &Path) -> Result<()> { /// The tools a sandbox confines: what runs a command or writes a file. pub fn confinable(tool: &str) -> bool { - matches!(tool, "shell" | "write" | "edit") + matches!(tool, "shell" | "write" | "edit" | "apply_patch") } /// Whether a call by a bot in `mode` asks to run outside its sandbox: a -/// command that asks to escalate, or a write or edit of a path outside -/// every folder the bot may write. `root` is the bot's folder and `folder` +/// command that asks to escalate, or a write, edit or patch of a path +/// outside every folder the bot may write. `root` is the bot's folder and `folder` /// the turn's, against which a relative path is read. Decided from the call /// and the folders' names alone, never from the files as they are now, so /// the round that announces a call and the run that starts it agree; a @@ -234,24 +234,42 @@ pub fn escapes( }; match tool { "shell" => arguments["sandbox_permissions"] == "require_escalated", - "write" | "edit" => { - let Some(path) = arguments["path"].as_str() else { - return false; - }; - let target = lexical(&folder.join(path)); - let inside = |dir: &Path| target.starts_with(dir); - let temporary = temporary().iter().any(|(named, real)| { - mode == Mode::WorkspaceWrite && (inside(named) || inside(real)) - }); - !(temporary - || writable_paths(mode, root, writable) - .iter() - .any(|dir| inside(dir))) - } + "write" | "edit" => arguments["path"] + .as_str() + .is_some_and(|path| outside(mode, root, writable, folder, path)), + // A patch escapes when any file it changes would. + "apply_patch" => arguments["input"] + .as_str() + .and_then(|input| crate::tools::Patch::parse(input).ok()) + .is_some_and(|patch| { + patch + .paths() + .any(|path| outside(mode, root, writable, folder, path)) + }), _ => false, } } +/// Whether `path`, read against `folder`, lies outside every folder a bot +/// in `mode` may write. +fn outside( + mode: Mode, + root: Option<&Path>, + writable: &[String], + folder: &Path, + path: &str, +) -> bool { + let target = lexical(&folder.join(path)); + let inside = |dir: &Path| target.starts_with(dir); + let temporary = temporary() + .iter() + .any(|(named, real)| mode == Mode::WorkspaceWrite && (inside(named) || inside(real))); + !(temporary + || writable_paths(mode, root, writable) + .iter() + .any(|dir| inside(dir))) +} + /// `path` with `.` dropped and `..` taken back, by name alone. fn lexical(path: &Path) -> PathBuf { let mut out = PathBuf::new(); @@ -882,6 +900,20 @@ mod tests { "{outside}" ); } + let patch = |paths: &[&str]| { + let mut input = String::from("*** Begin Patch\n"); + for path in paths { + input.push_str(&format!("*** Add File: {path}\n+x\n")); + } + input.push_str("*** End Patch"); + serde_json::json!({ "input": input }) + }; + assert!(!escapes("apply_patch", patch(&["a.txt", "/work/shared/c"]))); + assert!(escapes("apply_patch", patch(&["a.txt", "/etc/hosts"]))); + assert!(!escapes( + "apply_patch", + serde_json::json!({"input":"not a patch"}) + )); // Read-only writes nowhere, so every write asks. assert!(super::escapes( Mode::ReadOnly, diff --git a/src/tools.rs b/src/tools.rs index 5cb0adfba..f75d814a8 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1243,7 +1243,14 @@ impl Registry { path ))) } - Prepared::ApplyPatch(patch) => Ok(Outcome::text(patch.apply(workspace).await?)), + Prepared::ApplyPatch(patch) => { + for path in patch.paths() { + writable(sandbox.clone(), &resolve(workspace, path), path).await?; + } + let _held = crate::sandbox::hold_for(sandbox.as_deref()).await; + let _slot = self.helper_slot(&sandbox).await?; + Ok(Outcome::text(patch.apply(workspace, sandbox).await?)) + } Prepared::Edit { path, old, diff --git a/src/tools/patch.rs b/src/tools/patch.rs index 926f02c0e..ce86accf7 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -18,9 +18,10 @@ //! *** End Patch //! ``` use super::{FILE_BYTES, files}; -use crate::{Error, Result}; +use crate::{Error, Result, sandbox::Policy}; use std::collections::HashSet; use std::path::{Path, PathBuf}; +use std::sync::Arc; /// The Lark grammar OpenAI models are given for this tool, as Codex gives /// it. A model sends a patch that fits it as the call's raw input. @@ -212,9 +213,9 @@ impl Patch { } /// Apply the patch under `workspace`: read and check every file first, - /// then write. The summary lists each file as added (A), modified (M) - /// or deleted (D). - pub async fn apply(&self, workspace: &Path) -> Result { + /// then write, in `sandbox` when the bot has one. The summary lists each + /// file as added (A), modified (M) or deleted (D). + pub async fn apply(&self, workspace: &Path, sandbox: Option>) -> Result { enum Write { /// A file's new contents, and for a move the permissions of the /// file it came from. @@ -294,14 +295,13 @@ impl Patch { for write in writes { match write { Write::Put(target, contents, permissions) => { - if let Some(parent) = target.parent() { - tokio::fs::create_dir_all(parent).await?; - } - files::write(&target, contents.as_bytes()).await?; + files::write(&target, contents.as_bytes(), sandbox.clone()).await?; if let Some(permissions) = permissions { tokio::fs::set_permissions(&target, permissions).await?; } } + // The sandbox's helper only writes. A removal was checked + // against the bot's roots before anything ran. Write::Remove(target) => tokio::fs::remove_file(&target).await?, } } @@ -693,7 +693,7 @@ mod tests { let failing = patch("*** Add File: new.txt\n+n\n*** Update File: keep.txt\n-missing\n+b2\n"); assert_eq!( - failing.apply(&dir).await.unwrap_err().code, + failing.apply(&dir, None).await.unwrap_err().code, "patch_context_not_found" ); assert!(!dir.join("new.txt").exists()); @@ -704,7 +704,7 @@ mod tests { "*** Update File: keep.txt\n-b\n+b2\n*** Add File: {target}\n+n\n" )); assert_eq!( - failing.apply(&dir).await.unwrap_err().code, + failing.apply(&dir, None).await.unwrap_err().code, "file_not_regular" ); assert_eq!( @@ -718,7 +718,7 @@ mod tests { *** Delete File: gone.txt\n", ); assert_eq!( - good.apply(&dir).await.unwrap(), + good.apply(&dir, None).await.unwrap(), "Success. Updated the following files:\nA sub/new.txt\nM moved.txt\nD gone.txt\n" ); assert_eq!( diff --git a/tests/store_contract.rs b/tests/store_contract.rs index 31d9f5b59..e2a7eb699 100644 --- a/tests/store_contract.rs +++ b/tests/store_contract.rs @@ -861,6 +861,7 @@ fn a_sandboxed_bot_waits_for_a_verdict_only_on_an_escape() { name: name.into(), call_id: id.into(), arguments, + custom: false, }; (Bytes::from(serde_json::to_vec(&item).unwrap()), call) }; @@ -9738,6 +9739,7 @@ fn pictures_count_at_their_weight_and_go_whole() { name: "read".into(), call_id: "c1".into(), arguments: "{}".into(), + custom: false, }; let item = json!({"type":"function_call","call_id":"c1","name":"read","arguments":"{}"}); db.append( @@ -11499,6 +11501,7 @@ fn a_fork_narrows_what_it_may_call_and_never_widens() { name: "shell".into(), call_id: "c".into(), arguments: r#"{"command":"true"}"#.into(), + custom: false, }; let folder = std::path::Path::new("/synthetic"); assert!(db.inspect("Bob").unwrap().gated(&shell, folder)); diff --git a/tests/tool_contract.rs b/tests/tool_contract.rs index 466284f9c..dd690a51d 100644 --- a/tests/tool_contract.rs +++ b/tests/tool_contract.rs @@ -555,7 +555,7 @@ async fn a_sandboxed_command_writes_only_its_roots() { .unwrap() .unwrap(), ); - let tools = Registry::new("shell,write,edit").unwrap(); + let tools = Registry::new("shell,write,edit,apply_patch").unwrap(); let run = |command: String| { let tools = tools.clone(); let (dir, policy) = (dir.clone(), policy.clone()); @@ -651,6 +651,37 @@ async fn a_sandboxed_command_writes_only_its_roots() { .unwrap_err(); assert_eq!(error.code, "write_denied"); assert!(!other.join("new").exists()); + // So is a patch, every file it changes checked before any is written. + let patch = |paths: &[&str]| { + let mut input = String::from("*** Begin Patch\n"); + for path in paths { + input.push_str(&format!("*** Add File: {path}\n+x\n")); + } + input.push_str("*** End Patch"); + tools + .prepare("apply_patch", &json!({ "input": input }).to_string()) + .unwrap() + }; + let outside = other.join("b"); + let error = tools + .execute( + patch(&["notes/b.md", outside.to_str().unwrap()]), + &dir, + &[], + Some(policy.clone()), + ) + .await + .unwrap_err(); + assert_eq!(error.code, "write_denied"); + assert!(!dir.join("notes/b.md").exists() && !outside.exists()); + tools + .execute(patch(&["notes/b.md"]), &dir, &[], Some(policy.clone())) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(dir.join("notes/b.md")).unwrap(), + "x\n" + ); std::fs::remove_dir_all(base).unwrap(); } From 179856fa322bbab43095c178525f3f643322baa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:25:13 +0000 Subject: [PATCH 10/22] apply_patch: a process slot only while the helper writes; Sonny reads only regular files for a diff A sandboxed patch is staged before it takes a process slot, and one that only deletes takes none. Sonny's diff of an edit or a patch reads the file as it is now only when it is a regular file within the daemon's size limit, so a FIFO never blocks the screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- sonny/src/transcript.rs | 33 ++++++++++++++++++++++++--- src/tools.rs | 9 ++++++-- src/tools/patch.rs | 50 ++++++++++++++++++++++++++++++++--------- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/sonny/src/transcript.rs b/sonny/src/transcript.rs index 2f5e9c38d..cf7390d4e 100644 --- a/sonny/src/transcript.rs +++ b/sonny/src/transcript.rs @@ -1338,6 +1338,17 @@ fn waited(output: Option<&str>) -> Option> { crate::calls::waited(&serde_json::from_str(output?.trim()).ok()?) } +/// The text of the file at `path` as it is now, when it is a regular file +/// no larger than the daemon writes: never a FIFO or a device, whose +/// read could block the screen. +fn current(path: &std::path::Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + if !metadata.is_file() || metadata.len() > 4 << 20 { + return None; + } + std::fs::read_to_string(path).ok() +} + /// An edit as diffs.com draws one: the whole lines it touched, the words /// that changed within them, and two lines either side, numbered where /// the file now has them when the new text can be found there. @@ -1352,7 +1363,7 @@ fn diff(arguments: &Value, workspace: &str) -> crate::diff::Edit { // An edit replaces text, not lines: show the whole lines it touched, // read from the file as it is now, so indentation and numbers are right. if !new.is_empty() - && let Ok(now) = std::fs::read_to_string(path) + && let Some(now) = current(&path) && let Some(at) = now.find(&new) { let line_start = now[..at].rfind('\n').map_or(0, |i| i + 1); @@ -1436,8 +1447,7 @@ fn as_unified(patch: &str, workspace: &str) -> String { if let Some(to) = lines.next_if(|l| l.starts_with("*** Move to: ")) { path = to["*** Move to: ".len()..].trim().to_owned(); } - let now = std::fs::read_to_string(std::path::Path::new(workspace).join(&path)) - .unwrap_or_default(); + let now = current(&std::path::Path::new(workspace).join(&path)).unwrap_or_default(); out.push_str(&format!("--- {path}\n+++ {path}\n")); // Each chunk's lines, then its place: the line where its new // side starts now, and that less what earlier chunks added. @@ -1782,6 +1792,23 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn a_file_that_could_block_is_not_read_for_a_diff() { + let dir = std::env::temp_dir().join(format!("sonny-fifo-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fifo = dir.join("pipe"); + let made = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap(); + assert!(made.success()); + assert_eq!(current(&fifo), None); + assert_eq!(current(&dir), None); + std::fs::write(dir.join("f"), "a\n").unwrap(); + assert_eq!(current(&dir.join("f")).as_deref(), Some("a\n")); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn a_refused_patch_with_an_unmarked_line_still_draws() { let unified = as_unified( diff --git a/src/tools.rs b/src/tools.rs index f75d814a8..efacf795c 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1247,9 +1247,14 @@ impl Registry { for path in patch.paths() { writable(sandbox.clone(), &resolve(workspace, path), path).await?; } + let staged = patch.stage(workspace).await?; + // A process slot only while the sandbox's helper writes. let _held = crate::sandbox::hold_for(sandbox.as_deref()).await; - let _slot = self.helper_slot(&sandbox).await?; - Ok(Outcome::text(patch.apply(workspace, sandbox).await?)) + let _slot = match staged.writes() { + true => self.helper_slot(&sandbox).await?, + false => None, + }; + Ok(Outcome::text(staged.commit(sandbox).await?)) } Prepared::Edit { path, diff --git a/src/tools/patch.rs b/src/tools/patch.rs index ce86accf7..8727301ed 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -212,16 +212,15 @@ impl Patch { .flatten() } - /// Apply the patch under `workspace`: read and check every file first, - /// then write, in `sandbox` when the bot has one. The summary lists each - /// file as added (A), modified (M) or deleted (D). + /// Apply the patch under `workspace`: [`stage`](Self::stage), then + /// [`commit`](Staged::commit). pub async fn apply(&self, workspace: &Path, sandbox: Option>) -> Result { - enum Write { - /// A file's new contents, and for a move the permissions of the - /// file it came from. - Put(PathBuf, String, Option), - Remove(PathBuf), - } + self.stage(workspace).await?.commit(sandbox).await + } + + /// Read and check every file the patch changes, and compute what each + /// becomes, writing nothing. + pub async fn stage(&self, workspace: &Path) -> Result { let mut writes = Vec::new(); let mut staged = 0; let mut summary = String::from("Success. Updated the following files:\n"); @@ -292,7 +291,36 @@ impl Patch { } } } - for write in writes { + Ok(Staged { writes, summary }) + } +} + +/// A patch's changes, checked and computed, and its summary: each file as +/// added (A), modified (M) or deleted (D). +pub struct Staged { + writes: Vec, + summary: String, +} + +enum Write { + /// A file's new contents, and for a move the permissions of the file it + /// came from. + Put(PathBuf, String, Option), + Remove(PathBuf), +} + +impl Staged { + /// Whether committing writes any file, through the sandbox's helper + /// when the bot has one; a patch that only deletes writes none. + pub fn writes(&self) -> bool { + self.writes + .iter() + .any(|write| matches!(write, Write::Put(..))) + } + + /// Write the changes, in `sandbox` when the bot has one. + pub async fn commit(self, sandbox: Option>) -> Result { + for write in self.writes { match write { Write::Put(target, contents, permissions) => { files::write(&target, contents.as_bytes(), sandbox.clone()).await?; @@ -305,7 +333,7 @@ impl Patch { Write::Remove(target) => tokio::fs::remove_file(&target).await?, } } - Ok(summary) + Ok(self.summary) } } From e87bb85149d341927dffe49fbbeae5fcca39a954 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:26:24 +0000 Subject: [PATCH 11/22] approver: a custom tool's call and result show in the judge's transcript Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- client/src/approver.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/client/src/approver.rs b/client/src/approver.rs index eabbbf8fa..6e3f576e2 100644 --- a/client/src/approver.rs +++ b/client/src/approver.rs @@ -478,7 +478,15 @@ pub fn entries(item: &Value, judged: &[&str], redactor: &Redactor) -> Vec let arguments = serde_json::from_str(text).unwrap_or_else(|_| json!(text)); call(&item["call_id"], &item["name"], &arguments); } - Some("function_call_output") => found.push(Entry { + // A custom tool's call is its input, read as the daemon reads it. + Some("custom_tool_call") => { + call( + &item["call_id"], + &item["name"], + &json!({"input": item["input"]}), + ); + } + Some("function_call_output" | "custom_tool_call_output") => found.push(Entry { said: Said::Output, call: item["call_id"].as_str().map(str::to_owned), text: output_text(&item["output"], redactor), @@ -1447,6 +1455,8 @@ mod tests { json!({"type":"function_call","call_id":"a","name":"shell","arguments":"{\"command\":\"ls\"}"}), json!({"type":"function_call_output","call_id":"a","output":"README.md"}), json!({"type":"function_call","call_id":"judged","name":"shell","arguments":"{}"}), + json!({"type":"custom_tool_call","call_id":"p","name":"apply_patch","input":"*** Begin Patch"}), + json!({"type":"custom_tool_call_output","call_id":"p","output":"Success."}), ]; let found: Vec = responses .iter() @@ -1471,6 +1481,16 @@ mod tests { call: call("a"), text: "README.md".into() }, + Entry { + said: Said::Call, + call: call("p"), + text: "apply_patch {\"input\":\"*** Begin Patch\"}".into() + }, + Entry { + said: Said::Output, + call: call("p"), + text: "Success.".into() + }, ] ); let anthropic = [ From d8f306192d2528d0e5c7e4ed7153921c47f40545 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:30:56 +0000 Subject: [PATCH 12/22] apply_patch: a patch can't write both a file and a file below it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/tools/patch.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/tools/patch.rs b/src/tools/patch.rs index 8727301ed..443e71944 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -196,6 +196,20 @@ impl Patch { if changes.is_empty() { return Err(invalid(1, "a patch changes at least one file")); } + // A file and a file below it can't both be written: one of them + // would need a folder where the other puts a file. + if let Some(below) = seen + .iter() + .find(|key| key.ancestors().skip(1).any(|above| seen.contains(above))) + { + return Err(Error::with( + "invalid_patch", + format!( + "{} is below another file this patch changes", + below.display() + ), + )); + } Ok(Patch { changes }) } @@ -620,6 +634,10 @@ mod tests { "*** Begin Patch\n*** Update File: f\n*** Move to: g\n-a\n+b\n*** Delete File: d/../g\n*** End Patch", "changed twice", ), + ( + "*** Begin Patch\n*** Add File: new\n+a\n*** Add File: new/child\n+b\n*** End Patch", + "new/child is below another file", + ), ] { let error = Patch::parse(text).unwrap_err(); assert_eq!(error.code, "invalid_patch", "{text}"); @@ -727,7 +745,7 @@ mod tests { assert!(!dir.join("new.txt").exists()); // So does one that adds a file where a folder is, or below a file. std::fs::create_dir_all(dir.join("folder")).unwrap(); - for target in ["folder", "keep.txt/inner"] { + for target in ["folder", "gone.txt/inner"] { let failing = patch(&format!( "*** Update File: keep.txt\n-b\n+b2\n*** Add File: {target}\n+n\n" )); From 0291200ca1380c8ca5280916dc5259d58da39072 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:35:16 +0000 Subject: [PATCH 13/22] approver: a planned patch's files are read before its contents are clipped Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/approver.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/approver.rs b/src/approver.rs index dda65a01f..1526179fc 100644 --- a/src/approver.rs +++ b/src/approver.rs @@ -871,6 +871,9 @@ async fn intent( } } let mut planned = Vec::with_capacity(calls.len()); + // The files each call changes, read before its contents are clipped + // for showing, which can leave out a patch's file lines. + let mut changes = Vec::with_capacity(calls.len()); // Past this the state is too long to judge anyway: stop reading. let mut size = 0; for (index, call) in calls.iter().enumerate() { @@ -887,6 +890,7 @@ async fn intent( }; let tool = call["name"].as_str().unwrap_or_default().to_owned(); let mut unseen = false; + changes.push(changed_paths(&tool, &arguments)); if changes_files(&tool) { (arguments, unseen) = policy::shown_change(&arguments, &shared.redactor); } @@ -993,7 +997,7 @@ async fn intent( // call will find it, so it points to that call instead. let workspace = read["workspace"].as_str().map(PathBuf::from); let mut changing: Vec<(String, String)> = Vec::new(); - for call in &mut planned { + for (call, changed) in planned.iter_mut().zip(changes) { call.unseen |= written_cut; let mut text = String::new(); strings(&call.arguments, &mut text); @@ -1031,7 +1035,7 @@ async fn intent( None => {} } } - for path in changed_paths(&call.tool, &call.arguments) { + for path in changed { changing.push((path, call.id.clone())); } } From 1150a49f4cfad61cc4ee4be5abfb8aec32e44571 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:45:08 +0000 Subject: [PATCH 14/22] Sonny: placing a patch's chunk checks its last line first, within a bound Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- sonny/src/transcript.rs | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/sonny/src/transcript.rs b/sonny/src/transcript.rs index cf7390d4e..c11baa68f 100644 --- a/sonny/src/transcript.rs +++ b/sonny/src/transcript.rs @@ -1498,18 +1498,31 @@ fn as_unified(patch: &str, workspace: &str) -> String { } /// The first line, at or after `from`, where `text` holds `wanted` in a -/// row, by content with ends trimmed. +/// row, by content with ends trimmed. Drawing waits on it, so it gives up +/// past a bounded number of line comparisons and the caller places the +/// chunk where it would otherwise be. fn find_lines(text: &str, wanted: &[&str], from: usize) -> Option { + const COMPARED: usize = 1 << 20; let lines: Vec<&str> = text.lines().collect(); if wanted.is_empty() || wanted.len() > lines.len() { return None; } - (from..=lines.len() - wanted.len()).find(|&at| { - wanted - .iter() - .zip(&lines[at..]) - .all(|(want, have)| want.trim_end() == have.trim_end()) - }) + let (last, mut compared) = (wanted.len() - 1, 0); + (from..=lines.len() - wanted.len()).find_map(|at| { + if compared > COMPARED { + return Some(None); + } + compared += 1; + // The last line first: a long run rarely ends the same by chance. + if wanted[last].trim_end() != lines[at + last].trim_end() { + return None; + } + let same = wanted.iter().zip(&lines[at..]).take_while(|(want, have)| { + compared += 1; + want.trim_end() == have.trim_end() + }); + (same.count() == wanted.len()).then_some(Some(at)) + })? } /// The rule under a finished turn: how it ended, how long it took, what it cost. @@ -1792,6 +1805,20 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn a_long_repeated_context_is_placed_quickly() { + let mut text = "x\n".repeat(100_000); + text.push_str("y\n"); + let mut wanted = vec!["x"; 10_000]; + wanted.push("y"); + let started = std::time::Instant::now(); + let found = find_lines(&text, &wanted, 0); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + assert!(found.is_none_or(|at| at == 90_000), "{found:?}"); + assert_eq!(find_lines("a\nb\nc\n", &["b", "c"], 0), Some(1)); + assert_eq!(find_lines("a\nb\nc\n", &["c", "b"], 0), None); + } + #[test] fn a_file_that_could_block_is_not_read_for_a_diff() { let dir = std::env::temp_dir().join(format!("sonny-fifo-{}", std::process::id())); From 372eda8d1c741d553e598dd764ac44711f59df30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:48:15 +0000 Subject: [PATCH 15/22] apply_patch: matching reads lines in place and stops at a bound on lines compared Only the punctuation pass makes new strings, once for the whole file rather than for each chunk, and one update compares at most 50 million lines before it fails with invalid_patch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/tools/patch.rs | 122 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 96 insertions(+), 26 deletions(-) diff --git a/src/tools/patch.rs b/src/tools/patch.rs index 443e71944..9f20a5362 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -452,12 +452,17 @@ fn update(text: &str, chunks: &[Chunk], path: &str) -> Result { if lines.last() == Some(&"") { lines.pop(); } + let mut hay = Haystack { + lines: &lines, + plain: None, + left: COMPARED, + }; let mut replacements: Vec<(usize, usize, &[String])> = Vec::new(); let mut cursor = 0; for chunk in chunks { if let Some(context) = &chunk.context { let found = - seek(&lines, std::slice::from_ref(context), cursor, false).ok_or_else(|| { + seek(&mut hay, std::slice::from_ref(context), cursor, false)?.ok_or_else(|| { Error::with( "patch_context_not_found", format!("{path}: no line matches @@ {context}"), @@ -475,14 +480,14 @@ fn update(text: &str, chunks: &[Chunk], path: &str) -> Result { continue; } let (mut old, mut new) = (&chunk.old[..], &chunk.new[..]); - let mut found = seek(&lines, old, cursor, chunk.end_of_file); + let mut found = seek(&mut hay, old, cursor, chunk.end_of_file)?; // A trailing empty line can stand for the file's final newline. if found.is_none() && old.last().is_some_and(String::is_empty) { old = &old[..old.len() - 1]; if new.last().is_some_and(String::is_empty) { new = &new[..new.len() - 1]; } - found = seek(&lines, old, cursor, chunk.end_of_file); + found = seek(&mut hay, old, cursor, chunk.end_of_file)?; } let at = found.ok_or_else(|| { Error::with( @@ -527,39 +532,91 @@ fn update(text: &str, chunks: &[Chunk], path: &str) -> Result { Ok(updated) } -/// Where `pattern` first occurs in `lines` at or after `start`, matched +/// Line comparisons one update may make before it gives up: well past +/// what a real patch needs, short of what stalls the daemon's blocking pool. +const COMPARED: usize = 50_000_000; + +/// What `seek` reads a file through: its lines, the same lines with +/// typographic punctuation made plain once for every chunk, and the line +/// comparisons left. +struct Haystack<'a> { + lines: &'a [&'a str], + plain: Option>, + left: usize, +} + +impl Haystack<'_> { + /// Line `at` as pass `pass` reads it. + fn line(&self, pass: usize, at: usize) -> &str { + let line = self.lines[at]; + match pass { + 0 => line, + 1 => line.trim_end(), + 2 => line.trim(), + _ => &self.plain.as_ref().unwrap()[at], + } + } +} + +/// `line` as pass `pass` reads it: exactly, then without trailing +/// whitespace, then trimmed, then trimmed with punctuation made plain. +fn normal(pass: usize, line: &str) -> std::borrow::Cow<'_, str> { + match pass { + 0 => line.into(), + 1 => line.trim_end().into(), + 2 => line.trim().into(), + _ => line.trim().chars().map(plain).collect::().into(), + } +} + +/// Where `pattern` first occurs in the file at or after `start`, matched /// exactly, then ignoring trailing whitespace, then surrounding /// whitespace, then common typographic punctuation. With `end_of_file` -/// a match at the very end is preferred. -fn seek(lines: &[&str], pattern: &[String], start: usize, end_of_file: bool) -> Option { - if pattern.len() > lines.len() { - return None; +/// a match at the very end is preferred. Fails with `invalid_patch` once +/// the update has compared too many lines. +fn seek( + hay: &mut Haystack, + pattern: &[String], + start: usize, + end_of_file: bool, +) -> Result> { + let lines = hay.lines.len(); + if pattern.len() > lines { + return Ok(None); } - let last = lines.len() - pattern.len(); + let last = lines - pattern.len(); let from = if end_of_file { last.max(start) } else { start }; - let passes: [fn(&str) -> String; 4] = [ - str::to_owned, - |line| line.trim_end().to_owned(), - |line| line.trim().to_owned(), - |line| line.trim().chars().map(plain).collect(), - ]; - for norm in passes { - let wanted: Vec = pattern.iter().map(|line| norm(line)).collect(); - let normed: Vec = lines[start.min(from)..] - .iter() - .map(|line| norm(line)) - .collect(); - let base = start.min(from); + for pass in 0..4 { + if pass == 3 && hay.plain.is_none() { + hay.plain = Some( + hay.lines + .iter() + .map(|l| normal(3, l).into_owned()) + .collect(), + ); + } + let wanted: Vec<_> = pattern.iter().map(|line| normal(pass, line)).collect(); for at in (from..=last).chain(start..from.min(last + 1)) { - let lines = &normed[at - base..at - base + pattern.len()]; // The ends first: most places differ there, and a long run of // like lines is then not compared whole at every place. - if lines.last() == wanted.last() && lines == &wanted[..] { - return Some(at); + hay.left = hay.left.checked_sub(1).ok_or_else(too_slow)?; + if hay.line(pass, at + pattern.len() - 1) != wanted[pattern.len() - 1] { + continue; + } + hay.left = hay.left.checked_sub(pattern.len()).ok_or_else(too_slow)?; + if (0..pattern.len()).all(|i| hay.line(pass, at + i) == wanted[i]) { + return Ok(Some(at)); } } } - None + Ok(None) +} + +fn too_slow() -> Error { + Error::with( + "invalid_patch", + "matching this patch's chunks compares too many lines; split it into smaller patches", + ) } /// Typographic dashes, quotes and spaces as their ASCII forms. @@ -711,6 +768,19 @@ mod tests { let started = std::time::Instant::now(); assert!(update(&text, chunks, "f").is_err()); assert!(started.elapsed() < std::time::Duration::from_secs(2)); + // Many chunks that each scan the rest of a long file stop at a + // bound on the lines compared, rather than stall the daemon. + let mut text = "\u{201C}q\u{201D}\n".repeat(300); + text.push_str(&"x\n".repeat(100_000)); + let mut many = "*** Update File: f\n".to_owned(); + many.push_str(&"@@\n-\"q\"\n+r\n".repeat(300)); + let parsed = patch(&many); + let Change::Update { chunks, .. } = &parsed.changes[0] else { + panic!() + }; + let error = update(&text, chunks, "f").unwrap_err(); + assert_eq!(error.code, "invalid_patch"); + assert!(error.detail.unwrap().contains("too many lines")); // An insertion with no context lands at the end even when a later // chunk changes an earlier line. let parsed = patch("*** Update File: f\n@@\n+tail\n@@\n-first\n+changed\n"); From 687450c5a34845a95daa033d62fda49d539071af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:11:17 +0000 Subject: [PATCH 16/22] apply_patch: nothing to match is found at the start; nested paths checked in one sort Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/tools/patch.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/tools/patch.rs b/src/tools/patch.rs index 9f20a5362..c3e6d720c 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -198,9 +198,14 @@ impl Patch { } // A file and a file below it can't both be written: one of them // would need a folder where the other puts a file. - if let Some(below) = seen - .iter() - .find(|key| key.ancestors().skip(1).any(|above| seen.contains(above))) + // In path order a file's descendants follow it directly, so + // comparing neighbours finds any, in one sort. + let mut keys: Vec<&PathBuf> = seen.iter().collect(); + keys.sort(); + if let Some(below) = keys + .windows(2) + .find(|pair| pair[1].starts_with(pair[0])) + .map(|pair| pair[1]) { return Err(Error::with( "invalid_patch", @@ -581,6 +586,10 @@ fn seek( end_of_file: bool, ) -> Result> { let lines = hay.lines.len(); + // Nothing to match is found where the search starts, as in Codex. + if pattern.is_empty() { + return Ok(Some(start.min(lines))); + } if pattern.len() > lines { return Ok(None); } @@ -695,6 +704,10 @@ mod tests { "*** Begin Patch\n*** Add File: new\n+a\n*** Add File: new/child\n+b\n*** End Patch", "new/child is below another file", ), + ( + "*** Begin Patch\n*** Add File: a/b\n+a\n*** Add File: a.txt\n+b\n*** Add File: a/b/c/d\n+c\n*** End Patch", + "a/b/c/d is below another file", + ), ] { let error = Patch::parse(text).unwrap_err(); assert_eq!(error.code, "invalid_patch", "{text}"); @@ -756,6 +769,12 @@ mod tests { panic!() }; assert_eq!(update("a\n", chunks, "f").unwrap(), "\n"); + // A lone empty old line not found falls back to nothing to match. + let parsed = patch("*** Update File: f\n@@\n-\n+new\n"); + let Change::Update { chunks, .. } = &parsed.changes[0] else { + panic!() + }; + assert_eq!(update("", chunks, "f").unwrap(), "new\n"); // A long run of like lines whose last line is not there fails fast. let text = "x\n".repeat(20_000); let mut long = "*** Update File: f\n".to_owned(); From af05f6bb00aa357bc1449170508c668c4e41e006 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:25:25 +0000 Subject: [PATCH 17/22] apply_patch: a file's punctuation-plain lines are packed into one string Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/tools/patch.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/tools/patch.rs b/src/tools/patch.rs index c3e6d720c..5689844a6 100644 --- a/src/tools/patch.rs +++ b/src/tools/patch.rs @@ -546,10 +546,34 @@ const COMPARED: usize = 50_000_000; /// comparisons left. struct Haystack<'a> { lines: &'a [&'a str], - plain: Option>, + plain: Option, left: usize, } +/// A file's lines trimmed with punctuation made plain, packed into one +/// string so a file of many short lines is not one allocation a line. +struct Plain { + text: String, + ends: Vec, +} + +impl Plain { + fn of(lines: &[&str]) -> Self { + let mut text = String::new(); + let mut ends = Vec::with_capacity(lines.len()); + for line in lines { + text.extend(line.trim().chars().map(plain)); + ends.push(text.len()); + } + Self { text, ends } + } + + fn line(&self, at: usize) -> &str { + let start = if at == 0 { 0 } else { self.ends[at - 1] }; + &self.text[start..self.ends[at]] + } +} + impl Haystack<'_> { /// Line `at` as pass `pass` reads it. fn line(&self, pass: usize, at: usize) -> &str { @@ -558,7 +582,7 @@ impl Haystack<'_> { 0 => line, 1 => line.trim_end(), 2 => line.trim(), - _ => &self.plain.as_ref().unwrap()[at], + _ => self.plain.as_ref().unwrap().line(at), } } } @@ -597,12 +621,7 @@ fn seek( let from = if end_of_file { last.max(start) } else { start }; for pass in 0..4 { if pass == 3 && hay.plain.is_none() { - hay.plain = Some( - hay.lines - .iter() - .map(|l| normal(3, l).into_owned()) - .collect(), - ); + hay.plain = Some(Plain::of(hay.lines)); } let wanted: Vec<_> = pattern.iter().map(|line| normal(pass, line)).collect(); for at in (from..=last).chain(start..from.min(last + 1)) { From 4b40f4210dec206f5201540bb22408ee5e452eb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:30:58 +0000 Subject: [PATCH 18/22] Sonny: a patch's file is split into lines once, and its chunks share one placing budget Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- sonny/src/transcript.rs | 55 +++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/sonny/src/transcript.rs b/sonny/src/transcript.rs index c11baa68f..ccf7f8e7a 100644 --- a/sonny/src/transcript.rs +++ b/sonny/src/transcript.rs @@ -1448,6 +1448,7 @@ fn as_unified(patch: &str, workspace: &str) -> String { path = to["*** Move to: ".len()..].trim().to_owned(); } let now = current(&std::path::Path::new(workspace).join(&path)).unwrap_or_default(); + let now: Vec<&str> = now.lines().collect(); out.push_str(&format!("--- {path}\n+++ {path}\n")); // Each chunk's lines, then its place: the line where its new // side starts now, and that less what earlier chunks added. @@ -1469,7 +1470,7 @@ fn as_unified(patch: &str, workspace: &str) -> String { chunk.push(line); } } - let (mut grown, mut from) = (0isize, 0); + let (mut grown, mut from, mut budget) = (0isize, 0, PLACING); for chunk in chunks.into_iter().filter(|c| !c.is_empty()) { let new: Vec<&str> = chunk .iter() @@ -1477,7 +1478,7 @@ fn as_unified(patch: &str, workspace: &str) -> String { .map(|l| &l[1..]) .collect(); let old = chunk.iter().filter(|l| !l.starts_with('+')).count(); - let start = find_lines(&now, &new, from).unwrap_or(from); + let start = find_lines(&now, &new, from, &mut budget).unwrap_or(from); from = start + new.len(); let old_start = (start as isize - grown).max(0) as usize; grown += new.len() as isize - old as isize; @@ -1497,28 +1498,29 @@ fn as_unified(patch: &str, workspace: &str) -> String { out } -/// The first line, at or after `from`, where `text` holds `wanted` in a -/// row, by content with ends trimmed. Drawing waits on it, so it gives up -/// past a bounded number of line comparisons and the caller places the -/// chunk where it would otherwise be. -fn find_lines(text: &str, wanted: &[&str], from: usize) -> Option { - const COMPARED: usize = 1 << 20; - let lines: Vec<&str> = text.lines().collect(); +/// Line comparisons placing one file's chunks may make. Drawing waits on +/// them, so past this the rest are placed where they would otherwise be. +const PLACING: usize = 1 << 20; + +/// The first line, at or after `from`, where `lines` holds `wanted` in a +/// row, by content with ends trimmed. Each comparison comes out of +/// `budget`, and once it runs out nothing more is found. +fn find_lines(lines: &[&str], wanted: &[&str], from: usize, budget: &mut usize) -> Option { if wanted.is_empty() || wanted.len() > lines.len() { return None; } - let (last, mut compared) = (wanted.len() - 1, 0); + let last = wanted.len() - 1; (from..=lines.len() - wanted.len()).find_map(|at| { - if compared > COMPARED { + if *budget == 0 { return Some(None); } - compared += 1; + *budget -= 1; // The last line first: a long run rarely ends the same by chance. if wanted[last].trim_end() != lines[at + last].trim_end() { return None; } let same = wanted.iter().zip(&lines[at..]).take_while(|(want, have)| { - compared += 1; + *budget = budget.saturating_sub(1); want.trim_end() == have.trim_end() }); (same.count() == wanted.len()).then_some(Some(at)) @@ -1807,16 +1809,33 @@ mod tests { #[test] fn a_long_repeated_context_is_placed_quickly() { - let mut text = "x\n".repeat(100_000); - text.push_str("y\n"); + let mut lines = vec!["x"; 100_000]; + lines.push("y"); let mut wanted = vec!["x"; 10_000]; wanted.push("y"); let started = std::time::Instant::now(); - let found = find_lines(&text, &wanted, 0); + let found = find_lines(&lines, &wanted, 0, &mut { PLACING }); assert!(started.elapsed() < std::time::Duration::from_secs(2)); assert!(found.is_none_or(|at| at == 90_000), "{found:?}"); - assert_eq!(find_lines("a\nb\nc\n", &["b", "c"], 0), Some(1)); - assert_eq!(find_lines("a\nb\nc\n", &["c", "b"], 0), None); + let find = |wanted: &[&str]| find_lines(&["a", "b", "c"], wanted, 0, &mut { PLACING }); + assert_eq!(find(&["b", "c"]), Some(1)); + assert_eq!(find(&["c", "b"]), None); + } + + #[test] + fn many_chunks_against_a_long_file_are_drawn_quickly() { + let dir = std::env::temp_dir().join(format!("sonny-chunks-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("f"), "x\n".repeat(100_000)).unwrap(); + let patch = format!( + "*** Begin Patch\n*** Update File: f\n{}*** End Patch\n", + "@@\n-x\n+x\n".repeat(5_000) + ); + let started = std::time::Instant::now(); + let unified = as_unified(&patch, dir.to_str().unwrap()); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + assert_eq!(unified.matches("@@ -").count(), 5_000); + std::fs::remove_dir_all(dir).unwrap(); } #[test] From cbdc8dc39e4506cd5bf550fc9a7509010ba511aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:40:38 +0000 Subject: [PATCH 19/22] Calls in a row that only read or run commands run together, up to four at a time As Codex runs them: an ungated foreground command or a read starts beside the others in one commit, and each result is recorded in call order. A sandboxed bot's commands all run in its one sandbox; one that asks to leave it still runs alone, to be gated or refused. A steer moves every command still running to the background. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/server/turn.rs | 339 +++++++++++++++++++++++++++++++--------- tests/test_approvals.py | 22 +++ tests/test_runtime.py | 15 ++ tests/test_wait.py | 40 +++++ 4 files changed, 343 insertions(+), 73 deletions(-) diff --git a/src/server/turn.rs b/src/server/turn.rs index ec652a6c9..7207419e1 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -247,10 +247,13 @@ impl Lift { } } const LIFT_GRACE: Duration = Duration::from_millis(250); +/// The most of one round's calls a bot runs at once. Each command also +/// waits for the daemon's process budget, so this is a bot's share of it. +const PARALLEL_CALLS: usize = 4; /// A tool's result, or the command a steer moved to the background. -enum Ran { - Done(Result), +enum Ran { + Done(Result), Lifted(std::pin::Pin>), } @@ -3151,14 +3154,14 @@ impl Turn { /// the run to the background instead, handed back unfinished, once the /// paired signal says it is running: a command still waiting for a /// process slot stays in line in the foreground. - async fn run_tool>>( + async fn run_tool>>( &self, run: F, lift: Option<(&Lift, oneshot::Receiver<()>)>, warm: Option<&mut Warm<'_>>, - ) -> Result> { - enum Next { - Ran(Result), + ) -> Result> { + enum Next { + Ran(Result), Lifted, Refreshed(Result<(agent_runtime::provider::Usage, tokio::time::Instant)>), } @@ -3365,6 +3368,26 @@ impl Turn { let (turn, allowed) = (self.turn, record.callable()); let mut calls = calls.into_iter(); while let Some(call) = calls.next() { + // Calls in a row that only read or run commands, none of them + // gated, run together, as Codex runs them. + if let Some(first) = self.parallel(record, &call, workspace) + && calls + .as_slice() + .first() + .is_some_and(|next| self.parallel(record, next, workspace).is_some()) + { + let mut batch = vec![(call, first)]; + while let Some(prepared) = calls + .as_slice() + .first() + .and_then(|next| self.parallel(record, next, workspace)) + { + batch.push((calls.next().expect("a next call"), prepared)); + } + self.execute_parallel(batch, record, workspace, environment, warm.as_deref_mut()) + .await?; + continue; + } // An escape from a sandbox runs unconfined only once a gate // allowed it. let approved = record.gated(&call, workspace); @@ -3419,46 +3442,10 @@ impl Turn { run it without sandbox_permissions", )) } - Ok(prepared) if confined => { - // Reading the roots touches the filesystem: off the - // runtime thread. - let (settings, daemon, bot) = ( - record.settings.clone(), - self.registry.daemon().clone(), - record.bot_id, - ); - let root = record - .workspace - .as_ref() - .map_or_else(|| workspace.to_owned(), PathBuf::from); - tokio::task::spawn_blocking(move || { - // The probe for a sandbox runs once, here off the - // runtime thread, also for a bot from before a restart. - agent_runtime::sandbox::available()?; - // The bot's own socket, so its commands may use the - // daemon as far as a sandboxed bot may; opened first, - // so its folder is there to be kept read-only. - let capability = daemon.capability(bot)?; - let policy = Policy::new( - settings.sandbox, - Some(&root), - &settings.writable, - settings.network, - &daemon, - )?; - Ok(policy.map(|policy| Policy { - capability, - ..policy - })) - }) - .await - .map_err(|_| Error::new("sandbox_unavailable")) - .flatten() - .map(|policy| { - sandbox = policy.map(Arc::new); - prepared - }) - } + Ok(prepared) if confined => self.sandbox(record, workspace).await.map(|policy| { + sandbox = policy; + prepared + }), other => other, }; let answered = record @@ -3520,35 +3507,10 @@ impl Turn { } } Ok(Prepared::Read { - source: - ReadSource::Artifact { - turn: owner, - call_id: ref_call, - stream, - }, - offset, - limit, - }) => { - let bot = self.bot.clone(); - let text = self - .store - .op("artifact_lines", move |db| { - db.artifact_lines(&bot, owner, &ref_call, &stream, offset, limit) - }) - .await; - match text { - Ok(page) => Outcome::text(self.registry.redact_text(page)), - Err(error) => failure(error), - } - } - Ok(Prepared::Read { - source: ReadSource::Result { node }, + source: source @ (ReadSource::Artifact { .. } | ReadSource::Result { .. }), offset, limit, - }) => match self.result_page(&call.call_id, node, offset, limit).await { - Ok(outcome) => outcome, - Err(error) => failure(error), - }, + }) => self.read_stored(&call.call_id, source, offset, limit).await, Ok(Prepared::History { bot, turn: wanted, @@ -3641,6 +3603,237 @@ impl Turn { Ok(None) } + /// The call prepared, when it may run beside others: an ungated + /// command in the foreground or a read, which the bot may call. Codex + /// runs these together too; a change to a file runs alone, in order. + /// A sandboxed bot's command runs beside others only inside its + /// sandbox; one that asks to leave it goes the lone way, to be refused. + fn parallel( + &self, + record: &Bot, + call: &ToolCall, + workspace: &std::path::Path, + ) -> Option { + if !matches!(call.name.as_str(), "shell" | "read") + || record.gated(call, workspace) + || record.declared(&call.name).is_some() + || !record.callable().iter().any(|name| name == &call.name) + || (call.name == "shell" && record.sandboxed() && !record.confines(call, workspace)) + { + return None; + } + match self.registry.prepare(&call.name, &call.arguments) { + Ok( + prepared @ (Prepared::Shell { + background: false, .. + } + | Prepared::Read { .. }), + ) => Some(prepared), + _ => None, + } + } + + /// Run a batch of calls [`Self::parallel`] admits together, up to + /// [`PARALLEL_CALLS`] at a time: all started in one commit, each + /// result recorded in call order as soon as it and those before it + /// are done. A steer moves every command still running to the + /// background, as it does a lone one. + async fn execute_parallel( + &self, + batch: Vec<(ToolCall, Prepared)>, + record: &Bot, + workspace: &std::path::Path, + environment: &[(String, String)], + warm: Option<&mut Warm<'_>>, + ) -> Result<()> { + let turn = self.turn; + let starting: Vec = batch.iter().map(|(call, _)| call.clone()).collect(); + self.store + .op("tool_start", move |db| { + starting + .iter() + .try_for_each(|call| db.tool_start(turn, call).map(drop)) + }) + .await?; + // A sandboxed bot's commands all run in its one sandbox; when it + // can't be had, each command fails with why. + let shells = batch + .iter() + .any(|(_, prepared)| matches!(prepared, Prepared::Shell { .. })); + let sandbox = match shells && record.sandboxed() { + true => self.sandbox(record, workspace).await, + false => Ok(None), + }; + let runs = batch.into_iter().map(|(call, prepared)| { + let sandbox = match (&prepared, &sandbox) { + (Prepared::Shell { .. }, Err(error)) => Err(error.clone()), + (_, sandbox) => Ok(sandbox.clone().unwrap_or_default()), + }; + self.run_parallel(call.call_id, prepared, workspace, environment, sandbox) + }); + let record = async { + let mut results = stream::iter(runs).buffered(PARALLEL_CALLS); + while let Some((id, outcome)) = results.next().await { + let outcome = outcome?; + self.store + .op("tool_finish", move |db| db.tool_finish(turn, &id, &outcome)) + .await?; + } + Ok(()) + }; + match self.run_tool(record, None, warm).await? { + Ran::Done(result) => result, + Ran::Lifted(_) => unreachable!("a batch is not lifted whole"), + } + } + + /// One call of a batch, run to its outcome: an error only when the + /// scheduler closed. + async fn run_parallel( + &self, + call_id: String, + prepared: Prepared, + workspace: &std::path::Path, + environment: &[(String, String)], + sandbox: Result>>, + ) -> (String, Result) { + let sandbox = match sandbox { + Ok(sandbox) => sandbox, + Err(error) => return (call_id, Ok(failure(error))), + }; + let ran = match prepared { + Prepared::Shell { + command, + timeout_ms, + background: false, + } => { + let (started, running) = oneshot::channel(); + let mut run = Box::pin(self.registry.foreground( + command, + workspace.to_path_buf(), + timeout_ms, + environment.to_vec(), + sandbox, + started, + )); + let lifted = async { + // A run that ends before it starts wins the race below. + match running.await { + Ok(()) => self.lift.lifted().await, + Err(_) => std::future::pending().await, + } + }; + let finished = tokio::select! { + biased; + result = &mut run => Some(result), + () = lifted => None, + }; + match finished { + Some(result) => result, + None => return (call_id.clone(), self.lift_shell(&call_id, run).await), + } + } + Prepared::Read { + source: source @ (ReadSource::Artifact { .. } | ReadSource::Result { .. }), + offset, + limit, + } => { + let outcome = self.read_stored(&call_id, source, offset, limit).await; + return (call_id, Ok(outcome)); + } + prepared => { + self.registry + .execute(prepared, workspace, environment, sandbox) + .await + } + }; + let outcome = match ran { + Ok(outcome) => Ok(annotate(outcome, self.turn, &call_id)), + Err(error) if error.code == "tool_scheduler_closed" => Err(error), + Err(error) => Ok(failure(error)), + }; + (call_id, outcome) + } + + /// The bot's sandbox, built from its settings. Reading its roots + /// touches the filesystem, so it runs off the runtime thread; it fails + /// rather than leave a call that must be confined unconfined. + async fn sandbox( + &self, + record: &Bot, + workspace: &std::path::Path, + ) -> Result>> { + let (settings, daemon, bot) = ( + record.settings.clone(), + self.registry.daemon().clone(), + record.bot_id, + ); + let root = record + .workspace + .as_ref() + .map_or_else(|| workspace.to_owned(), PathBuf::from); + tokio::task::spawn_blocking(move || { + // The probe for a sandbox runs once, here off the runtime + // thread, also for a bot from before a restart. + agent_runtime::sandbox::available()?; + // The bot's own socket, so its commands may use the daemon as + // far as a sandboxed bot may; opened first, so its folder is + // there to be kept read-only. + let capability = daemon.capability(bot)?; + let policy = Policy::new( + settings.sandbox, + Some(&root), + &settings.writable, + settings.network, + &daemon, + )?; + Ok(policy.map(|policy| Policy { + capability, + ..policy + })) + }) + .await + .map_err(|_| Error::new("sandbox_unavailable")) + .flatten() + .map(|policy| policy.map(Arc::new)) + } + + /// A page of a stored stream or result, for `read`. + async fn read_stored( + &self, + call_id: &str, + source: ReadSource, + offset: usize, + limit: usize, + ) -> Outcome { + match source { + ReadSource::Artifact { + turn: owner, + call_id: ref_call, + stream, + } => { + let bot = self.bot.clone(); + let text = self + .store + .op("artifact_lines", move |db| { + db.artifact_lines(&bot, owner, &ref_call, &stream, offset, limit) + }) + .await; + match text { + Ok(page) => Outcome::text(self.registry.redact_text(page)), + Err(error) => failure(error), + } + } + ReadSource::Result { node } => { + match self.result_page(call_id, node, offset, limit).await { + Ok(outcome) => outcome, + Err(error) => failure(error), + } + } + ReadSource::Path(_) => unreachable!("a file is read by the registry"), + } + } + /// Wait for a gated call's verdict: live for the hold, then parked like /// `wait`, holding no task and no slot. Answers wake the task through /// the worker, so each check is one job and no commit. diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 37f168fb5..fb02af9b1 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -250,6 +250,28 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): self.assertIn('result', daemon.request('delete', bot='Carl')) self.assertFalse(own.exists()) + @unittest.skipUnless(shutil.which('bwrap') or sys.platform == 'darwin', 'sandboxes need bubblewrap') + def test_a_sandboxed_bots_commands_run_together_in_its_sandbox(self): + (ROOT / '.local').mkdir(exist_ok=True) + outside = Path(tempfile.mkdtemp(dir=ROOT / '.local')) + self.addCleanup(shutil.rmtree, outside) + client = self.client('shell', settings={'sandbox': 'workspace-write'}) + client.request('create', bot='Bob', workspace=str(self.path)) + calls = [['shell', {'command': 'sleep .6; touch a'}], + ['shell', {'command': f'sleep .6; touch b; touch {outside}/x'}], + ['shell', {'command': f'touch {outside}/escaped', 'sandbox_permissions': 'require_escalated'}]] + started = time.monotonic() + turn = client.request('submit', bot='Bob', request_id='t', + prompt='calls:' + json.dumps(calls))['result']['turn'] + self.assertEqual(client.finished(turn)['data']['status'], 'completed') + # The two confined commands ran at once, each in the sandbox. + self.assertLess(time.monotonic() - started, 1.1) + self.assertTrue((self.path / 'a').exists() and (self.path / 'b').exists()) + self.assertNotEqual(self.tool_output(client, 'shell-2')[1]['exit_code'], 0) + # The one that asks to leave it still runs alone, and is refused. + self.assertEqual(self.tool_output(client, 'shell-3')[1]['error'], 'escalation_refused') + self.assertEqual(list(outside.iterdir()), []) + def test_a_denial_is_the_result_and_the_turn_goes_on(self): client = self.gated() turn = client.request('submit', bot='Bob', request_id='t', prompt='shell:touch ran')['result']['turn'] diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 8be9ee7b1..72a7ed75c 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -319,6 +319,21 @@ def do_POST(self): text = 'echo:' + json.dumps(outputs[-1]) output = [{'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': text}]}] + elif user.startswith('para:'): + # `para:CMD|CMD|...`: every command in one response, then + # the results in the order they were recorded. + start = max(n for n, i in enumerate(request['input']) if i.get('role') == 'user' + and not i['content'][0]['text'].startswith('steer:')) + outputs = [i for i in request['input'][start:] if i.get('type') == 'function_call_output'] + text = '' + if not outputs: + output = [{'type': 'function_call', 'name': 'shell', 'call_id': f'para-{n}', + 'arguments': json.dumps({'command': command, 'timeout_ms': 5000})} + for n, command in enumerate(user[5:].split('|'), 1)] + else: + text = 'echo:' + json.dumps([i['call_id'] for i in outputs]) + output = [{'type': 'message', 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': text}]}] elif user.startswith('sayslow:'): # An answer and a slow command in one response, then a word # for whatever steered it. diff --git a/tests/test_wait.py b/tests/test_wait.py index 8d7795f17..ebeb5ad78 100644 --- a/tests/test_wait.py +++ b/tests/test_wait.py @@ -403,6 +403,46 @@ def test_a_steer_moves_a_running_command_to_the_background(self): result = self.tool_output(client, 'Bob', 'wait-2')['results'][lifted['handle']] self.assertEqual((result['stdout'], result['exit_code']), ('done', 0)) + def test_commands_in_one_response_run_together_and_record_in_order(self): + client = self.client('echo,shell,wait') + client.request('create', bot='Bob', workspace=str(self.path)) + # The first ends last: results still go in call order. + commands = ['sleep .9; printf 1'] + [f'sleep .6; printf {n}' for n in range(2, 7)] + started = time.monotonic() + submitted = client.request('submit', bot='Bob', request_id='p', prompt='para:' + '|'.join(commands))['result'] + turn = submitted['turn'] + finished = client.finished(turn)['data'] + elapsed = time.monotonic() - started + self.assertEqual(finished['status'], 'completed') + # Four at a time: two waves, not six commands in a row. + self.assertGreater(elapsed, 1.2) + self.assertLess(elapsed, 3.0) + ids = [f'para-{n}' for n in range(1, 7)] + result = client.request('wait', handles=[submitted['handle']])['result']['results'][submitted['handle']] + self.assertEqual(result['text'], 'echo:' + json.dumps(ids)) + events = client.request('events', bot='Bob', after=0, limit=256)['result']['events'] + self.assertEqual([e['data']['call_id'] for e in events if e['event'] == 'tool_completed'], ids) + for n, call in enumerate(ids, 1): + self.assertEqual(self.tool_output(client, 'Bob', call)['stdout'], str(n)) + + def test_a_steer_moves_every_running_command_to_the_background(self): + client = self.client('echo,shell,wait') + client.request('create', bot='Bob', workspace=str(self.path)) + turn = client.request('submit', bot='Bob', request_id='p', + prompt='para:sleep 1.5; printf a|sleep 1.5; printf b|printf c')['result']['turn'] + client.receive(lambda m: m.get('event') == 'tool_started' and m.get('turn') == turn + and m['data']['call_id'] == 'para-3') + time.sleep(.2) + sent = time.monotonic() + steer = client.request('submit', bot='Bob', request_id='s', prompt='steer:look', delivery='steer')['result'] + done = client.finished(steer['turn'])['data'] + self.assertEqual((done['status'], done['into']), ('steered', turn)) + self.assertLess(time.monotonic() - sent, 1.0) + for call in ('para-1', 'para-2'): + lifted = self.tool_output(client, 'Bob', call) + self.assertEqual((lifted['background'], lifted['steered']), (True, True)) + self.assertEqual(self.tool_output(client, 'Bob', 'para-3')['stdout'], 'c') + def test_a_command_that_ends_soon_after_a_steer_returns_as_usual(self): client = self.client('echo,shell,wait') client.request('create', bot='Bob', workspace=str(self.path)) From e32bf35f74a39bcf41beb95f70399aaee5cef378 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:03:45 +0000 Subject: [PATCH 20/22] A batch's results share commits; stored reads stay sequential; record the screen Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- docs/DAEMON_MEASUREMENTS.md | 25 +++++++++ src/server/turn.rs | 108 ++++++++++++++++++------------------ 2 files changed, 80 insertions(+), 53 deletions(-) diff --git a/docs/DAEMON_MEASUREMENTS.md b/docs/DAEMON_MEASUREMENTS.md index 0c4a2d849..7bc6167aa 100644 --- a/docs/DAEMON_MEASUREMENTS.md +++ b/docs/DAEMON_MEASUREMENTS.md @@ -5644,3 +5644,28 @@ Hot-path screen, in the same alternating run: the previous pacer change against Every range overlaps the other build's. The medians for completion p99 and lifecycle CPU are a little higher; neither bench waits on a learned limit, so the new path costs one look at an empty list of bookings per call. + +## Calls in a row run together (#291) + +The Perf workflow's matched screen, base main 93e57b8 against the PR head +78c5a4d, on the GitHub runner (2026-10-28): + +| Bench | Measure | Base | Head | Change | +| --- | --- | ---: | ---: | ---: | +| admission | lone submit CPU, ms | 38.914 | 38.556 | -0.9% | +| admission | burst submit p99, ms | 6.025 | 5.86 | -2.7% | +| completion | release to terminal p99, ms | 13.442 | 13.297 | -1.1% | +| footprint | idle RSS, MiB | 17.418 | 17.51 | +0.5% | +| footprint | per idle bot, KiB | 1.578 | 1.61 | +2.0% | +| footprint | RSS after a turn each, MiB | 26.357 | 26.639 | +1.1% | +| footprint | CPU per turn, ms | 1.284 | 1.268 | -1.2% | +| lifecycle | CPU, s | 1.225 | 1.165 | -4.9% | +| lifecycle | p95 turn, ms | 583.266 | 581.296 | -0.3% | +| lifecycle | peak RSS, MiB | 23.912 | 24.0 | +0.4% | + +Store jobs per turn did not change. Every change is within the gate's 15%. +These benches make one call per round, so they show the lone path unchanged. +The batch path's gain is wall time: in `tests/test_wait.py`, two commands +that each sleep 0.6 s finish in under 1.1 s. Results of a batch are queued +to the store in call order without waiting on each other, so calls that +finish together share a commit. diff --git a/src/server/turn.rs b/src/server/turn.rs index 7207419e1..e579b5b05 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -3507,10 +3507,35 @@ impl Turn { } } Ok(Prepared::Read { - source: source @ (ReadSource::Artifact { .. } | ReadSource::Result { .. }), + source: + ReadSource::Artifact { + turn: owner, + call_id: ref_call, + stream, + }, + offset, + limit, + }) => { + let bot = self.bot.clone(); + let text = self + .store + .op("artifact_lines", move |db| { + db.artifact_lines(&bot, owner, &ref_call, &stream, offset, limit) + }) + .await; + match text { + Ok(page) => Outcome::text(self.registry.redact_text(page)), + Err(error) => failure(error), + } + } + Ok(Prepared::Read { + source: ReadSource::Result { node }, offset, limit, - }) => self.read_stored(&call.call_id, source, offset, limit).await, + }) => match self.result_page(&call.call_id, node, offset, limit).await { + Ok(outcome) => outcome, + Err(error) => failure(error), + }, Ok(Prepared::History { bot, turn: wanted, @@ -3604,7 +3629,7 @@ impl Turn { } /// The call prepared, when it may run beside others: an ungated - /// command in the foreground or a read, which the bot may call. Codex + /// command in the foreground or a file read, which the bot may call. Codex /// runs these together too; a change to a file runs alone, in order. /// A sandboxed bot's command runs beside others only inside its /// sandbox; one that asks to leave it goes the lone way, to be refused. @@ -3623,11 +3648,16 @@ impl Turn { return None; } match self.registry.prepare(&call.name, &call.arguments) { + // A stored stream or result is paged to fit the context left, + // which a read beside it would spend too: it runs alone. Ok( prepared @ (Prepared::Shell { background: false, .. } - | Prepared::Read { .. }), + | Prepared::Read { + source: ReadSource::Path(_), + .. + }), ) => Some(prepared), _ => None, } @@ -3673,11 +3703,27 @@ impl Turn { }); let record = async { let mut results = stream::iter(runs).buffered(PARALLEL_CALLS); - while let Some((id, outcome)) = results.next().await { - let outcome = outcome?; - self.store - .op("tool_finish", move |db| db.tool_finish(turn, &id, &outcome)) - .await?; + // Each result is queued as it comes, in call order, and not + // waited on before the next: results done together share a + // commit, and the calls still running are polled meanwhile. + let mut writes = stream::FuturesOrdered::new(); + loop { + tokio::select! { + Some(written) = writes.next(), if !writes.is_empty() => drop(written?), + next = results.next() => match next { + Some((id, outcome)) => { + let outcome = outcome?; + writes.push_back(self.store.slot().await?.op( + "tool_finish", + move |db| db.tool_finish(turn, &id, &outcome), + )); + } + None => break, + }, + } + } + while let Some(written) = writes.next().await { + written?; } Ok(()) }; @@ -3733,14 +3779,6 @@ impl Turn { None => return (call_id.clone(), self.lift_shell(&call_id, run).await), } } - Prepared::Read { - source: source @ (ReadSource::Artifact { .. } | ReadSource::Result { .. }), - offset, - limit, - } => { - let outcome = self.read_stored(&call_id, source, offset, limit).await; - return (call_id, Ok(outcome)); - } prepared => { self.registry .execute(prepared, workspace, environment, sandbox) @@ -3798,42 +3836,6 @@ impl Turn { .map(|policy| policy.map(Arc::new)) } - /// A page of a stored stream or result, for `read`. - async fn read_stored( - &self, - call_id: &str, - source: ReadSource, - offset: usize, - limit: usize, - ) -> Outcome { - match source { - ReadSource::Artifact { - turn: owner, - call_id: ref_call, - stream, - } => { - let bot = self.bot.clone(); - let text = self - .store - .op("artifact_lines", move |db| { - db.artifact_lines(&bot, owner, &ref_call, &stream, offset, limit) - }) - .await; - match text { - Ok(page) => Outcome::text(self.registry.redact_text(page)), - Err(error) => failure(error), - } - } - ReadSource::Result { node } => { - match self.result_page(call_id, node, offset, limit).await { - Ok(outcome) => outcome, - Err(error) => failure(error), - } - } - ReadSource::Path(_) => unreachable!("a file is read by the registry"), - } - } - /// Wait for a gated call's verdict: live for the hold, then parked like /// `wait`, holding no task and no slot. Answers wake the task through /// the worker, so each check is one job and no commit. From 0c490427dd2408efe02308473a59d2e98441e060 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:15:13 +0000 Subject: [PATCH 21/22] A batch runs at most half the process budget at once; its results wait for store room beside the running calls Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/server/turn.rs | 58 +++++++++++++++++++++++++--------------------- src/tools.rs | 22 ++++++++++++++++++ 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/server/turn.rs b/src/server/turn.rs index e579b5b05..24459546c 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -3664,9 +3664,9 @@ impl Turn { } /// Run a batch of calls [`Self::parallel`] admits together, up to - /// [`PARALLEL_CALLS`] at a time: all started in one commit, each - /// result recorded in call order as soon as it and those before it - /// are done. A steer moves every command still running to the + /// [`PARALLEL_CALLS`] at a time and no more than half the process + /// budget: all started in one commit, each result recorded in call + /// order as soon as it and those before it are done. A steer moves every command still running to the /// background, as it does a lone one. async fn execute_parallel( &self, @@ -3702,30 +3702,36 @@ impl Turn { self.run_parallel(call.call_id, prepared, workspace, environment, sandbox) }); let record = async { - let mut results = stream::iter(runs).buffered(PARALLEL_CALLS); - // Each result is queued as it comes, in call order, and not - // waited on before the next: results done together share a - // commit, and the calls still running are polled meanwhile. - let mut writes = stream::FuturesOrdered::new(); - loop { - tokio::select! { - Some(written) = writes.next(), if !writes.is_empty() => drop(written?), - next = results.next() => match next { - Some((id, outcome)) => { - let outcome = outcome?; - writes.push_back(self.store.slot().await?.op( - "tool_finish", - move |db| db.tool_finish(turn, &id, &outcome), - )); - } - None => break, - }, + // Results go to the store in call order as they come, each + // queued without waiting on the one before, so results done + // together share a commit. Waiting for room on the store's + // queue happens beside the calls still running, which go on + // being polled. + let (done, mut recorded) = tokio::sync::mpsc::unbounded_channel(); + let run = async move { + let mut results = stream::iter(runs).buffered(self.registry.wide(PARALLEL_CALLS)); + while let Some((id, outcome)) = results.next().await { + let _ = done.send((id, outcome?)); } - } - while let Some(written) = writes.next().await { - written?; - } - Ok(()) + Ok::<_, Error>(()) + }; + let write = async { + let mut written = Vec::new(); + while let Some((id, outcome)) = recorded.recv().await { + written.push( + self.store + .slot() + .await? + .op("tool_finish", move |db| db.tool_finish(turn, &id, &outcome)), + ); + } + for answer in written { + answer.await?; + } + Ok::<_, Error>(()) + }; + let (ran, wrote) = tokio::join!(run, write); + ran.and(wrote) }; match self.run_tool(record, None, warm).await? { Ran::Done(result) => result, diff --git a/src/tools.rs b/src/tools.rs index ea2e751e2..e42290734 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -853,6 +853,15 @@ impl Registry { })); self } + /// How many of one bot's calls may run together: `cap` at most, and + /// no more than half the process budget, so one bot's wide round + /// leaves room for the others. + pub fn wide(&self, cap: usize) -> usize { + match self.budget { + 0 => cap, + budget => cap.min(budget / 2).max(1), + } + } /// Take a place in line for a background command, or refuse when as many /// are already waiting as may run: a counter and a comparison, so a fleet /// cannot accumulate an invisible backlog behind the process bound. @@ -1957,6 +1966,19 @@ async fn shell( mod tests { use super::*; #[test] + fn one_bot_runs_at_most_half_the_process_budget_at_once() { + let wide = |budget| { + Registry::new("shell") + .unwrap() + .with_process_budget(budget) + .wide(4) + }; + assert_eq!( + [wide(0), wide(1), wide(2), wide(4), wide(64)], + [4, 1, 1, 2, 4] + ); + } + #[test] fn binary_files_are_named_and_text_passes() { assert_eq!(binary(b"\x89PNG\r\n\x1a\n\0\0"), Some("a PNG image")); assert_eq!(binary(b"RIFF\0\0\0\0WEBPVP8 "), Some("a WebP image")); From 008da79e4b1aa9f729c577627824f26399001720 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:22:11 +0000 Subject: [PATCH 22/22] A batch drops each committed result's acknowledgement as it comes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGFtCdGun1rPhhwTSrG --- src/server/turn.rs | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/server/turn.rs b/src/server/turn.rs index 24459546c..01fcf084f 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -3706,7 +3706,8 @@ impl Turn { // queued without waiting on the one before, so results done // together share a commit. Waiting for room on the store's // queue happens beside the calls still running, which go on - // being polled. + // being polled; meanwhile the channel holds at most one result + // per call in the batch. let (done, mut recorded) = tokio::sync::mpsc::unbounded_channel(); let run = async move { let mut results = stream::iter(runs).buffered(self.registry.wide(PARALLEL_CALLS)); @@ -3715,18 +3716,25 @@ impl Turn { } Ok::<_, Error>(()) }; + // Each write's acknowledgement carries its result: dropped as + // it comes, so committed results aren't held for the batch. let write = async { - let mut written = Vec::new(); - while let Some((id, outcome)) = recorded.recv().await { - written.push( - self.store - .slot() - .await? - .op("tool_finish", move |db| db.tool_finish(turn, &id, &outcome)), - ); + let mut written = stream::FuturesOrdered::new(); + loop { + tokio::select! { + Some(answer) = written.next(), if !written.is_empty() => drop(answer?), + next = recorded.recv() => match next { + Some((id, outcome)) => written.push_back( + self.store.slot().await?.op("tool_finish", move |db| { + db.tool_finish(turn, &id, &outcome) + }), + ), + None => break, + }, + } } - for answer in written { - answer.await?; + while let Some(answer) = written.next().await { + answer?; } Ok::<_, Error>(()) };