feat(skills): a files arm — progressive disclosure through Read, not a deferred tool
deploy / test (push) Successful in 5m18s
deploy / build (push) Successful in 5m36s

The `index` arm retrieves through `ReadMcpResourceTool`, which is DEFERRED:
absent from the agent's default tool list until `ToolSearch` loads it. Across
three matched production runs (same recipe, same task, same three offered
uris) it retrieved 1 skill in 9 chances:

    01a07812  delegation forced      no instruction    0/3
    01a0842e  no delegation          no instruction    1/3
    01a09877  no delegation          told to load it   0/3

The third run is the decisive one. The preamble said in plain words to run
ToolSearch first; all three prompts carried it; zero ToolSearch calls, and the
three reasoning narratives never mention skills at all. The section was not
declined, it was never engaged with. Instruction is not the lever.

`Read` is a core tool. Never deferred, and every one of those agents used it.
So this arm keeps progressive disclosure exactly as `index` has it — a name, a
`when_to_use`, and a pointer the agent has to follow — and changes only what
the pointer is: a path under /mission/skills instead of an MCP uri. The bodies
are written into the container at launch (every visible skill, one tar upload;
bindings resolve per agent at turn time so a per-mission subset is not knowable
here) and a `Read` of that path is a tapped tool call, so Trigger is exactly as
observable as before.

A third arm and not a replacement, selected per mission like the others, so
the comparison runs against one binary. `resolve` falls back to `inline` when
the files were not written, for the reason `index` does: a pointer to nothing
reads as an agent ignoring its skills.

The writer and reader of a path are one pair of functions
(`skill_file_path` / `skill_from_file_path`), matched by the scorer through
the same seam `parse_uri` uses, and the end-to-end test fails when the matcher
is broken. `Mode::is_retrieval` exists so the next arm cannot silently inherit
`inline`'s "not observable" for what is a miss.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-12 22:33:41 -05:00
co-authored by Claude Opus 5
parent 8d6310f126
commit 00160739de
7 changed files with 421 additions and 61 deletions
+45
View File
@@ -247,6 +247,51 @@ pub async fn put_file(
.map_err(|e| format!("upload {path} to {container}: {e}"))
}
/// Build a flat tar of several files. [`single_file_archive`] for many.
fn files_archive(files: &[(String, Vec<u8>)]) -> Result<Vec<u8>, String> {
let mut builder = tar::Builder::new(Vec::new());
for (name, contents) in files {
let mut header = tar::Header::new_gnu();
header
.set_path(name)
.map_err(|e| format!("tar path {name}: {e}"))?;
header.set_size(contents.len() as u64);
// World-readable, unlike `single_file_archive`'s 0600: that one carries
// a credential, this one carries procedures the agent is meant to read.
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
builder
.append(&header, contents.as_slice())
.map_err(|e| format!("tar {name}: {e}"))?;
}
builder
.into_inner()
.map_err(|e| format!("finish archive of {} files: {e}", files.len()))
}
/// Write several files into one directory of a container, in one upload.
///
/// `dir` must already exist — `upload_to_container` will not create it, the
/// same constraint [`sync_in`] works around. Size-independent for the reason
/// [`put_file`] gives; fifty skill bodies would be well past `ARG_MAX` as a
/// printf.
pub async fn put_files(
docker: &Docker,
container: &str,
dir: &str,
files: &[(String, Vec<u8>)],
) -> Result<(), String> {
let archive = files_archive(files)?;
let opts = bollard::query_parameters::UploadToContainerOptionsBuilder::default()
.path(dir)
.build();
docker
.upload_to_container(container, Some(opts), bollard::body_full(archive.into()))
.await
.map_err(|e| format!("upload {} files to {container}:{dir}: {e}", files.len()))
}
/// Copy a directory back out of a container onto the host.
pub async fn copy_out(
docker: &Docker,