Files
codex/codex-rs/ext/skills/src/render.rs
T
jifandGitHub 7c2394808e skills: decouple the skills extension from core (#27413)
## Why

`ext/skills` currently depends on `codex-core` for two host concerns:
reading the concrete `Config` type and borrowing core-owned
model-context fragment types. That coupling prevents the extension from
being assembled independently above core and leaves context that belongs
to the skills feature owned by core.

This stacked PR introduces the host boundary needed for the broader
extension migration while intentionally preserving existing skills
behavior. It is stacked on #27404.

## What changed

- Adds a small public `SkillsExtensionConfig` view and makes skills
installation generic over the host config type.
- Requires the host to map its config into that view; app-server
supplies the current `Config` values.
- Moves the available-skills and selected-skill context fragment
implementations into `ext/skills`, preserving their roles, markers, and
rendered bytes.
- Removes the direct `codex-core` dependency from
`codex-skills-extension`.
- Keeps local discovery, invocation, side effects, and the
`codex-core-skills` compatibility types unchanged for later staged PRs.

## Behavior

This adds no capability and is intended to have no user-visible or
model-visible behavior change. The install API and ownership boundary
change internally; emitted skills context remains byte-for-byte
compatible.

## Validation

- Updates the skills extension integration coverage to use a host-owned
test config.
- Asserts the complete rendered catalog and selected-skill fragments,
including their roles and markers.
- `just bazel-lock-check`
- Rust tests and Clippy were not run locally per request; CI will run
them.
2026-06-11 14:03:53 +02:00

76 lines
2.5 KiB
Rust

use codex_utils_string::take_bytes_at_char_boundary;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillSourceKind;
use crate::fragments::AvailableSkillsInstructions;
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, description);
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(entry: &SkillCatalogEntry, description: &str) -> String {
let locator_kind = match &entry.authority.kind {
SkillSourceKind::Host => "file",
SkillSourceKind::Executor => "environment resource",
SkillSourceKind::Orchestrator => "orchestrator resource",
SkillSourceKind::Custom(_) => "custom resource",
};
let name = entry.name.as_str();
let path = entry.rendered_path();
if description.is_empty() {
format!("- {name}: ({locator_kind}: {path})")
} else {
format!("- {name}: {description} ({locator_kind}: {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())
}