mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Preserve skill descriptions outside model context (#29006)
## Why Skill descriptions are used in model-visible lists: the default available-skills catalog that supports implicit selection, and the on-demand `skills.list` tool response used to discover orchestrator skills. A single overlong description should not consume a disproportionate share of either list. Enforcing the 1024-character limit while loading or migrating skills is the wrong boundary: it rejects otherwise-valid skills and discards metadata that non-model consumers and full skill reads may need. Skill metadata and `SKILL.md` content should remain intact; the cap belongs at model-visible list rendering boundaries. ## What changed - Preserve full `description` and `metadata.short-description` values when loading skills. - Preserve full external-agent command descriptions during `source-command-*` migration instead of skipping commands solely because their descriptions exceed 1024 characters. - Preserve full normalized orchestrator descriptions in the underlying skills catalog. - Cap each description at 1024 Unicode characters when rendering the default available-skills context in `codex-core-skills` and `codex-skills-extension`. - Apply the same cap when serializing descriptions in the model-visible `skills.list` response. - Render truncated descriptions as 1021 original characters plus `...`. - Leave explicit `$skill` injection, `skills.read`, underlying metadata, and on-disk `SKILL.md` files unchanged and full-fidelity. ## Implicit skill selection Codex injects a bounded catalog containing each implicitly allowed skill's name, description, and source locator, together with instructions to use a skill when the task clearly matches its description. The model makes that semantic choice; after selecting a skill, it reads the full `SKILL.md` from its filesystem or provider resource. Explicit `$skill` mentions remain a separate path that injects the full skill instructions. For orchestrator skills, `skills.list` provides bounded discovery metadata before `skills.read` returns the full selected resource. ## Test plan - `just test -p codex-core-skills` - `just test -p codex-skills-extension` - `just test -p codex-external-agent-migration` The focused regressions verify that overlong metadata is preserved at load and migration boundaries while default available-skills rendering and `skills.list` output produce the 1021-character prefix plus `...`.
This commit is contained in:
@@ -28,7 +28,6 @@ const MAX_RESOURCE_PAGES: usize = 10;
|
||||
const MAX_ORCHESTRATOR_SKILLS: usize = 100;
|
||||
const MAX_SKILL_NAME_CHARS: usize = 64;
|
||||
const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128;
|
||||
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
|
||||
const MAX_SKILL_PACKAGE_URI_CHARS: usize = 1_024;
|
||||
const MAX_SKILL_RESOURCE_URI_CHARS: usize = 2_048;
|
||||
const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024;
|
||||
@@ -308,12 +307,17 @@ fn normalized_label(value: &str, max_chars: usize) -> Option<String> {
|
||||
}
|
||||
|
||||
fn normalized_description(value: &str) -> Option<String> {
|
||||
normalized_single_line(value, MAX_SKILL_DESCRIPTION_CHARS).map(|value| {
|
||||
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if value.chars().any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
})
|
||||
.replace('>', ">"),
|
||||
)
|
||||
}
|
||||
|
||||
fn normalized_single_line(value: &str, max_chars: usize) -> Option<String> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
|
||||
use crate::catalog::SkillCatalog;
|
||||
@@ -7,6 +9,8 @@ use crate::fragments::AvailableSkillsInstructions;
|
||||
|
||||
const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000;
|
||||
const MAX_MAIN_PROMPT_BYTES: usize = 8_000;
|
||||
const MAX_CATALOG_SKILL_DESCRIPTION_CHARS: usize = 1_024;
|
||||
const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "...";
|
||||
pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256;
|
||||
pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024;
|
||||
|
||||
@@ -31,7 +35,8 @@ pub(crate) fn available_skills_fragment(
|
||||
.short_description
|
||||
.as_deref()
|
||||
.unwrap_or(entry.description.as_str());
|
||||
let line = render_skill_line(entry, description);
|
||||
let description = truncate_catalog_skill_description(description);
|
||||
let line = render_skill_line(entry, description.as_ref());
|
||||
let next_bytes = total_bytes.saturating_add(line.len());
|
||||
if next_bytes > MAX_AVAILABLE_SKILLS_BYTES {
|
||||
omitted = omitted.saturating_add(1);
|
||||
@@ -54,6 +59,26 @@ pub(crate) fn available_skills_fragment(
|
||||
Some(AvailableSkillsInstructions::from_skill_lines(skill_lines))
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_catalog_skill_description(description: &str) -> Cow<'_, str> {
|
||||
if description
|
||||
.char_indices()
|
||||
.nth(MAX_CATALOG_SKILL_DESCRIPTION_CHARS)
|
||||
.is_none()
|
||||
{
|
||||
return Cow::Borrowed(description);
|
||||
}
|
||||
|
||||
let prefix_chars = MAX_CATALOG_SKILL_DESCRIPTION_CHARS
|
||||
.saturating_sub(TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count());
|
||||
let prefix_end = description
|
||||
.char_indices()
|
||||
.nth(prefix_chars)
|
||||
.map_or(description.len(), |(index, _)| index);
|
||||
let mut truncated = description[..prefix_end].to_string();
|
||||
truncated.push_str(TRUNCATED_SKILL_DESCRIPTION_SUFFIX);
|
||||
Cow::Owned(truncated)
|
||||
}
|
||||
|
||||
fn render_skill_line(entry: &SkillCatalogEntry, description: &str) -> String {
|
||||
let locator_kind = match &entry.authority.kind {
|
||||
SkillSourceKind::Host => "file",
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::catalog::SkillCatalogEntry;
|
||||
use crate::render::truncate_catalog_skill_description;
|
||||
use crate::render::truncate_utf8_to_bytes;
|
||||
|
||||
use super::MAX_HANDLE_BYTES;
|
||||
@@ -95,7 +96,7 @@ fn listed_skill(entry: SkillCatalogEntry) -> Option<ListedSkill> {
|
||||
authority,
|
||||
package: entry.id.0,
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
description: truncate_catalog_skill_description(&entry.description).into_owned(),
|
||||
main_resource: entry.main_prompt.as_str().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ use codex_core_skills::SKILLS_INTRO_WITH_ABSOLUTE_PATHS;
|
||||
use codex_core_skills::SkillLoadOutcome;
|
||||
use codex_core_skills::SkillMetadata;
|
||||
use codex_core_skills::injection::InjectedHostSkillPrompts;
|
||||
use codex_extension_api::ConversationHistory;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionEventSink;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::NoopTurnItemEmitter;
|
||||
use codex_extension_api::ThreadStartInput;
|
||||
use codex_extension_api::ToolCall;
|
||||
use codex_extension_api::ToolPayload;
|
||||
use codex_extension_api::TurnInputContext;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
@@ -23,6 +27,7 @@ use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::protocol::TruncationPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_skills_extension::SkillProviders;
|
||||
use codex_skills_extension::SkillsExtensionConfig;
|
||||
@@ -255,6 +260,128 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_context_truncates_catalog_descriptions() -> TestResult {
|
||||
let description = "x".repeat(1_025);
|
||||
let mut entry = test_entry(
|
||||
SkillSourceKind::Orchestrator,
|
||||
"codex_apps",
|
||||
"orchestrator/long-description",
|
||||
"skill://orchestrator/long-description/SKILL.md",
|
||||
);
|
||||
entry.description = description.clone();
|
||||
let providers =
|
||||
SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider {
|
||||
catalog: SkillCatalog {
|
||||
entries: vec![entry],
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
read_requests: Arc::new(Mutex::new(Vec::new())),
|
||||
list_calls: None,
|
||||
fail_first_list: false,
|
||||
}));
|
||||
let mut builder = ExtensionRegistryBuilder::new();
|
||||
install_with_providers(&mut builder, providers, skills_extension_config);
|
||||
let registry = builder.build();
|
||||
let session_store = ExtensionData::new("session");
|
||||
let thread_store = ExtensionData::new("thread");
|
||||
let session_source = SessionSource::Cli;
|
||||
let config = default_config();
|
||||
registry.thread_lifecycle_contributors()[0]
|
||||
.on_thread_start(ThreadStartInput {
|
||||
config: &config,
|
||||
session_source: &session_source,
|
||||
persistent_thread_state_available: true,
|
||||
environments: &[],
|
||||
session_store: &session_store,
|
||||
thread_store: &thread_store,
|
||||
})
|
||||
.await;
|
||||
|
||||
let fragments = registry.context_contributors()[0]
|
||||
.contribute_thread_context(&session_store, &thread_store)
|
||||
.await;
|
||||
assert_eq!(1, fragments.len());
|
||||
let rendered = fragments[0].text();
|
||||
assert!(rendered.contains(&("x".repeat(1_021) + "...")));
|
||||
assert!(!rendered.contains(&"x".repeat(1_024)));
|
||||
assert!(!rendered.contains(&description));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResult {
|
||||
let description = "x".repeat(1_025);
|
||||
let mut entry = test_entry(
|
||||
SkillSourceKind::Orchestrator,
|
||||
"codex_apps",
|
||||
"orchestrator/long-description",
|
||||
"skill://orchestrator/long-description/SKILL.md",
|
||||
);
|
||||
entry.description = description.clone();
|
||||
let providers =
|
||||
SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider {
|
||||
catalog: SkillCatalog {
|
||||
entries: vec![entry],
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
read_requests: Arc::new(Mutex::new(Vec::new())),
|
||||
list_calls: None,
|
||||
fail_first_list: false,
|
||||
}));
|
||||
let mut builder = ExtensionRegistryBuilder::new();
|
||||
install_with_providers(&mut builder, providers, skills_extension_config);
|
||||
let registry = builder.build();
|
||||
let session_store = ExtensionData::new("session");
|
||||
let thread_store = ExtensionData::new("thread");
|
||||
let session_source = SessionSource::Cli;
|
||||
let config = default_config();
|
||||
registry.thread_lifecycle_contributors()[0]
|
||||
.on_thread_start(ThreadStartInput {
|
||||
config: &config,
|
||||
session_source: &session_source,
|
||||
persistent_thread_state_available: true,
|
||||
environments: &[],
|
||||
session_store: &session_store,
|
||||
thread_store: &thread_store,
|
||||
})
|
||||
.await;
|
||||
|
||||
let tools = registry.tool_contributors()[0].tools(&session_store, &thread_store);
|
||||
let list_tool = tools
|
||||
.iter()
|
||||
.find(|tool| tool.tool_name().name == "list")
|
||||
.ok_or("skills.list tool should be registered")?;
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: serde_json::json!({"authority": {"kind": "orchestrator"}}).to_string(),
|
||||
};
|
||||
let output = list_tool
|
||||
.handle(ToolCall {
|
||||
turn_id: "turn-1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: list_tool.tool_name(),
|
||||
model: "gpt-test".to_string(),
|
||||
truncation_policy: TruncationPolicy::Bytes(1_024),
|
||||
conversation_history: ConversationHistory::default(),
|
||||
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
||||
environments: Vec::new(),
|
||||
payload: payload.clone(),
|
||||
})
|
||||
.await?;
|
||||
let response = output
|
||||
.post_tool_use_response("call-1", &payload)
|
||||
.ok_or("skills.list should expose structured output")?;
|
||||
let rendered_description = response["skills"][0]["description"]
|
||||
.as_str()
|
||||
.ok_or("skills.list response should include a description")?;
|
||||
|
||||
assert_eq!(rendered_description, "x".repeat(1_021) + "...");
|
||||
assert_ne!(rendered_description, description);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrator_catalog_snapshot_caches_failure() -> TestResult {
|
||||
let list_calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
Reference in New Issue
Block a user