Compare commits

...

4 Commits

Author SHA1 Message Date
Matthew Zeng
deae88d371 Propagate skill warning to thread-start clients 2026-04-16 22:13:23 -07:00
Matthew Zeng
64cd78e0e7 Resolve merge conflicts after pulling main 2026-04-16 18:49:04 -07:00
Matthew Zeng
82e5ae9611 Warn on trimmed skills at thread start 2026-04-16 16:35:04 -07:00
Matthew Zeng
47f5026d98 Add skill metadata budget handling 2026-04-16 14:52:38 -07:00
14 changed files with 607 additions and 22 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2070,6 +2070,7 @@ dependencies = [
"codex-protocol",
"codex-skills",
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"codex-utils-plugins",
"dirs",
"dunce",

View File

@@ -937,6 +937,8 @@ Event notifications are the server-initiated event stream for thread lifecycles,
Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`.
Recoverable startup warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization and immediately after a thread starts for session setup warnings such as skill metadata being trimmed to fit its budget.
### Notification opt-out
Clients can suppress specific notifications per connection by sending exact method names in `initialize.params.capabilities.optOutNotificationMethods`.

View File

@@ -46,6 +46,7 @@ use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::CommandExecResizeParams;
use codex_app_server_protocol::CommandExecTerminateParams;
use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
@@ -2557,7 +2558,7 @@ impl CodexMessageProcessor {
thread_id,
thread,
session_configured,
..
thread_start_warnings,
} = new_conv;
if let Err(error) = Self::set_app_server_client_info(
thread.as_ref(),
@@ -2650,6 +2651,7 @@ impl CodexMessageProcessor {
);
}
let response_connection_id = request_id.connection_id;
listener_task_context
.outgoing
.send_response(request_id, response)
@@ -2668,6 +2670,13 @@ impl CodexMessageProcessor {
otel.name = "app_server.thread_start.notify_started",
))
.await;
send_thread_start_warnings_to_connection(
&listener_task_context.outgoing,
response_connection_id,
thread_start_warnings,
)
.await;
}
Err(err) => {
let error = JSONRPCErrorError {
@@ -4110,6 +4119,7 @@ impl CodexMessageProcessor {
thread_id,
thread: codex_thread,
session_configured,
..
}) => {
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
let Some(rollout_path) = rollout_path else {
@@ -4678,7 +4688,7 @@ impl CodexMessageProcessor {
thread_id,
thread: forked_thread,
session_configured,
..
thread_start_warnings,
} = match self
.thread_manager
.fork_thread(
@@ -4873,6 +4883,12 @@ impl CodexMessageProcessor {
self.outgoing
.send_server_notification(ServerNotification::ThreadStarted(notif))
.await;
send_thread_start_warnings_to_connection(
&self.outgoing,
connection_id,
thread_start_warnings,
)
.await;
}
async fn get_thread_summary(
@@ -7422,7 +7438,7 @@ impl CodexMessageProcessor {
thread_id,
thread: review_thread,
session_configured,
..
thread_start_warnings,
} = self
.thread_manager
.fork_thread(
@@ -7470,6 +7486,12 @@ impl CodexMessageProcessor {
self.outgoing
.send_server_notification(ServerNotification::ThreadStarted(notif))
.await;
send_thread_start_warnings_to_connection(
&self.outgoing,
request_id.connection_id,
thread_start_warnings,
)
.await;
}
Err(err) => {
tracing::warn!(
@@ -8360,6 +8382,31 @@ impl CodexMessageProcessor {
}
}
async fn send_thread_start_warnings_to_connection(
outgoing: &Arc<OutgoingMessageSender>,
connection_id: ConnectionId,
thread_start_warnings: Vec<String>,
) {
let warning_connection_ids = [connection_id];
for warning in thread_start_warnings {
outgoing
.send_server_notification_to_connections(
&warning_connection_ids,
ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: warning,
details: None,
path: None,
range: None,
}),
)
.instrument(tracing::info_span!(
"app_server.thread_start.notify_config_warning",
otel.name = "app_server.thread_start.notify_config_warning",
))
.await;
}
}
fn normalize_thread_list_cwd_filter(
cwd: Option<String>,
) -> Result<Option<PathBuf>, JSONRPCErrorError> {

View File

@@ -188,6 +188,72 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_fork_emits_skill_metadata_budget_warning() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
insert_model_context_window(codex_home.path(), 100)?;
for i in 0..120 {
write_skill(codex_home.path(), &format!("demo-{i:03}"))?;
}
let conversation_id = create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id,
..Default::default()
})
.await?;
let fork_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
)
.await??;
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(fork_resp)?;
let started = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/started"),
)
.await??;
let started: ThreadStartedNotification =
serde_json::from_value(started.params.expect("params must be present"))?;
assert_eq!(started.thread.id, thread.id);
let warning = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("configWarning"),
)
.await??;
let parsed: ServerNotification = warning.try_into()?;
let ServerNotification::ConfigWarning(notification) = parsed else {
panic!("expected configWarning notification");
};
assert!(
notification
.summary
.contains("Skills list trimmed to fit the metadata budget"),
"unexpected warning: {}",
notification.summary
);
assert!(notification.summary.contains("2 tokens"));
Ok(())
}
#[tokio::test]
async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -614,6 +680,33 @@ stream_max_retries = 0
)
}
fn insert_model_context_window(
codex_home: &Path,
model_context_window: i64,
) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
let mut config = std::fs::read_to_string(&config_toml)?;
let provider_table = "\n[model_providers.mock_provider]";
let Some(provider_table_index) = config.find(provider_table) else {
return Err(std::io::Error::other(
"mock provider table missing from test config",
));
};
config.insert_str(
provider_table_index,
&format!("\nmodel_context_window = {model_context_window}\n"),
);
std::fs::write(config_toml, config)
}
fn write_skill(codex_home: &Path, name: &str) -> std::io::Result<()> {
let skill_dir = codex_home.join("skills").join(name);
std::fs::create_dir_all(&skill_dir)?;
let description = "desc ".repeat(200);
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
std::fs::write(skill_dir.join("SKILL.md"), content)
}
fn create_config_toml_with_chatgpt_base_url(
codex_home: &Path,
server_uri: &str,

View File

@@ -24,6 +24,7 @@ codex-otel = { workspace = true }
codex-protocol = { workspace = true }
codex-skills = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-output-truncation = { workspace = true }
codex-utils-plugins = { workspace = true }
dirs = { workspace = true }
dunce = { workspace = true }

View File

@@ -22,4 +22,9 @@ pub use model::SkillLoadOutcome;
pub use model::SkillMetadata;
pub use model::SkillPolicy;
pub use model::filter_skill_load_outcome_for_product;
pub use render::RenderedSkillsSection;
pub use render::SkillMetadataBudget;
pub use render::SkillRenderReport;
pub use render::default_skill_metadata_budget;
pub use render::render_skills_section;
pub use render::render_skills_section_with_budget;

View File

@@ -1,22 +1,140 @@
use crate::model::SkillMetadata;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::SkillScope;
use codex_utils_output_truncation::approx_token_count;
pub const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000;
pub const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2;
const DEFAULT_OMITTED_SKILL_SAMPLE_LIMIT: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillMetadataBudget {
Tokens(usize),
Characters(usize),
}
impl SkillMetadataBudget {
fn limit(self) -> usize {
match self {
Self::Tokens(limit) | Self::Characters(limit) => limit,
}
}
fn cost(self, text: &str) -> usize {
match self {
Self::Tokens(_) => approx_token_count(text),
Self::Characters(_) => text.chars().count(),
}
}
fn describe(self) -> String {
match self {
Self::Tokens(limit) => format!("{limit} tokens"),
Self::Characters(limit) => format!("{limit} characters"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OmittedSkillSummary {
pub name: String,
pub scope: SkillScope,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRenderReport {
pub budget: SkillMetadataBudget,
pub total_count: usize,
pub included_count: usize,
pub omitted_count: usize,
pub omitted_samples: Vec<OmittedSkillSummary>,
}
impl SkillRenderReport {
pub fn warning_message(&self) -> Option<String> {
if self.omitted_count == 0 {
return None;
}
let omitted = if self.omitted_samples.is_empty() {
"some skills".to_string()
} else {
let mut names = self
.omitted_samples
.iter()
.map(|skill| format!("{} ({})", skill.name, scope_label(skill.scope)))
.collect::<Vec<_>>();
let hidden_count = self.omitted_count.saturating_sub(names.len());
if hidden_count > 0 {
names.push(format!("{hidden_count} more"));
}
names.join(", ")
};
Some(format!(
"Skills list trimmed to fit the metadata budget: showing {included} of {total} enabled skills ({budget}). Omitted skills include {omitted}. Explicitly mention a skill by name or path if needed, or disable unused skills to reduce the list.",
included = self.included_count,
total = self.total_count,
budget = self.budget.describe(),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedSkillsSection {
pub text: String,
pub report: SkillRenderReport,
}
pub fn default_skill_metadata_budget(context_window: Option<i64>) -> SkillMetadataBudget {
context_window
.and_then(|window| usize::try_from(window).ok())
.filter(|window| *window > 0)
.map(|window| {
SkillMetadataBudget::Tokens(
window
.saturating_mul(SKILL_METADATA_CONTEXT_WINDOW_PERCENT)
.saturating_div(100)
.max(1),
)
})
.unwrap_or(SkillMetadataBudget::Characters(
DEFAULT_SKILL_METADATA_CHAR_BUDGET,
))
}
pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
render_skills_section_inner(skills, None).map(|rendered| rendered.text)
}
pub fn render_skills_section_with_budget(
skills: &[SkillMetadata],
budget: SkillMetadataBudget,
) -> Option<RenderedSkillsSection> {
render_skills_section_inner(skills, Some(budget))
}
fn render_skills_section_inner(
skills: &[SkillMetadata],
budget: Option<SkillMetadataBudget>,
) -> Option<RenderedSkillsSection> {
if skills.is_empty() {
return None;
}
let (skill_lines, report) = render_skill_lines(skills, budget);
let mut lines: Vec<String> = Vec::new();
lines.push("## Skills".to_string());
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
lines.push("### Available skills".to_string());
for skill in skills {
let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
let name = skill.name.as_str();
let description = skill.description.as_str();
lines.push(format!("- {name}: {description} (file: {path_str})"));
if skill_lines.is_empty() {
lines.push("No skill metadata entries fit within the configured budget.".to_string());
} else {
lines.extend(skill_lines);
}
if let Some(message) = report.warning_message() {
lines.push(message);
}
lines.push("### How to use skills".to_string());
@@ -42,7 +160,202 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
);
let body = lines.join("\n");
Some(format!(
"{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"
))
Some(RenderedSkillsSection {
text: format!("{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"),
report,
})
}
fn render_skill_lines(
skills: &[SkillMetadata],
budget: Option<SkillMetadataBudget>,
) -> (Vec<String>, SkillRenderReport) {
let ordered_skills = ordered_skills_for_budget(skills, budget.is_some());
let Some(budget) = budget else {
return (
ordered_skills
.iter()
.map(|skill| render_skill_line(skill))
.collect(),
SkillRenderReport {
budget: SkillMetadataBudget::Characters(usize::MAX),
total_count: skills.len(),
included_count: skills.len(),
omitted_count: 0,
omitted_samples: Vec::new(),
},
);
};
let mut included = Vec::new();
let mut omitted_samples = Vec::new();
let mut used = 0usize;
let mut omitted_count = 0usize;
for skill in ordered_skills {
let line = render_skill_line(skill);
let line_cost = budget.cost(&format!("{line}\n"));
if used.saturating_add(line_cost) <= budget.limit() {
used = used.saturating_add(line_cost);
included.push(line);
continue;
}
omitted_count = omitted_count.saturating_add(1);
if omitted_samples.len() < DEFAULT_OMITTED_SKILL_SAMPLE_LIMIT {
omitted_samples.push(OmittedSkillSummary {
name: skill.name.clone(),
scope: skill.scope,
});
}
}
let report = SkillRenderReport {
budget,
total_count: skills.len(),
included_count: included.len(),
omitted_count,
omitted_samples,
};
(included, report)
}
fn ordered_skills_for_budget(
skills: &[SkillMetadata],
prioritize_for_budget: bool,
) -> Vec<&SkillMetadata> {
let mut ordered = skills.iter().collect::<Vec<_>>();
if prioritize_for_budget {
ordered.sort_by(|a, b| {
prompt_scope_rank(a.scope)
.cmp(&prompt_scope_rank(b.scope))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md))
});
}
ordered
}
fn prompt_scope_rank(scope: SkillScope) -> u8 {
match scope {
SkillScope::System => 0,
SkillScope::Admin => 1,
SkillScope::Repo => 2,
SkillScope::User => 3,
}
}
fn render_skill_line(skill: &SkillMetadata) -> String {
let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
let name = skill.name.as_str();
let description = skill.description.as_str();
format!("- {name}: {description} (file: {path_str})")
}
fn scope_label(scope: SkillScope) -> &'static str {
match scope {
SkillScope::Admin => "admin",
SkillScope::Repo => "repo",
SkillScope::User => "user",
SkillScope::System => "system",
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
fn make_skill(name: &str, scope: SkillScope) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(),
scope,
}
}
#[test]
fn default_budget_uses_two_percent_of_full_context_window() {
assert_eq!(
default_skill_metadata_budget(Some(200_000)),
SkillMetadataBudget::Tokens(4_000)
);
assert_eq!(
default_skill_metadata_budget(Some(99)),
SkillMetadataBudget::Tokens(1)
);
}
#[test]
fn default_budget_falls_back_to_characters_without_context_window() {
assert_eq!(
default_skill_metadata_budget(None),
SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET)
);
assert_eq!(
default_skill_metadata_budget(Some(-1)),
SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET)
);
}
#[test]
fn budgeted_rendering_preserves_prompt_priority() {
let system = make_skill("system-skill", SkillScope::System);
let user = make_skill("user-skill", SkillScope::User);
let repo = make_skill("repo-skill", SkillScope::Repo);
let admin = make_skill("admin-skill", SkillScope::Admin);
let system_cost = SkillMetadataBudget::Characters(usize::MAX)
.cost(&format!("{}\n", render_skill_line(&system)));
let admin_cost = SkillMetadataBudget::Characters(usize::MAX)
.cost(&format!("{}\n", render_skill_line(&admin)));
let budget = SkillMetadataBudget::Characters(system_cost + admin_cost);
let rendered = render_skills_section_with_budget(&[system, user, repo, admin], budget)
.expect("skills should render");
assert_eq!(rendered.report.included_count, 2);
assert_eq!(rendered.report.omitted_count, 2);
assert!(rendered.text.contains("- system-skill:"));
assert!(rendered.text.contains("- admin-skill:"));
assert!(!rendered.text.contains("- repo-skill:"));
assert!(!rendered.text.contains("- user-skill:"));
}
#[test]
fn budgeted_rendering_keeps_scanning_after_oversized_entry() {
let mut oversized = make_skill("oversized-system-skill", SkillScope::System);
oversized.description = "desc ".repeat(100);
let repo = make_skill("repo-skill", SkillScope::Repo);
let repo_cost = SkillMetadataBudget::Characters(usize::MAX)
.cost(&format!("{}\n", render_skill_line(&repo)));
let budget = SkillMetadataBudget::Characters(repo_cost);
let rendered =
render_skills_section_with_budget(&[oversized, repo], budget).expect("skills render");
assert_eq!(rendered.report.included_count, 1);
assert_eq!(rendered.report.omitted_count, 1);
assert!(!rendered.text.contains("- oversized-system-skill:"));
assert!(rendered.text.contains("- repo-skill:"));
}
#[test]
fn unbudgeted_rendering_preserves_input_order() {
let user = make_skill("user-skill", SkillScope::User);
let admin = make_skill("admin-skill", SkillScope::Admin);
let rendered = render_skills_section(&[user, admin]).expect("skills should render");
let user_index = rendered.find("- user-skill:").expect("user skill");
let admin_index = rendered.find("- admin-skill:").expect("admin skill");
assert!(user_index < admin_index);
}
}

View File

@@ -20,12 +20,13 @@ use crate::commit_attribution::commit_message_trailer_instruction;
use crate::compact;
use crate::config::ManagedFeatures;
use crate::connectors;
use crate::default_skill_metadata_budget;
use crate::exec_policy::ExecPolicyManager;
use crate::installation_id::resolve_installation_id;
use crate::parse_turn_item;
use crate::path_utils::normalize_for_native_workdir;
use crate::realtime_conversation::RealtimeConversationManager;
use crate::render_skills_section;
use crate::render_skills_section_with_budget;
use crate::rollout::find_thread_name_by_id;
use crate::session_prefix::format_subagent_notification_message;
use crate::skills_load_input_from_config;
@@ -361,12 +362,12 @@ pub struct Codex {
pub(crate) type SessionLoopTermination = Shared<BoxFuture<'static, ()>>;
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
/// the submission id for the initial `ConfigureSession` request and the
/// unique session id.
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`], the
/// unique session id, and any warnings known at thread start.
pub struct CodexSpawnOk {
pub codex: Codex,
pub thread_id: ThreadId,
pub thread_start_warnings: Vec<String>,
}
pub(crate) struct CodexSpawnArgs {
@@ -544,6 +545,14 @@ impl Codex {
let model_info = models_manager
.get_model_info(model.as_str(), &config.to_models_manager_config())
.await;
let implicit_skills = loaded_skills.allowed_skills_for_implicit_invocation();
let thread_start_warnings = render_skills_section_with_budget(
&implicit_skills,
default_skill_metadata_budget(model_info.context_window),
)
.and_then(|rendered| rendered.report.warning_message())
.into_iter()
.collect();
let base_instructions = config
.base_instructions
.clone()
@@ -661,7 +670,11 @@ impl Codex {
session_loop_termination: session_loop_termination_from_handle(session_loop_handle),
};
Ok(CodexSpawnOk { codex, thread_id })
Ok(CodexSpawnOk {
codex,
thread_id,
thread_start_warnings,
})
}
/// Submit the `op` wrapped in a `Submission` with a unique ID.
@@ -3970,8 +3983,11 @@ impl Session {
.turn_skills
.outcome
.allowed_skills_for_implicit_invocation();
if let Some(skills_section) = render_skills_section(&implicit_skills) {
developer_sections.push(skills_section);
if let Some(rendered_skills) = render_skills_section_with_budget(
&implicit_skills,
default_skill_metadata_budget(turn_context.model_info.context_window),
) {
developer_sections.push(rendered_skills.text);
}
let loaded_plugins = self
.services

View File

@@ -85,6 +85,7 @@ use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::RealtimeVoicesList;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TokenCountEvent;
@@ -4579,6 +4580,52 @@ async fn build_initial_context_omits_default_image_save_location_without_image_h
);
}
#[tokio::test]
async fn build_initial_context_trims_skill_metadata_from_context_window_budget() {
let (session, mut turn_context) = make_session_and_context().await;
let mut outcome = SkillLoadOutcome::default();
outcome.skills = vec![
SkillMetadata {
name: "admin-skill".to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: PathBuf::from("/tmp/admin-skill/SKILL.md").abs(),
scope: SkillScope::Admin,
},
SkillMetadata {
name: "repo-skill".to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: PathBuf::from("/tmp/repo-skill/SKILL.md").abs(),
scope: SkillScope::Repo,
},
];
turn_context.model_info.context_window = Some(100);
turn_context.turn_skills = TurnSkillsContext::new(Arc::new(outcome));
let initial_context = session.build_initial_context(&turn_context).await;
let developer_texts = developer_input_texts(&initial_context);
assert!(
developer_texts
.iter()
.any(|text| text.contains("Skills list trimmed to fit the metadata budget")),
"expected skill budget warning in initial context, got {developer_texts:?}"
);
assert!(
developer_texts
.iter()
.all(|text| !text.contains("- admin-skill:") && !text.contains("- repo-skill:")),
"expected no skill metadata entries to fit the tiny budget, got {developer_texts:?}"
);
}
#[tokio::test]
async fn handle_output_item_done_records_image_save_history_message() {
let (session, turn_context) = make_session_and_context().await;

View File

@@ -99,10 +99,11 @@ pub(crate) use skills::build_skill_injections;
pub(crate) use skills::build_skill_name_counts;
pub(crate) use skills::collect_env_var_dependencies;
pub(crate) use skills::collect_explicit_skill_mentions;
pub(crate) use skills::default_skill_metadata_budget;
pub(crate) use skills::injection;
pub(crate) use skills::manager;
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
pub(crate) use skills::render_skills_section;
pub(crate) use skills::render_skills_section_with_budget;
pub(crate) use skills::resolve_skill_dependencies_for_turn;
pub(crate) use skills::skills_load_input_from_config;
mod skills_watcher;

View File

@@ -21,11 +21,13 @@ pub use codex_core_skills::SkillError;
pub use codex_core_skills::SkillLoadOutcome;
pub use codex_core_skills::SkillMetadata;
pub use codex_core_skills::SkillPolicy;
pub use codex_core_skills::SkillRenderReport;
pub use codex_core_skills::SkillsLoadInput;
pub use codex_core_skills::SkillsManager;
pub use codex_core_skills::build_skill_name_counts;
pub use codex_core_skills::collect_env_var_dependencies;
pub use codex_core_skills::config_rules;
pub use codex_core_skills::default_skill_metadata_budget;
pub use codex_core_skills::detect_implicit_skill_invocation_for_command;
pub use codex_core_skills::filter_skill_load_outcome_for_product;
pub use codex_core_skills::injection;
@@ -38,6 +40,7 @@ pub use codex_core_skills::model;
pub use codex_core_skills::remote;
pub use codex_core_skills::render;
pub use codex_core_skills::render_skills_section;
pub use codex_core_skills::render_skills_section_with_budget;
pub use codex_core_skills::system;
pub(crate) fn skills_load_input_from_config(

View File

@@ -137,6 +137,7 @@ pub struct NewThread {
pub thread_id: ThreadId,
pub thread: Arc<CodexThread>,
pub session_configured: SessionConfiguredEvent,
pub thread_start_warnings: Vec<String>,
}
// TODO(ccunningham): Add an explicit non-interrupting live-turn snapshot once
@@ -926,7 +927,9 @@ impl ThreadManagerState {
Some(_) | None => crate::file_watcher::WatchRegistration::default(),
};
let CodexSpawnOk {
codex, thread_id, ..
codex,
thread_id,
thread_start_warnings,
} = Codex::spawn(CodexSpawnArgs {
config,
auth_manager,
@@ -949,7 +952,7 @@ impl ThreadManagerState {
analytics_events_client: self.analytics_events_client.clone(),
})
.await?;
self.finalize_thread_spawn(codex, thread_id, watch_registration)
self.finalize_thread_spawn(codex, thread_id, watch_registration, thread_start_warnings)
.await
}
@@ -958,6 +961,7 @@ impl ThreadManagerState {
codex: Codex,
thread_id: ThreadId,
watch_registration: crate::file_watcher::WatchRegistration,
thread_start_warnings: Vec<String>,
) -> CodexResult<NewThread> {
let event = codex.next_event().await?;
let session_configured = match event {
@@ -982,6 +986,7 @@ impl ThreadManagerState {
thread_id,
thread,
session_configured,
thread_start_warnings,
})
}

View File

@@ -1,5 +1,7 @@
use super::*;
use crate::codex::make_session_and_context;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::test_config;
use crate::rollout::RolloutRecorder;
use crate::tasks::interrupted_turn_history_marker;
@@ -43,6 +45,14 @@ fn assistant_msg(text: &str) -> ResponseItem {
}
}
fn write_skill(codex_home: &std::path::Path, name: &str) {
let skill_dir = codex_home.join("skills").join(name);
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
let description = "desc ".repeat(200);
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
std::fs::write(skill_dir.join("SKILL.md"), content).expect("write skill");
}
#[test]
fn truncates_before_requested_user_message() {
let items = [
@@ -234,6 +244,46 @@ async fn ignores_session_prefix_messages_when_truncating() {
);
}
#[tokio::test]
async fn start_thread_reports_skill_metadata_budget_warning() {
let temp_dir = tempdir().expect("tempdir");
let codex_home = temp_dir.path().join("codex-home");
let cwd = codex_home.clone();
std::fs::create_dir_all(&codex_home).expect("create codex home");
for i in 0..120 {
write_skill(&codex_home, &format!("demo-{i:03}"));
}
let mut config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.clone())
.harness_overrides(ConfigOverrides {
cwd: Some(cwd),
..Default::default()
})
.build()
.await
.expect("load test config");
config.model_context_window = Some(100);
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::new(
/*exec_server_url*/ None,
)),
);
let new_thread = manager.start_thread(config).await.expect("start thread");
assert_eq!(new_thread.thread_start_warnings.len(), 1);
let warning = &new_thread.thread_start_warnings[0];
assert!(warning.contains("Skills list trimmed to fit the metadata budget"));
assert!(warning.contains("enabled skills"));
let _ = manager
.shutdown_all_threads_bounded(Duration::from_secs(10))
.await;
}
#[tokio::test]
async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
let temp_dir = tempdir().expect("tempdir");

View File

@@ -68,6 +68,7 @@ pub async fn run_codex_tool_session(
thread_id,
thread,
session_configured,
..
} = match thread_manager.start_thread(config).await {
Ok(res) => res,
Err(e) => {