From 2fb0dab54e005afc9c6d8083bea91539f35cdfc7 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 25 Sep 2026 06:02:12 +0000 Subject: [PATCH] Let writer tools replace a conversation directory --- SPEC.md | 8 + .../conversation-protocol/src/v3/apply.rs | 3 + .../src/v3/content_tests.rs | 65 ++++++ .../conversation-protocol/src/v3/validate.rs | 41 +++- .../conversation-protocol/src/v3/view.rs | 11 +- std/llm-step/src/main.rs | 137 ++++++++--- std/llm-step/src/scoped.rs | 218 ++++++++++++++++++ std/llm-step/src/tools.rs | 31 ++- 8 files changed, 468 insertions(+), 46 deletions(-) create mode 100644 std/llm-step/src/scoped.rs diff --git a/SPEC.md b/SPEC.md index 413dab053..851684f0a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -360,6 +360,14 @@ source tree returns a source commit; one run on the conversation returns a conversation commit. The path the caller named already decides which, so the declaration says only WHETHER a tool writes, never where. +A `run_tool` call may select `scope=` for a writer +that declares `@in` without `@git`. The tool receives that directory's tree and returns +its replacement as `prop`. Scope and the conversation input commit stay in the +invocation record, outside the worker's cache key. The ordinary proposal apply +path treats this as one directory replacement: any concurrent change inside +it rejects the replacement, while edits elsewhere are preserved. An empty +replacement tree deletes the directory, since Git does not store empty folders. + A writer's `out` SHALL be its own. Composing it in the harness — as the merge conflict report once was — makes the tool's answer depend on who ran it, which is the same reason the `help` lives in the expression. diff --git a/rust/crates/conversation-protocol/src/v3/apply.rs b/rust/crates/conversation-protocol/src/v3/apply.rs index 4787a7ff0..376ef01b3 100644 --- a/rust/crates/conversation-protocol/src/v3/apply.rs +++ b/rust/crates/conversation-protocol/src/v3/apply.rs @@ -1007,6 +1007,9 @@ fn apply_files( builder.put(&path, *mode, bytes.clone()); } Some((mode @ (Mode::Tree | Mode::Commit), bytes)) => { + if *mode == Mode::Tree { + builder.delete(&path); + } builder.put_oid(&path, *mode, Oid::parse_line(bytes, "content object")?); } None => builder.delete(&path), diff --git a/rust/crates/conversation-protocol/src/v3/content_tests.rs b/rust/crates/conversation-protocol/src/v3/content_tests.rs index 9d683adf2..b9bb2b420 100644 --- a/rust/crates/conversation-protocol/src/v3/content_tests.rs +++ b/rust/crates/conversation-protocol/src/v3/content_tests.rs @@ -170,3 +170,68 @@ fn code_paths_are_content_and_renaming_preserves_code_history() { .unwrap() .is_some()); } + +#[test] +fn tool_completion_roundtrips_directory_replacement_and_deletion() { + let mut store = MemoryStore::new(); + let mut cursor = fixtures::golden(&mut store); + let (parent, record) = loop { + let info = store.read_commit(&cursor).unwrap(); + let (kind, events) = events::decode(&info.message).unwrap(); + if kind == Kind::ToolComplete { + if let Some(record) = events.into_iter().find_map(|event| match event { + Event::Tool(record) if !record.files.is_empty() => Some(record), + _ => None, + }) { + break (info.parents[0].clone(), record); + } + } + cursor = info.parents[0].clone(); + }; + let sig = client_signature("Test", "test@example.com", 1); + let mut original = TreeBuilder::from(None); + original.put("old", Mode::Blob, b"old contents".to_vec()); + let original = original.build(&mut store).unwrap(); + let setup = Transition::FilesApply { + files: vec![("feature".into(), Some((Mode::Tree, original.encode_line())))], + }; + let applied = apply(&mut store, Some(&parent), &setup).unwrap(); + let before = mint(&mut store, &parent, &applied, setup.kind(), &sig).unwrap(); + let mut replacement = TreeBuilder::from(None); + replacement.put("new", Mode::Blob, b"new contents".to_vec()); + let replacement = replacement.build(&mut store).unwrap(); + for value in [Some((Mode::Tree, replacement.encode_line())), None] { + let transition = Transition::ToolComplete { + record: CallRecord { + files: vec!["feature".into()], + files_outcome: Some(FilesOutcome { + applied: vec!["feature".into()], + conflicted: Vec::new(), + }), + ..record.clone() + }, + payloads: vec![("observation".into(), b"changed".to_vec())], + files: vec![("feature".into(), value.clone())], + }; + let applied = apply(&mut store, Some(&before), &transition).unwrap(); + let after = mint(&mut store, &before, &applied, transition.kind(), &sig).unwrap(); + validate_spine(&store, &after, &mut HashSet::new()).unwrap(); + let tree = store.read_commit(&after).unwrap().tree; + assert_eq!( + Snapshot::new(&store, tree.clone()) + .entry("feature") + .unwrap() + .map(|entry| entry.oid), + value.as_ref().map(|_| replacement.clone()) + ); + let mut tampered = TreeBuilder::from(Some(tree)); + tampered.put("unrecorded", Mode::Blob, b"outside the scope".to_vec()); + let mut info = store.read_commit(&after).unwrap(); + info.tree = tampered.build(&mut store).unwrap(); + let tampered = store.write_commit(&info).unwrap(); + assert!(validate_commit(&store, &tampered) + .unwrap_err() + .reason + .contains("unrecorded")); + } +} diff --git a/rust/crates/conversation-protocol/src/v3/validate.rs b/rust/crates/conversation-protocol/src/v3/validate.rs index 5b5d7db0a..71ccc7995 100644 --- a/rust/crates/conversation-protocol/src/v3/validate.rs +++ b/rust/crates/conversation-protocol/src/v3/validate.rs @@ -304,10 +304,14 @@ fn reconstruct( paths::call_payload_dir(record.request.as_str(), record.round, &record.id); Ok(Transition::ToolComplete { payloads: payload_changes(kind, child_snapshot, changes, &payload_dir)?, - files: file_changes(child_snapshot, changes)? - .into_iter() - .filter(|(path, _)| record.files.contains(path)) - .collect(), + // A recorded edit can replace a whole directory. The tree diff + // expands directories into leaves, so reconstruct at the recorded + // paths instead; reapplication below still checks the whole tree. + files: recorded_file_changes( + parent_snapshot.expect("non-root parent checked above"), + child_snapshot, + &record.files, + )?, record, }) } @@ -539,6 +543,35 @@ fn payload_changes( .collect() } +fn recorded_file_changes( + parent: &Conversation<'_>, + child: &Conversation<'_>, + paths: &[String], +) -> Result { + let changes = paths + .iter() + .map(|path| { + let before = parent + .snapshot() + .entry(path)? + .map(|entry| (entry.mode, entry.oid)); + let after = child + .snapshot() + .entry(path)? + .map(|entry| (entry.mode, entry.oid)); + if before == after { + return Err(format!("recorded tool file {path:?} did not change")); + } + Ok(Change { + path: path.clone(), + before, + after, + }) + }) + .collect::, String>>()?; + file_changes(child, &changes) +} + fn file_changes(child: &Conversation<'_>, changes: &[Change]) -> Result { changes .iter() diff --git a/rust/crates/conversation-protocol/src/v3/view.rs b/rust/crates/conversation-protocol/src/v3/view.rs index 05e0e1286..b952f8713 100644 --- a/rust/crates/conversation-protocol/src/v3/view.rs +++ b/rust/crates/conversation-protocol/src/v3/view.rs @@ -414,13 +414,16 @@ impl<'s> Conversation<'s> { } pub fn payload(&self, path: &str) -> Result, String> { + self.optional_payload(path)? + .ok_or_else(|| format!("required path {path} is absent")) + } + + pub fn optional_payload(&self, path: &str) -> Result>, String> { paths::validate_tree_path(path)?; if let Some(bytes) = self.execution()?.payloads.get(path) { - return Ok(bytes.clone()); + return Ok(Some(bytes.clone())); } - self.snapshot - .read(path)? - .ok_or_else(|| format!("required path {path} is absent")) + self.snapshot.read(path) } pub fn async_task(&self, task: &Oid) -> Result, String> { diff --git a/std/llm-step/src/main.rs b/std/llm-step/src/main.rs index fe3097bb4..bebd0fad2 100644 --- a/std/llm-step/src/main.rs +++ b/std/llm-step/src/main.rs @@ -5,6 +5,7 @@ mod githist; mod import_source; mod progress; mod publish_source; +mod scoped; mod source_trees; mod subagents; mod timing; @@ -1624,15 +1625,62 @@ fn launch_resolved_tool( return resume(cfg, state, request, request_head); } }; + let scope = match call.input.get("scope") { + None => None, + Some(Value::String(scope)) if tool.writer && tool.wants_in && !tool.git => { + Some(scope.clone()) + } + _ => { + site.failed( + state, + &error_block(id, "scope requires a writer with @in and without @git"), + name.map(|name| (name, commit)), + )?; + return resume(cfg, state, request, request_head); + } + }; + let scoped_input = match scope.as_deref() { + Some(scope) => match scoped::input(state.store(), &conversation, scope) { + Ok(tree) if !bound.iter().any(|(name, _)| name == "in") => Some(tree), + Ok(_) => { + site.failed( + state, + &error_block( + id, + "scope selects the writer input; do not also pass arguments.in", + ), + name.map(|name| (name, commit)), + )?; + return resume(cfg, state, request, request_head); + } + Err(error) => { + site.failed( + state, + &error_block(id, &error), + name.map(|name| (name, commit)), + )?; + return resume(cfg, state, request, request_head); + } + }, + None => None, + }; let tool_tree = cas_hash(&image)?; - let source_tree_name = name; + let source_tree_name = if scope.is_some() { None } else { name }; + let commit = if scope.is_some() { + state.head().clone() + } else { + commit + }; // Each argument binds at its OWN kind: a literal as bytes, a tree by hash, // a commit as a materialized path (which preserves the kind). let ready: Vec<(String, tools::ReadyArg)> = bound .iter() .map(|(key, value)| Ok((key.clone(), value.ready()?))) .collect::>()?; - let (args, input) = split_input(&ready, tool.wants_in, ws.as_str()); + let (args, mut input) = split_input(&ready, tool.wants_in, ws.as_str()); + if let Some(tree) = scoped_input.as_ref() { + input = vec![("in", Arg::Hash(tree.as_str()))]; + } let mut args = args; if tool.git { args.push(("wc", Arg::Path(&wc))); @@ -1676,7 +1724,9 @@ fn launch_resolved_tool( match state.try_append_at( &expected, Transition::ToolStart { - payloads: Vec::new(), + payloads: scope + .map(|scope| vec![("scope.txt".into(), scope.into_bytes())]) + .unwrap_or_default(), record: started.clone(), }, )? { @@ -1847,34 +1897,57 @@ fn writer_callback( .input_commit .as_ref() .ok_or("a writer's tool.start has no input commit")?; - let proposal = match result.kind.as_str() { - // The common case: the tool hands back a tree and never has to know - // what a commit is. Parentage is ours, so it descends by construction. - "tree" => { - let tree = Oid::parse(&cas_hash(&result.prop)?, "writer result tree")?; - let message = result.message.as_deref().unwrap_or(tool); - mint_source_tree_commit(state, &tree, base, message.trim())? + let scope = scoped::saved(state, record)?; + let proposal = if let Some(scope) = scope { + if result.kind != "tree" { + return fail(format!("{tool}: a scoped writer must return a tree")); } - // A commit the tool built itself, which only a second parent justifies. - // `reconcile` ERRORS rather than conflicts when a proposal does not - // descend from its base, so check it here, where the model can be told - // which two commits disagree instead of losing the turn to it. - _ => { - let proposal = Oid::parse(&cas_hash(&result.prop)?, "writer result commit")?; - state.fetch_object(&proposal)?; - let descends = conversation_protocol::v3::CodeOps::is_ancestor( - state.store(), - base, - &proposal, - )?; - if !descends { - return fail(format!( - "{tool}: the returned commit {proposal} does not descend from the commit \ + let tree = Oid::parse(&cas_hash(&result.prop)?, "writer scope result")?; + // Git packing needs the returned directory objects locally. Fetching + // this tree does not follow its source gitlinks into repository history. + state.fetch_object(&tree)?; + let base_tree = state.store().tree_of(base)?; + let tree = scoped::graft(state.store_mut(), &base_tree, &scope, tree)?; + let commit = mint_source_tree_commit( + state, + &tree, + base, + result.message.as_deref().unwrap_or(tool), + )?; + // The returned subtree is already on the server. Publish the small + // enclosing conversation trees built here before recording the proposal. + state.push_code(&commit)?; + commit + } else { + match result.kind.as_str() { + // The common case: the tool hands back a tree and never has to know + // what a commit is. Parentage is ours, so it descends by construction. + "tree" => { + let tree = Oid::parse(&cas_hash(&result.prop)?, "writer result tree")?; + let message = result.message.as_deref().unwrap_or(tool); + mint_source_tree_commit(state, &tree, base, message.trim())? + } + // A commit the tool built itself, which only a second parent justifies. + // `reconcile` ERRORS rather than conflicts when a proposal does not + // descend from its base, so check it here, where the model can be told + // which two commits disagree instead of losing the turn to it. + _ => { + let proposal = Oid::parse(&cas_hash(&result.prop)?, "writer result commit")?; + state.fetch_object(&proposal)?; + let descends = conversation_protocol::v3::CodeOps::is_ancestor( + state.store(), + base, + &proposal, + )?; + if !descends { + return fail(format!( + "{tool}: the returned commit {proposal} does not descend from the commit \ it was given ({base}). A writer must build on its input; return a tree \ instead and the commit will be made for you." - )); + )); + } + proposal } - proposal } }; Ok(( @@ -1923,7 +1996,12 @@ fn complete_compute( .input_commit .as_ref() .ok_or("tool has no input commit")?; - let changes = if let Some(name) = &started.source_tree_name { + let scope = scoped::saved(state, started)?; + let changes = if let Some(scope) = scope { + let before = state.store().tree_of(base)?; + let after = state.store().tree_of(&proposal)?; + vec![scoped::change(state.store(), &before, &after, &scope)?] + } else if let Some(name) = &started.source_tree_name { vec![conversation_protocol::v3::tree::Change { path: name.clone(), before: Some((Mode::Commit, base.clone())), @@ -3150,7 +3228,8 @@ fn registry(cfg: &Config) -> Result, String> { "name":"run_tool", "description":"Run a repository tool by conversation-relative path, e.g. feature/dirty/caos-tools/test. The tool runs with the containing source tree as its input. Nothing lists the available tools: each repository documents its own, and `tool_help` at a path gives that tool's parameters. Pass them under `arguments` as strings.", "input_schema":{"type":"object","properties":{ - "path":{"type":"string"}, "arguments":{"type":"object"} + "path":{"type":"string"}, "arguments":{"type":"object"}, + "scope":{"type":"string","description":"For a writer with @in: conversation directory to replace atomically. The worker receives only this subtree. Concurrent edits inside it reject the proposal."} },"required":["path"]} })); Ok(registry) diff --git a/std/llm-step/src/scoped.rs b/std/llm-step/src/scoped.rs new file mode 100644 index 000000000..91a193503 --- /dev/null +++ b/std/llm-step/src/scoped.rs @@ -0,0 +1,218 @@ +//! A scoped writer replaces one ordinary conversation directory. Its task +//! contains only that tree; invocation metadata stays in the conversation. +use super::*; +use conversation_protocol::v3::Snapshot; + +pub(super) fn input(store: &dyn ObjectStore, root: &Oid, scope: &str) -> Result { + paths::validate_source_tree_name(scope)?; + match Snapshot::new(store, root.clone()).entry(scope)? { + Some(entry) if entry.mode == Mode::Tree => Ok(entry.oid), + _ => Err(format!( + "writer scope {scope:?} must be a conversation directory" + )), + } +} + +pub(super) fn saved( + state: &progress::State, + record: &CallRecord, +) -> Result, String> { + let path = format!( + "{}/scope.txt", + paths::call_payload_dir(record.request.as_str(), record.round, &record.id) + ); + state + .conversation()? + .optional_payload(&path)? + .map(|bytes| String::from_utf8(bytes).map_err(|_| "invalid writer scope".into())) + .transpose() +} + +pub(super) fn graft( + store: &mut dyn ObjectStore, + root: &Oid, + scope: &str, + replacement: Oid, +) -> Result { + input(store, root, scope)?; + let mut proposal = conversation_protocol::v3::TreeBuilder::from(Some(root.clone())); + proposal.delete(scope); + proposal.put_oid(scope, Mode::Tree, replacement); + proposal.build(store) +} + +pub(super) fn change( + store: &dyn ObjectStore, + before: &Oid, + after: &Oid, + scope: &str, +) -> Result { + Ok(conversation_protocol::v3::tree::Change { + path: scope.to_owned(), + before: Some((Mode::Tree, input(store, before, scope)?)), + after: match Snapshot::new(store, after.clone()).entry(scope)? { + Some(entry) if entry.mode == Mode::Tree => Some((Mode::Tree, entry.oid)), + None => None, // Git omits empty directories. + _ => return Err("scoped proposal must be a directory".into()), + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use conversation_protocol::v3::{GitStore, TreeBuilder}; + + struct Temp(std::path::PathBuf); + impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn fixture() -> (Temp, GitStore, Oid, Oid) { + let path = std::env::temp_dir().join(fresh_name("scoped-writer-test")); + assert!(std::process::Command::new("git") + .args(["init", "--quiet", "--bare"]) + .arg(&path) + .status() + .unwrap() + .success()); + let mut store = GitStore::open(&path, None).unwrap(); + let mut feature = TreeBuilder::from(None); + feature.put("00.base", Mode::Blob, b"base".to_vec()); + // Rewritten gitlinks must be replaced, never reconciled as code edits. + feature.put_oid( + "00-work", + Mode::Commit, + Oid::parse(&"a".repeat(40), "test").unwrap(), + ); + let feature = feature.build(&mut store).unwrap(); + let mut root = TreeBuilder::from(None); + root.put_oid("feature", Mode::Tree, feature.clone()); + root.put("notes", Mode::Blob, b"before".to_vec()); + let root = root.build(&mut store).unwrap(); + (Temp(path), store, root, feature) + } + + #[test] + fn unrelated_conversation_edits_do_not_change_writer_input_or_block_replacement() { + let (_temp, mut store, root, feature) = fixture(); + let mut output = TreeBuilder::from(Some(feature.clone())); + output.put_oid( + "00-work", + Mode::Commit, + Oid::parse(&"b".repeat(40), "test").unwrap(), + ); + let output = output.build(&mut store).unwrap(); + let proposed = graft(&mut store, &root, "feature", output.clone()).unwrap(); + let change = change(&store, &root, &proposed, "feature").unwrap(); + let mut current = TreeBuilder::from(Some(root)); + current.put("notes", Mode::Blob, b"after".to_vec()); + let current = current.build(&mut store).unwrap(); + assert_eq!(input(&store, ¤t, "feature").unwrap(), feature); + let plan = plan_file_changes(&mut store, &[change], ¤t, None).unwrap(); + assert!(plan.conflicts.is_empty()); + assert_eq!( + plan.files, + vec![("feature".into(), Some((Mode::Tree, output.encode_line())))] + ); + } + + #[test] + fn concurrent_scope_edit_rejects_the_whole_proposal() { + let (_temp, mut store, root, feature) = fixture(); + let mut output = TreeBuilder::from(Some(feature.clone())); + output.put("00.base", Mode::Blob, b"proposed".to_vec()); + let output = output.build(&mut store).unwrap(); + let proposed = graft(&mut store, &root, "feature", output).unwrap(); + let change = change(&store, &root, &proposed, "feature").unwrap(); + let mut current = TreeBuilder::from(Some(root)); + current.put("feature/notes", Mode::Blob, b"concurrent edit".to_vec()); + let current = current.build(&mut store).unwrap(); + let plan = plan_file_changes(&mut store, &[change], ¤t, None).unwrap(); + assert_eq!(plan.conflicts, ["feature"]); + assert!(plan.files.is_empty()); + } + + #[test] + fn scope_cannot_be_protocol_state_a_gitlink_or_a_path_through_one() { + let (_temp, store, root, _) = fixture(); + for scope in [ + ".caos", + "../feature", + "feature/00-work", + "feature/00-work/src", + "notes", + "missing", + ] { + assert!(input(&store, &root, scope).is_err(), "{scope}"); + } + } + + fn files_transition(store: &mut GitStore, head: &Oid, files: FileEdits) -> Oid { + let transition = Transition::FilesApply { files }; + let applied = apply(store, Some(head), &transition).unwrap(); + let signature = inherited_signature(store, head).unwrap(); + mint(store, head, &applied, transition.kind(), &signature).unwrap() + } + + #[test] + fn directory_replacement_applies_through_the_conversation_transition() { + let (_temp, mut store, _, feature) = fixture(); + let root = conversation_protocol::v3::fixtures::golden(&mut store); + let before = files_transition( + &mut store, + &root, + vec![("feature".into(), Some((Mode::Tree, feature.encode_line())))], + ); + let before_tree = store.tree_of(&before).unwrap(); + let mut replacement = TreeBuilder::from(None); + replacement.put("replacement", Mode::Blob, b"new contents".to_vec()); + let replacement = replacement.build(&mut store).unwrap(); + let proposed = graft(&mut store, &before_tree, "feature", replacement.clone()).unwrap(); + let change = change(&store, &before_tree, &proposed, "feature").unwrap(); + let plan = plan_file_changes(&mut store, &[change], &before_tree, None).unwrap(); + let after = files_transition(&mut store, &before, plan.files); + let after_tree = store.tree_of(&after).unwrap(); + let snapshot = Snapshot::new(&store, after_tree); + assert_eq!(snapshot.entry("feature").unwrap().unwrap().oid, replacement); + assert!(!snapshot.exists("feature/00-work").unwrap()); + assert_eq!( + snapshot.read("feature/replacement").unwrap().unwrap(), + b"new contents" + ); + assert_eq!( + snapshot.read("notes.md").unwrap().unwrap(), + b"seeded notes\n" + ); + } + + #[test] + fn empty_output_deletes_only_the_scope_and_is_retry_safe() { + let (_temp, mut store, root, _) = fixture(); + let proposed = graft( + &mut store, + &root, + "feature", + conversation_protocol::v3::oid::empty_tree(), + ) + .unwrap(); + let change = change(&store, &root, &proposed, "feature").unwrap(); + assert_eq!(change.after, None); + let plan = + plan_file_changes(&mut store, std::slice::from_ref(&change), &root, None).unwrap(); + assert_eq!(plan.files, [("feature".into(), None)]); + assert!(plan.conflicts.is_empty()); + let retry = plan_file_changes(&mut store, &[change], &proposed, None).unwrap(); + assert!(retry.files.is_empty()); + assert!(retry.conflicts.is_empty()); + assert_eq!( + Snapshot::new(&store, proposed) + .read("notes") + .unwrap() + .unwrap(), + b"before" + ); + } +} diff --git a/std/llm-step/src/tools.rs b/std/llm-step/src/tools.rs index a5fc3d476..62eb3e34b 100644 --- a/std/llm-step/src/tools.rs +++ b/std/llm-step/src/tools.rs @@ -340,7 +340,6 @@ impl Bound { _ => None, } } - } /// Parse one `@param` tag's payload (everything after the tag) into a @@ -479,6 +478,9 @@ fn parse_help(ctx: &str, text: &str) -> Help { git = true; } else if trimmed == "@writer" { in_tags = true; + if writer { + eprintln!("{ctx}: duplicate @writer tag ignored"); + } writer = true; } else if trimmed == "@in" { in_tags = true; @@ -734,13 +736,15 @@ pub fn writer_result(result: &str) -> Result { let prop = path(&prop).to_string(); let kind = worker_common::cas_kind(&prop)?; if kind != "tree" && kind != "commit" { - return Err(format!("a writer's `prop` must be a tree or a commit, not a {kind}")); + return Err(format!( + "a writer's `prop` must be a tree or a commit, not a {kind}" + )); } // `out` is REQUIRED. Substituting a generic line for a tool that forgot it // would hide the tool's silence behind the harness's voice, and the whole // point of the entry is that the model learns what happened. - let out = blob("out")? - .ok_or("a writer's result has no `out` entry — the text for the model")?; + let out = + blob("out")?.ok_or("a writer's result has no `out` entry — the text for the model")?; Ok(WriterResult { prop, kind, @@ -1403,7 +1407,10 @@ mod tests { .as_str() .unwrap(); assert!(doc.contains("The tree to test."), "keeps the author's text"); - assert!(doc.contains("conversation-relative path"), "says how to name one"); + assert!( + doc.contains("conversation-relative path"), + "says how to name one" + ); let doc = d["input_schema"]["properties"]["against"]["description"] .as_str() .unwrap(); @@ -1456,7 +1463,10 @@ mod tests { assert_eq!(tester.args[0].name, "in"); assert_eq!(tester.args[0].ty, ArgType::Tree); assert!(!tester.args[0].required, "it defaults, so it is optional"); - assert!(text.contains("in (optional)"), "listed for the model: {text}"); + assert!( + text.contains("in (optional)"), + "listed for the model: {text}" + ); // A tool may not declare it by hand: `in` is what the interpreter // binds, and `@in` is the way to ask for it. @@ -1543,7 +1553,11 @@ mod tests { // `@params x` is NOT `@param` with `s x` after it — that once minted an // arg named `s` and silently lost the real one. let h = parse_help("t", "d\n@params hash The hash."); - assert!(h.args.is_empty(), "got {:?}", h.args.first().map(|a| &a.name)); + assert!( + h.args.is_empty(), + "got {:?}", + h.args.first().map(|a| &a.name) + ); // An `@` inside prose is not a tag. Both of these appear in help and // commit text today. @@ -1740,8 +1754,7 @@ mod tests { // Scalars are stringified, since a `{string}` arg reaches the script as // a blob. - let bound = - tree_tool_args(&call(json!({"word": 7, "suffix": true})), &tool, None).unwrap(); + let bound = tree_tool_args(&call(json!({"word": 7, "suffix": true})), &tool, None).unwrap(); assert_eq!( bound, vec![