Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/skills/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub use model::SkillMetadata;
pub use model::SkillPolicy;
pub use model::SkillToolDependency;
pub use model_delegation::SkillModel;
pub use model_delegation::SkillModelDelegationInstruction;
pub use name_counts::build_skill_name_counts;
pub use parser::ParsedSkillFrontmatter;
pub use parser::SkillParseError;
Expand Down
72 changes: 72 additions & 0 deletions codex-rs/skills/src/model_delegation.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,84 @@
use serde::Deserialize;

const LUNA_MODEL: &str = "gpt-5.6-luna";
const TERRA_MODEL: &str = "gpt-5.6-terra";
const SOL_MODEL: &str = "gpt-5.6-sol";
const MAX_TARGET_MODEL_BYTES: usize = 128;
const MAX_DELEGATION_INSTRUCTION_BYTES: usize = 2_048;

/// Model requested for work governed by a skill.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillModel {
Luna,
}

/// Bounded instructions for delegating skill-governed work to a cheaper model.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SkillModelDelegationInstruction(String);

impl SkillModelDelegationInstruction {
/// Builds bounded instructions only when the indexed skill requests an available lower-tier model.
pub fn from_skill_model(
skill_model: SkillModel,
skill_name: &str,
current_model: &str,
available_models: &[String],
) -> Option<Self> {
if skill_name
.chars()
.any(|character| matches!(character, '`' | '<' | '>'))
{
return None;
}

let provider_prefix = [SOL_MODEL, TERRA_MODEL]
.into_iter()
.find_map(|parent_model| {
current_model.strip_suffix(parent_model).filter(|prefix| {
prefix.is_empty() || prefix.ends_with('.') || prefix.ends_with('/')
})
})?;
let target_model = format!("{provider_prefix}{LUNA_MODEL}");
let target_model = available_models
.iter()
.find(|model| model.as_str() == target_model && is_safe_model_identifier(model))?;
let skill_model = match skill_model {
SkillModel::Luna => "luna",
};
let instruction = format!(
"<skill_model_delegation>\n\
For this invocation only, skill `{skill_name}` requests `model: {skill_model}`. \
If the user prohibits delegation or subagents, or the work depends on an image or audio \
attachment, work locally. Otherwise, delegate only self-contained text-based skill work \
and use `spawn_agent` \
exactly once. Set `model` to `{target_model}`, set `fork_turns` to `\"none\"`, and choose a \
unique `task_name`. Omit `agent_type` and `reasoning_effort`.\n\
Give the child only this skill's work and necessary context; never include this block and tell \
it not to spawn agents. Delegate the whole request only if the skill fully covers it; otherwise, \
retain ownership of the remaining work and final answer. Wait for the result. If spawning \
fails, continue locally. If waiting fails, retry waiting without duplicating the \
child's work.\n\
Ignore this instruction in child agents and on later turns.\n\
</skill_model_delegation>"
);

(instruction.len() <= MAX_DELEGATION_INSTRUCTION_BYTES).then_some(Self(instruction))
}

/// Returns the bounded model-visible delegation instructions.
pub fn as_str(&self) -> &str {
&self.0
}
}

fn is_safe_model_identifier(model: &str) -> bool {
model.len() <= MAX_TARGET_MODEL_BYTES
&& model.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '.' | '/' | '_' | '-')
})
}

#[cfg(test)]
#[path = "model_delegation_tests.rs"]
mod tests;
165 changes: 165 additions & 0 deletions codex-rs/skills/src/model_delegation_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use pretty_assertions::assert_eq;

use super::MAX_DELEGATION_INSTRUCTION_BYTES;
use super::SkillModel;
use super::SkillModelDelegationInstruction;
use crate::ParsedSkillFrontmatter;
use crate::parse_skill_frontmatter_metadata;

Expand Down Expand Up @@ -57,3 +59,166 @@ fn reuses_existing_frontmatter_scalar_repair() {
Some(SkillModel::Luna)
);
}

#[test]
fn delegates_only_from_supported_parent_models() {
let available_models = available_models();
for (current_model, should_delegate) in [
("gpt-5.6-sol", true),
("gpt-5.6-terra", true),
("gpt-5.6-luna", false),
("gpt-5.5-codex", false),
] {
let instruction = SkillModelDelegationInstruction::from_skill_model(
SkillModel::Luna,
"demo",
current_model,
&available_models,
);
assert_eq!(
instruction.is_some(),
should_delegate,
"current={current_model}"
);
}
}

