Files
codex/codex-rs/ext/skills/src/render.rs
T
jif a287c5dffd skills: make backend plugin skills invocable without an executor (#27387)
## Why

#27198 made the extension-owned `codex_apps` MCP connection the hosted
plugin runtime, but its `mcp/skill` resources still bypassed the skills
extension. App-server could list and read those resources through
generic MCP APIs, but a thread with no selected environment did not
expose them in the model's skills catalog or load their `SKILL.md`
through `$skill`.

Hosted skills should stay remote while using the same typed catalog,
source authority, deduplication, bounded contextual catalog, and
selected-skill prompt injection as host and executor skills. They should
not be downloaded or exposed as ambient filesystem paths.

## What changed

- Add a session-scoped `McpResourceClient` over the replaceable MCP
connection manager so resource list/read calls follow startup and
refresh replacements.
- Add a `BackendSkillProvider` that pages `codex_apps` resources,
accepts bounded and validated `mcp/skill` entries, and reads a selected
skill's `SKILL.md` through the same MCP connection.
- Register the remote provider in app-server and include it in the
skills catalog even when a thread has no selected capability roots or
executor.
- Contribute hosted skill metadata through the bounded
`AvailableSkillsInstructions` developer-context path, exclude remote
entries from per-turn catalog injection, and classify `<skills>`
messages as contextual developer content so rollback can trim and
rebuild them correctly.

## Testing

- Extend the app-server MCP resource integration test with
`environments: []` to exercise two-page discovery, filter a
non-`mcp/skill` resource, verify the escaped developer catalog entry and
user-role `<skill>` fragment containing the fetched `SKILL.md`, and
preserve generic MCP resource reads.
- Add core event-mapping coverage that classifies `<skills>` developer
messages as contextual history.
2026-06-11 11:28:16 +02:00

66 lines
2.1 KiB
Rust

use codex_core::context::AvailableSkillsInstructions;
use codex_utils_string::take_bytes_at_char_boundary;
use crate::catalog::SkillCatalog;
const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000;
const MAX_MAIN_PROMPT_BYTES: usize = 8_000;
pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256;
pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024;
pub(crate) fn available_skills_fragment(
catalog: &SkillCatalog,
) -> Option<AvailableSkillsInstructions> {
let mut total_bytes = 0usize;
let mut omitted = 0usize;
let mut skill_lines = Vec::new();
for entry in catalog
.entries
.iter()
.filter(|entry| entry.enabled && entry.prompt_visible)
{
let description = entry
.short_description
.as_deref()
.unwrap_or(entry.description.as_str());
let line = render_skill_line(entry.name.as_str(), description, entry.rendered_path());
let next_bytes = total_bytes.saturating_add(line.len());
if next_bytes > MAX_AVAILABLE_SKILLS_BYTES {
omitted = omitted.saturating_add(1);
continue;
}
total_bytes = next_bytes;
skill_lines.push(line);
}
if skill_lines.is_empty() {
return None;
}
if omitted > 0 {
let skill_word = if omitted == 1 { "skill" } else { "skills" };
skill_lines.push(format!(
"- {omitted} additional {skill_word} omitted from this bounded skills list."
));
}
Some(AvailableSkillsInstructions::from_skill_lines(skill_lines))
}
fn render_skill_line(name: &str, description: &str, path: &str) -> String {
if description.is_empty() {
format!("- {name}: (file: {path})")
} else {
format!("- {name}: {description} (file: {path})")
}
}
pub(crate) fn truncate_main_prompt_contents(contents: &str) -> (String, bool) {
truncate_utf8_to_bytes(contents, MAX_MAIN_PROMPT_BYTES)
}
pub(crate) fn truncate_utf8_to_bytes(contents: &str, max_bytes: usize) -> (String, bool) {
let truncated = take_bytes_at_char_boundary(contents, max_bytes);
(truncated.to_string(), truncated.len() < contents.len())
}