Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<conversation-directory>` 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.
Expand Down
3 changes: 3 additions & 0 deletions rust/crates/conversation-protocol/src/v3/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
65 changes: 65 additions & 0 deletions rust/crates/conversation-protocol/src/v3/content_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
}
41 changes: 37 additions & 4 deletions rust/crates/conversation-protocol/src/v3/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand Down Expand Up @@ -539,6 +543,35 @@ fn payload_changes(
.collect()
}

fn recorded_file_changes(
parent: &Conversation<'_>,
child: &Conversation<'_>,
paths: &[String],
) -> Result<FileChanges, String> {
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::<Result<Vec<_>, String>>()?;
file_changes(child, &changes)
}

fn file_changes(child: &Conversation<'_>, changes: &[Change]) -> Result<FileChanges, String> {
changes
.iter()
Expand Down
11 changes: 7 additions & 4 deletions rust/crates/conversation-protocol/src/v3/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,13 +414,16 @@ impl<'s> Conversation<'s> {
}

pub fn payload(&self, path: &str) -> Result<Vec<u8>, String> {
self.optional_payload(path)?
.ok_or_else(|| format!("required path {path} is absent"))
}

pub fn optional_payload(&self, path: &str) -> Result<Option<Vec<u8>>, 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<Option<AsyncRecord>, String> {
Expand Down
137 changes: 108 additions & 29 deletions std/llm-step/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod githist;
mod import_source;
mod progress;
mod publish_source;
mod scoped;
mod source_trees;
mod subagents;
mod timing;
Expand Down Expand Up @@ -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::<Result<_, String>>()?;
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)));
Expand Down Expand Up @@ -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(),
},
)? {
Expand Down Expand Up @@ -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((
Expand Down Expand Up @@ -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())),
Expand Down Expand Up @@ -3150,7 +3228,8 @@ fn registry(cfg: &Config) -> Result<Vec<Value>, 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)
Expand Down
Loading
Loading