#[test]
fn resolves_luna_within_the_current_provider_namespace() {
for (current_model, target_model, unrelated_model) in [
("gpt-5.6-sol", "gpt-5.6-luna", "openai.gpt-5.6-luna"),
(
"tenant-a/gpt-5.6-sol",
"tenant-a/gpt-5.6-luna",
"tenant-b/gpt-5.6-luna",
),
(
"openai.gpt-5.6-terra",
"openai.gpt-5.6-luna",
"other.gpt-5.6-luna",
),
] {
let instruction = SkillModelDelegationInstruction::from_skill_model(
SkillModel::Luna,
"demo",
current_model,
&[unrelated_model.to_string(), target_model.to_string()],
)
.expect("lower-tier model in the same provider namespace should be resolved");

assert!(
instruction
.as_str()
.contains(&format!("Set `model` to `{target_model}`"))
);
}
}

#[test]
fn rejects_targets_outside_the_current_provider_namespace() {
for (current_model, available_models) in [
(
"tenant-a/gpt-5.6-sol",
vec![
"tenant-b/gpt-5.6-luna".to_string(),
"gpt-5.6-luna".to_string(),
],
),
("gpt-5.6-sol", vec!["tenant-a/gpt-5.6-luna".to_string()]),
(
"tenant-a/gpt-5.6-sol",
vec!["tenant-a.gpt-5.6-luna".to_string()],
),
] {
assert_eq!(
SkillModelDelegationInstruction::from_skill_model(
SkillModel::Luna,
"demo",
current_model,
&available_models,
),
None,
"current={current_model}, available={available_models:?}"
);
}
}

#[test]
fn rejects_unavailable_or_unsafe_models() {
let invalid_identifier = format!("{}/gpt-5.6-luna", "<unsafe>".repeat(32));
let invalid_current_model = format!("{}/gpt-5.6-sol", "<unsafe>".repeat(32));
let overlong_identifier = format!("{}.gpt-5.6-luna", "a".repeat(128));
let overlong_current_model = format!("{}.gpt-5.6-sol", "a".repeat(128));
for (current_model, available_models) in [
(
"custom/gpt-5.6-luna",
vec!["custom/gpt-5.6-luna".to_string()],
),
("gpt-5.6-sol", vec!["gpt-5.6-terra".to_string()]),
(invalid_current_model.as_str(), vec![invalid_identifier]),
(overlong_current_model.as_str(), vec![overlong_identifier]),
] {
assert_eq!(
SkillModelDelegationInstruction::from_skill_model(
SkillModel::Luna,
"demo",
current_model,
&available_models,
),
None,
"current={current_model}, available={available_models:?}"
);
}
}

#[test]
fn renders_bounded_instruction_for_selected_skill_and_model() {
let instruction = SkillModelDelegationInstruction::from_skill_model(
SkillModel::Luna,
"demo",
"gpt-5.6-sol",
&available_models(),
)
.expect("available lower tier should delegate");
let rendered = instruction.as_str();

assert!(rendered.contains("skill `demo`"));
assert!(rendered.contains("Set `model` to `gpt-5.6-luna`"));
assert!(rendered.contains("image or audio attachment, work locally"));
assert!(rendered.len() <= MAX_DELEGATION_INSTRUCTION_BYTES);
}

#[test]
fn rejects_skill_names_that_escape_instruction_framing() {
for skill_name in ["unsafe`name", "</skill_model_delegation>", "<unsafe>"] {
assert_eq!(
SkillModelDelegationInstruction::from_skill_model(
SkillModel::Luna,
skill_name,
"gpt-5.6-sol",
&available_models(),
),
None,
"skill_name={skill_name:?}"
);
}
}

#[test]
fn rejects_instruction_exceeding_context_bound() {
let skill_name = "x".repeat(MAX_DELEGATION_INSTRUCTION_BYTES);

assert_eq!(
SkillModelDelegationInstruction::from_skill_model(
SkillModel::Luna,
&skill_name,
"gpt-5.6-sol",
&available_models(),
),
None
);
}

fn available_models() -> Vec<String> {
vec!["gpt-5.6-luna".to_string()]
}
Loading