feat(skills): a files arm — progressive disclosure through Read, not a deferred tool
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:
co-authored by
Claude Opus 5
parent
8d6310f126
commit
00160739de
@@ -349,17 +349,20 @@ pub async fn on_launch(
|
||||
// Only when this mission got its OWN container — the shared runtime is
|
||||
// not ours to reconfigure, and `mission_gateway` being Some is exactly
|
||||
// the signal that `ensure_container` ran.
|
||||
let door = if mission_gateway.is_some() {
|
||||
install_skills_door(
|
||||
pool,
|
||||
user_id,
|
||||
mission_id,
|
||||
&crate::mission_runtime::container_name(mission_id),
|
||||
p,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
false
|
||||
// What a retrieval arm retrieves FROM is installed here, per arm: the
|
||||
// MCP door for `index`, the skill files for `files`. `inline` installs
|
||||
// nothing and `installed` is irrelevant to it.
|
||||
let requested = crate::skill_delivery::requested_for(&mission.config);
|
||||
let container = crate::mission_runtime::container_name(mission_id);
|
||||
let installed = match requested {
|
||||
_ if mission_gateway.is_none() => false,
|
||||
crate::skill_delivery::Mode::Index => {
|
||||
install_skills_door(pool, user_id, mission_id, &container, p).await
|
||||
}
|
||||
crate::skill_delivery::Mode::Files => {
|
||||
install_skill_files(pool, workspace_id, mission_id, &container).await
|
||||
}
|
||||
crate::skill_delivery::Mode::Inline => false,
|
||||
};
|
||||
// Decided here and recorded, not re-derived per turn: this is the only
|
||||
// point that knows whether the door actually installed, and an arm that
|
||||
@@ -367,10 +370,7 @@ pub async fn on_launch(
|
||||
record_skill_delivery(
|
||||
pool,
|
||||
mission_id,
|
||||
crate::skill_delivery::resolve(
|
||||
crate::skill_delivery::requested_for(&mission.config),
|
||||
door,
|
||||
),
|
||||
crate::skill_delivery::resolve(requested, installed),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -991,6 +991,80 @@ async fn install_skills_door(
|
||||
true
|
||||
}
|
||||
|
||||
/// Write every skill the workspace can see into the mission container as a
|
||||
/// file, for the `files` arm.
|
||||
///
|
||||
/// Every visible skill and not only the bound ones, because bindings are
|
||||
/// resolved per AGENT at turn time (`effective_for_agent`) and this runs once
|
||||
/// per mission before any turn — the same reason the MCP door serves the whole
|
||||
/// catalogue rather than a per-mission subset. A few KB each; the whole
|
||||
/// catalogue is smaller than one phase's evidence.
|
||||
///
|
||||
/// Returns whether the files are in place. `false` means the mission falls
|
||||
/// back to `inline` (see `skill_delivery::resolve`) — an entry that points at
|
||||
/// a file which is not there reads exactly like an agent ignoring its skills,
|
||||
/// which is the failure this arm exists to stop misdiagnosing.
|
||||
async fn install_skill_files(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
mission_id: Uuid,
|
||||
container: &str,
|
||||
) -> bool {
|
||||
let skills = match cm_db::repo::skills_catalog::list_visible(pool, workspace_id.as_uuid()).await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"mission_orchestrator: could not list skills for the files arm ({e}) — \
|
||||
mission {mission_id} delivers skills inline"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let docker = match crate::container_exec::connect() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("mission_orchestrator: cannot reach docker for the skill files: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let dir = crate::skill_delivery::SKILLS_DIR;
|
||||
// `upload_to_container` will not create the directory.
|
||||
let argv = vec!["sh".to_string(), "-lc".to_string(), format!("mkdir -p {dir}")];
|
||||
match crate::container_exec::exec_as_root(
|
||||
&docker,
|
||||
container,
|
||||
None,
|
||||
&argv,
|
||||
crate::container_tool_hooks::INSTALL_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(out) if out.exit_code == Some(0) => {}
|
||||
other => {
|
||||
eprintln!(
|
||||
"mission_orchestrator: could not create {dir} in {container} ({other:?}) — \
|
||||
mission {mission_id} delivers skills inline"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let files: Vec<(String, Vec<u8>)> = skills
|
||||
.iter()
|
||||
.map(|sk| (format!("{}.md", sk.name), sk.body.clone().into_bytes()))
|
||||
.collect();
|
||||
let n = files.len();
|
||||
if let Err(e) = crate::mission_fs::put_files(&docker, container, dir, &files).await {
|
||||
eprintln!(
|
||||
"mission_orchestrator: could not write the skill files ({e}) — mission \
|
||||
{mission_id} delivers skills inline"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
eprintln!("mission_orchestrator: {n} skill file(s) installed for mission {mission_id} under {dir}");
|
||||
true
|
||||
}
|
||||
|
||||
/// Record which arm this mission runs, so every turn composes the same one and
|
||||
/// the score can be attributed to it afterwards.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user