mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
3 Commits
dh--git-in
...
ambrosino/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e02fd94a8a | ||
|
|
3f4a6f91c7 | ||
|
|
b1adbc01bd |
@@ -121,6 +121,10 @@ client_request_definitions! {
|
||||
params: v2::ThreadCompactParams,
|
||||
response: v2::ThreadCompactResponse,
|
||||
},
|
||||
SkillsList => "skills/list" {
|
||||
params: v2::SkillsListParams,
|
||||
response: v2::SkillsListResponse,
|
||||
},
|
||||
TurnStart => "turn/start" {
|
||||
params: v2::TurnStartParams,
|
||||
response: v2::TurnStartResponse,
|
||||
|
||||
@@ -21,6 +21,9 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
|
||||
use codex_protocol::protocol::SessionSource as CoreSessionSource;
|
||||
use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo;
|
||||
use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata;
|
||||
use codex_protocol::protocol::SkillScope as CoreSkillScope;
|
||||
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo;
|
||||
use codex_protocol::user_input::UserInput as CoreUserInput;
|
||||
@@ -966,6 +969,86 @@ pub struct ThreadCompactParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadCompactResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SkillsListParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwds: Option<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SkillsListResponse {
|
||||
pub data: Vec<SkillsListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum SkillScope {
|
||||
User,
|
||||
Repo,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SkillMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub path: PathBuf,
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SkillErrorInfo {
|
||||
pub path: PathBuf,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SkillsListEntry {
|
||||
pub cwd: PathBuf,
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
pub errors: Vec<SkillErrorInfo>,
|
||||
}
|
||||
|
||||
impl From<CoreSkillMetadata> for SkillMetadata {
|
||||
fn from(value: CoreSkillMetadata) -> Self {
|
||||
Self {
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
path: value.path,
|
||||
scope: value.scope.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreSkillScope> for SkillScope {
|
||||
fn from(value: CoreSkillScope) -> Self {
|
||||
match value {
|
||||
CoreSkillScope::User => Self::User,
|
||||
CoreSkillScope::Repo => Self::Repo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreSkillErrorInfo> for SkillErrorInfo {
|
||||
fn from(value: CoreSkillErrorInfo) -> Self {
|
||||
Self {
|
||||
path: value.path,
|
||||
message: value.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -65,6 +65,7 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
|
||||
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
|
||||
- `model/list` — list available models (with reasoning effort options).
|
||||
- `skills/list` — list skills for one or more `cwd` values; each skill includes a `scope` of `user` or `repo` (not thread-scoped).
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `mcpServers/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
|
||||
- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id.
|
||||
|
||||
@@ -81,6 +81,8 @@ use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::SessionConfiguredNotification;
|
||||
use codex_app_server_protocol::SetDefaultModelParams;
|
||||
use codex_app_server_protocol::SetDefaultModelResponse;
|
||||
use codex_app_server_protocol::SkillsListParams;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadArchiveParams;
|
||||
use codex_app_server_protocol::ThreadArchiveResponse;
|
||||
@@ -374,6 +376,9 @@ impl CodexMessageProcessor {
|
||||
self.send_unimplemented_error(request_id, "thread/compact")
|
||||
.await;
|
||||
}
|
||||
ClientRequest::SkillsList { request_id, params } => {
|
||||
self.skills_list(request_id, params).await;
|
||||
}
|
||||
ClientRequest::TurnStart { request_id, params } => {
|
||||
self.turn_start(request_id, params).await;
|
||||
}
|
||||
@@ -2630,6 +2635,40 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn skills_list(&self, request_id: RequestId, params: SkillsListParams) {
|
||||
let SkillsListParams { cwds } = params;
|
||||
let cwds = match cwds {
|
||||
Some(cwds) if !cwds.is_empty() => cwds,
|
||||
_ => vec![self.config.cwd.clone()],
|
||||
};
|
||||
let data = if self.config.features.enabled(Feature::Skills) {
|
||||
let skills_manager = self.conversation_manager.skills_manager();
|
||||
cwds.into_iter()
|
||||
.map(|cwd| {
|
||||
let outcome = skills_manager.skills_for_cwd(&cwd);
|
||||
let errors = errors_to_info(&outcome.errors);
|
||||
let skills = skills_to_info(&outcome.skills);
|
||||
codex_app_server_protocol::SkillsListEntry {
|
||||
cwd,
|
||||
skills,
|
||||
errors,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
cwds.into_iter()
|
||||
.map(|cwd| codex_app_server_protocol::SkillsListEntry {
|
||||
cwd,
|
||||
skills: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
self.outgoing
|
||||
.send_response(request_id, SkillsListResponse { data })
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn interrupt_conversation(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
@@ -3275,6 +3314,32 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fn skills_to_info(
|
||||
skills: &[codex_core::skills::SkillMetadata],
|
||||
) -> Vec<codex_app_server_protocol::SkillMetadata> {
|
||||
skills
|
||||
.iter()
|
||||
.map(|skill| codex_app_server_protocol::SkillMetadata {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope.into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn errors_to_info(
|
||||
errors: &[codex_core::skills::SkillError],
|
||||
) -> Vec<codex_app_server_protocol::SkillErrorInfo> {
|
||||
errors
|
||||
.iter()
|
||||
.map(|err| codex_app_server_protocol::SkillErrorInfo {
|
||||
path: err.path.clone(),
|
||||
message: err.message.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn derive_config_from_params(
|
||||
overrides: ConfigOverrides,
|
||||
cli_overrides: Option<std::collections::HashMap<String, serde_json::Value>>,
|
||||
|
||||
@@ -1111,6 +1111,18 @@ impl AuthManager {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Create an AuthManager with a specific CodexAuth and codex home, for testing only.
|
||||
pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc<Self> {
|
||||
let cached = CachedAuth { auth: Some(auth) };
|
||||
Arc::new(Self {
|
||||
codex_home,
|
||||
inner: RwLock::new(cached),
|
||||
enable_codex_api_key_env: false,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
})
|
||||
}
|
||||
|
||||
/// Current cached auth (clone). May be `None` if not logged in or load failed.
|
||||
pub fn auth(&self) -> Option<CodexAuth> {
|
||||
self.inner.read().ok().and_then(|c| c.auth.clone())
|
||||
|
||||
@@ -102,8 +102,7 @@ use crate::protocol::ReviewDecision;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::protocol::SkillErrorInfo;
|
||||
use crate::protocol::SkillInfo;
|
||||
use crate::protocol::SkillLoadOutcomeInfo;
|
||||
use crate::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use crate::protocol::StreamErrorEvent;
|
||||
use crate::protocol::Submission;
|
||||
use crate::protocol::TokenCountEvent;
|
||||
@@ -116,10 +115,11 @@ use crate::rollout::RolloutRecorderParams;
|
||||
use crate::rollout::map_session_init_error;
|
||||
use crate::shell;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skills::SkillError;
|
||||
use crate::skills::SkillInjections;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills::build_skill_injections;
|
||||
use crate::skills::load_skills;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::SessionServices;
|
||||
use crate::state::SessionState;
|
||||
@@ -203,6 +203,7 @@ impl Codex {
|
||||
config: Config,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
conversation_history: InitialHistory,
|
||||
session_source: SessionSource,
|
||||
) -> CodexResult<CodexSpawnOk> {
|
||||
@@ -210,7 +211,7 @@ impl Codex {
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
|
||||
let loaded_skills = if config.features.enabled(Feature::Skills) {
|
||||
Some(load_skills(&config))
|
||||
Some(skills_manager.skills_for_cwd(&config.cwd))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -225,11 +226,9 @@ impl Codex {
|
||||
}
|
||||
}
|
||||
|
||||
let skills_outcome = loaded_skills.clone();
|
||||
|
||||
let user_instructions = get_user_instructions(
|
||||
&config,
|
||||
skills_outcome
|
||||
loaded_skills
|
||||
.as_ref()
|
||||
.map(|outcome| outcome.skills.as_slice()),
|
||||
)
|
||||
@@ -275,7 +274,7 @@ impl Codex {
|
||||
tx_event.clone(),
|
||||
conversation_history,
|
||||
session_source_clone,
|
||||
skills_outcome.clone(),
|
||||
skills_manager,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -542,7 +541,7 @@ impl Session {
|
||||
tx_event: Sender<Event>,
|
||||
initial_history: InitialHistory,
|
||||
session_source: SessionSource,
|
||||
skills: Option<SkillLoadOutcome>,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
debug!(
|
||||
"Configuring session: model={}; provider={:?}",
|
||||
@@ -661,7 +660,7 @@ impl Session {
|
||||
otel_event_manager,
|
||||
models_manager: Arc::clone(&models_manager),
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
skills: skills.clone(),
|
||||
skills_manager,
|
||||
};
|
||||
|
||||
let sess = Arc::new(Session {
|
||||
@@ -677,8 +676,6 @@ impl Session {
|
||||
// Dispatch the SessionConfiguredEvent first and then report any errors.
|
||||
// If resuming, include converted initial messages in the payload so UIs can render them immediately.
|
||||
let initial_messages = initial_history.get_event_msgs();
|
||||
let skill_load_outcome = skill_load_outcome_for_client(skills.as_ref());
|
||||
|
||||
let events = std::iter::once(Event {
|
||||
id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
@@ -692,7 +689,6 @@ impl Session {
|
||||
history_log_id,
|
||||
history_entry_count,
|
||||
initial_messages,
|
||||
skill_load_outcome,
|
||||
rollout_path,
|
||||
}),
|
||||
})
|
||||
@@ -1567,6 +1563,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::ListCustomPrompts => {
|
||||
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
|
||||
}
|
||||
Op::ListSkills { cwds } => {
|
||||
handlers::list_skills(&sess, sub.id.clone(), cwds).await;
|
||||
}
|
||||
Op::Undo => {
|
||||
handlers::undo(&sess, sub.id.clone()).await;
|
||||
}
|
||||
@@ -1611,6 +1610,7 @@ mod handlers {
|
||||
|
||||
use crate::codex::spawn_review_thread;
|
||||
use crate::config::Config;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::collect_mcp_snapshot_from_manager;
|
||||
use crate::review_prompts::resolve_review_request;
|
||||
@@ -1624,9 +1624,11 @@ mod handlers {
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_protocol::protocol::ListSkillsResponseEvent;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::SkillsListEntry;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
|
||||
@@ -1634,6 +1636,7 @@ mod handlers {
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use mcp_types::RequestId;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
@@ -1861,6 +1864,44 @@ mod handlers {
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
pub async fn list_skills(sess: &Session, sub_id: String, cwds: Option<Vec<PathBuf>>) {
|
||||
let cwds = match cwds {
|
||||
Some(cwds) if !cwds.is_empty() => cwds,
|
||||
_ => {
|
||||
let state = sess.state.lock().await;
|
||||
vec![state.session_configuration.cwd.clone()]
|
||||
}
|
||||
};
|
||||
let skills = if sess.enabled(Feature::Skills) {
|
||||
let skills_manager = &sess.services.skills_manager;
|
||||
cwds.into_iter()
|
||||
.map(|cwd| {
|
||||
let outcome = skills_manager.skills_for_cwd(&cwd);
|
||||
let errors = super::errors_to_info(&outcome.errors);
|
||||
let skills = super::skills_to_info(&outcome.skills);
|
||||
SkillsListEntry {
|
||||
cwd,
|
||||
skills,
|
||||
errors,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
cwds.into_iter()
|
||||
.map(|cwd| SkillsListEntry {
|
||||
cwd,
|
||||
skills: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { skills }),
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
|
||||
pub async fn undo(sess: &Arc<Session>, sub_id: String) {
|
||||
let turn_context = sess
|
||||
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
|
||||
@@ -2046,28 +2087,26 @@ async fn spawn_review_thread(
|
||||
.await;
|
||||
}
|
||||
|
||||
fn skill_load_outcome_for_client(
|
||||
outcome: Option<&SkillLoadOutcome>,
|
||||
) -> Option<SkillLoadOutcomeInfo> {
|
||||
outcome.map(|outcome| SkillLoadOutcomeInfo {
|
||||
skills: outcome
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| SkillInfo {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
path: skill.path.clone(),
|
||||
})
|
||||
.collect(),
|
||||
errors: outcome
|
||||
.errors
|
||||
.iter()
|
||||
.map(|err| SkillErrorInfo {
|
||||
path: err.path.clone(),
|
||||
message: err.message.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
fn skills_to_info(skills: &[SkillMetadata]) -> Vec<ProtocolSkillMetadata> {
|
||||
skills
|
||||
.iter()
|
||||
.map(|skill| ProtocolSkillMetadata {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn errors_to_info(errors: &[SkillError]) -> Vec<SkillErrorInfo> {
|
||||
errors
|
||||
.iter()
|
||||
.map(|err| SkillErrorInfo {
|
||||
path: err.path.clone(),
|
||||
message: err.message.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Takes a user message as input and runs a loop where, at each turn, the model
|
||||
@@ -2098,10 +2137,20 @@ pub(crate) async fn run_task(
|
||||
});
|
||||
sess.send_event(&turn_context, event).await;
|
||||
|
||||
let skills_outcome = if sess.enabled(Feature::Skills) {
|
||||
Some(
|
||||
sess.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&turn_context.cwd),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let SkillInjections {
|
||||
items: skill_items,
|
||||
warnings: skill_warnings,
|
||||
} = build_skill_injections(&input, sess.services.skills.as_ref()).await;
|
||||
} = build_skill_injections(&input, skills_outcome.as_ref()).await;
|
||||
|
||||
for message in skill_warnings {
|
||||
sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message }))
|
||||
@@ -2954,6 +3003,7 @@ mod tests {
|
||||
otel_event_manager(conversation_id, config.as_ref(), &model_family);
|
||||
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone()));
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
@@ -2967,7 +3017,7 @@ mod tests {
|
||||
otel_event_manager: otel_event_manager.clone(),
|
||||
models_manager,
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
skills: None,
|
||||
skills_manager,
|
||||
};
|
||||
|
||||
let turn_context = Session::make_turn_context(
|
||||
@@ -3040,6 +3090,7 @@ mod tests {
|
||||
otel_event_manager(conversation_id, config.as_ref(), &model_family);
|
||||
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone()));
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
@@ -3053,7 +3104,7 @@ mod tests {
|
||||
otel_event_manager: otel_event_manager.clone(),
|
||||
models_manager,
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
skills: None,
|
||||
skills_manager,
|
||||
};
|
||||
|
||||
let turn_context = Arc::new(Session::make_turn_context(
|
||||
|
||||
@@ -49,6 +49,7 @@ pub(crate) async fn run_codex_conversation_interactive(
|
||||
config,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
Arc::clone(&parent_session.services.skills_manager),
|
||||
initial_history.unwrap_or(InitialHistory::New),
|
||||
SessionSource::SubAgent(SubAgentSource::Review),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::skills::SkillsManager;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -40,16 +41,19 @@ pub struct ConversationManager {
|
||||
conversations: Arc<RwLock<HashMap<ConversationId, Arc<CodexConversation>>>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
skills_manager: Arc<SkillsManager>,
|
||||
session_source: SessionSource,
|
||||
}
|
||||
|
||||
impl ConversationManager {
|
||||
pub fn new(auth_manager: Arc<AuthManager>, session_source: SessionSource) -> Self {
|
||||
let skills_manager = Arc::new(SkillsManager::new(auth_manager.codex_home().to_path_buf()));
|
||||
Self {
|
||||
conversations: Arc::new(RwLock::new(HashMap::new())),
|
||||
auth_manager: auth_manager.clone(),
|
||||
session_source,
|
||||
models_manager: Arc::new(ModelsManager::new(auth_manager)),
|
||||
skills_manager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +62,32 @@ impl ConversationManager {
|
||||
/// Used for integration tests: should not be used by ordinary business logic.
|
||||
pub fn with_models_provider(auth: CodexAuth, provider: ModelProviderInfo) -> Self {
|
||||
let auth_manager = crate::AuthManager::from_auth_for_testing(auth);
|
||||
let skills_manager = Arc::new(SkillsManager::new(auth_manager.codex_home().to_path_buf()));
|
||||
Self {
|
||||
conversations: Arc::new(RwLock::new(HashMap::new())),
|
||||
auth_manager: auth_manager.clone(),
|
||||
session_source: SessionSource::Exec,
|
||||
models_manager: Arc::new(ModelsManager::with_provider(auth_manager, provider)),
|
||||
skills_manager,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Construct with a dummy AuthManager containing the provided CodexAuth and codex home.
|
||||
/// Used for integration tests: should not be used by ordinary business logic.
|
||||
pub fn with_models_provider_and_home(
|
||||
auth: CodexAuth,
|
||||
provider: ModelProviderInfo,
|
||||
codex_home: PathBuf,
|
||||
) -> Self {
|
||||
let auth_manager = crate::AuthManager::from_auth_for_testing_with_home(auth, codex_home);
|
||||
let skills_manager = Arc::new(SkillsManager::new(auth_manager.codex_home().to_path_buf()));
|
||||
Self {
|
||||
conversations: Arc::new(RwLock::new(HashMap::new())),
|
||||
auth_manager: auth_manager.clone(),
|
||||
session_source: SessionSource::Exec,
|
||||
models_manager: Arc::new(ModelsManager::with_provider(auth_manager, provider)),
|
||||
skills_manager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +95,10 @@ impl ConversationManager {
|
||||
self.session_source.clone()
|
||||
}
|
||||
|
||||
pub fn skills_manager(&self) -> Arc<SkillsManager> {
|
||||
self.skills_manager.clone()
|
||||
}
|
||||
|
||||
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
|
||||
self.spawn_conversation(
|
||||
config,
|
||||
@@ -92,6 +121,7 @@ impl ConversationManager {
|
||||
config,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
self.skills_manager.clone(),
|
||||
InitialHistory::New,
|
||||
self.session_source.clone(),
|
||||
)
|
||||
@@ -169,6 +199,7 @@ impl ConversationManager {
|
||||
config,
|
||||
auth_manager,
|
||||
self.models_manager.clone(),
|
||||
self.skills_manager.clone(),
|
||||
initial_history,
|
||||
self.session_source.clone(),
|
||||
)
|
||||
@@ -210,6 +241,7 @@ impl ConversationManager {
|
||||
config,
|
||||
auth_manager,
|
||||
self.models_manager.clone(),
|
||||
self.skills_manager.clone(),
|
||||
history,
|
||||
self.session_source.clone(),
|
||||
)
|
||||
|
||||
@@ -342,8 +342,8 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::Skills,
|
||||
key: "skills",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellSnapshot,
|
||||
|
||||
@@ -79,6 +79,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
| EventMsg::ViewImageToolCall(_)
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use crate::skills::model::SkillError;
|
||||
use crate::skills::model::SkillLoadOutcome;
|
||||
use crate::skills::model::SkillMetadata;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use serde::Deserialize;
|
||||
use std::collections::VecDeque;
|
||||
@@ -53,10 +54,21 @@ impl fmt::Display for SkillParseError {
|
||||
impl Error for SkillParseError {}
|
||||
|
||||
pub fn load_skills(config: &Config) -> SkillLoadOutcome {
|
||||
load_skills_from_roots(skill_roots(config))
|
||||
}
|
||||
|
||||
pub(crate) struct SkillRoot {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) scope: SkillScope,
|
||||
}
|
||||
|
||||
pub(crate) fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
|
||||
where
|
||||
I: IntoIterator<Item = SkillRoot>,
|
||||
{
|
||||
let mut outcome = SkillLoadOutcome::default();
|
||||
let roots = skill_roots(config);
|
||||
for root in roots {
|
||||
discover_skills_under_root(&root, &mut outcome);
|
||||
discover_skills_under_root(&root.path, root.scope, &mut outcome);
|
||||
}
|
||||
|
||||
outcome
|
||||
@@ -66,21 +78,33 @@ pub fn load_skills(config: &Config) -> SkillLoadOutcome {
|
||||
outcome
|
||||
}
|
||||
|
||||
fn skill_roots(config: &Config) -> Vec<PathBuf> {
|
||||
let mut roots = vec![config.codex_home.join(SKILLS_DIR_NAME)];
|
||||
pub(crate) fn user_skills_root(codex_home: &Path) -> SkillRoot {
|
||||
SkillRoot {
|
||||
path: codex_home.join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(repo_root) = resolve_root_git_project_for_trust(&config.cwd) {
|
||||
roots.push(
|
||||
repo_root
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
);
|
||||
pub(crate) fn repo_skills_root(cwd: &Path) -> Option<SkillRoot> {
|
||||
resolve_root_git_project_for_trust(cwd).map(|repo_root| SkillRoot {
|
||||
path: repo_root
|
||||
.join(REPO_ROOT_CONFIG_DIR_NAME)
|
||||
.join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Repo,
|
||||
})
|
||||
}
|
||||
|
||||
fn skill_roots(config: &Config) -> Vec<SkillRoot> {
|
||||
let mut roots = vec![user_skills_root(&config.codex_home)];
|
||||
|
||||
if let Some(repo_root) = repo_skills_root(&config.cwd) {
|
||||
roots.push(repo_root);
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) {
|
||||
fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut SkillLoadOutcome) {
|
||||
let Ok(root) = normalize_path(root) else {
|
||||
return;
|
||||
};
|
||||
@@ -124,7 +148,7 @@ fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) {
|
||||
}
|
||||
|
||||
if file_type.is_file() && file_name == SKILLS_FILENAME {
|
||||
match parse_skill_file(&path) {
|
||||
match parse_skill_file(&path, scope) {
|
||||
Ok(skill) => outcome.skills.push(skill),
|
||||
Err(err) => outcome.errors.push(SkillError {
|
||||
path,
|
||||
@@ -136,7 +160,7 @@ fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_skill_file(path: &Path) -> Result<SkillMetadata, SkillParseError> {
|
||||
fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, SkillParseError> {
|
||||
let contents = fs::read_to_string(path).map_err(SkillParseError::Read)?;
|
||||
|
||||
let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?;
|
||||
@@ -156,6 +180,7 @@ fn parse_skill_file(path: &Path) -> Result<SkillMetadata, SkillParseError> {
|
||||
name,
|
||||
description,
|
||||
path: resolved_path,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
48
codex-rs/core/src/skills/manager.rs
Normal file
48
codex-rs/core/src/skills/manager.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use crate::skills::loader::repo_skills_root;
|
||||
use crate::skills::loader::user_skills_root;
|
||||
|
||||
pub struct SkillsManager {
|
||||
codex_home: PathBuf,
|
||||
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
|
||||
}
|
||||
|
||||
impl SkillsManager {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
cache_by_cwd: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome {
|
||||
let cached = match self.cache_by_cwd.read() {
|
||||
Ok(cache) => cache.get(cwd).cloned(),
|
||||
Err(err) => err.into_inner().get(cwd).cloned(),
|
||||
};
|
||||
if let Some(outcome) = cached {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let mut roots = vec![user_skills_root(&self.codex_home)];
|
||||
if let Some(repo_root) = repo_skills_root(cwd) {
|
||||
roots.push(repo_root);
|
||||
}
|
||||
let outcome = load_skills_from_roots(roots);
|
||||
match self.cache_by_cwd.write() {
|
||||
Ok(mut cache) => {
|
||||
cache.insert(cwd.to_path_buf(), outcome.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
err.into_inner().insert(cwd.to_path_buf(), outcome.clone());
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
pub mod injection;
|
||||
pub mod loader;
|
||||
pub mod manager;
|
||||
pub mod model;
|
||||
pub mod render;
|
||||
|
||||
pub(crate) use injection::SkillInjections;
|
||||
pub(crate) use injection::build_skill_injections;
|
||||
pub use loader::load_skills;
|
||||
pub use manager::SkillsManager;
|
||||
pub use model::SkillError;
|
||||
pub use model::SkillLoadOutcome;
|
||||
pub use model::SkillMetadata;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub path: PathBuf,
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::AuthManager;
|
||||
use crate::RolloutRecorder;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::openai_models::models_manager::ModelsManager;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::unified_exec::UnifiedExecSessionManager;
|
||||
use crate::user_notification::UserNotifier;
|
||||
@@ -25,5 +25,5 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) models_manager: Arc<ModelsManager>,
|
||||
pub(crate) otel_event_manager: OtelEventManager,
|
||||
pub(crate) tool_approvals: Mutex<ApprovalStore>,
|
||||
pub(crate) skills: Option<SkillLoadOutcome>,
|
||||
pub(crate) skills_manager: Arc<SkillsManager>,
|
||||
}
|
||||
|
||||
@@ -107,8 +107,11 @@ impl TestCodexBuilder {
|
||||
let (config, cwd) = self.prepare_config(server, &home).await?;
|
||||
|
||||
let auth = self.auth.clone();
|
||||
let conversation_manager =
|
||||
ConversationManager::with_models_provider(auth.clone(), config.model_provider.clone());
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
auth.clone(),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
|
||||
let new_conversation = match resume_from {
|
||||
Some(path) => {
|
||||
|
||||
@@ -259,9 +259,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
// Also configure user instructions to ensure they are NOT delivered on resume.
|
||||
config.user_instructions = Some("be nice".to_string());
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let auth_manager =
|
||||
codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
@@ -345,9 +346,10 @@ async fn includes_conversation_id_and_model_headers_in_request() {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
@@ -406,9 +408,10 @@ async fn includes_base_instructions_override_in_request() {
|
||||
config.base_instructions = Some("test instructions".to_string());
|
||||
config.model_provider = model_provider;
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
@@ -466,9 +469,10 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
create_dummy_codex_auth(),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
@@ -602,9 +606,10 @@ async fn includes_user_instructions_message_in_request() {
|
||||
config.model_provider = model_provider;
|
||||
config.user_instructions = Some("be nice".to_string());
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
@@ -671,9 +676,10 @@ async fn skills_append_to_instructions_when_feature_enabled() {
|
||||
config.features.enable(Feature::Skills);
|
||||
config.cwd = codex_home.path().to_path_buf();
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
@@ -713,6 +719,7 @@ async fn skills_append_to_instructions_when_feature_enabled() {
|
||||
instructions_text.contains(&expected_path_str),
|
||||
"expected path {expected_path_str} in instructions"
|
||||
);
|
||||
let _codex_home_guard = codex_home;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -1027,9 +1034,10 @@ async fn includes_developer_instructions_message_in_request() {
|
||||
config.user_instructions = Some("be nice".to_string());
|
||||
config.developer_instructions = Some("be useful".to_string());
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
@@ -1255,9 +1263,10 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
let mut config = load_default_config_for_test(&home);
|
||||
config.model_provider = provider;
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("test"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
@@ -1609,9 +1618,10 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = provider;
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
create_dummy_codex_auth(),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
@@ -1690,9 +1700,10 @@ async fn env_var_overrides_loaded_auth() {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = provider;
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
create_dummy_codex_auth(),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
@@ -1771,9 +1782,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
|
||||
let conversation_manager = ConversationManager::with_models_provider(
|
||||
let conversation_manager = ConversationManager::with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.clone(),
|
||||
);
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
|
||||
@@ -6,7 +6,6 @@ use codex_core::features::Feature;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::SkillLoadOutcomeInfo;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -115,11 +114,21 @@ async fn skill_load_errors_surface_in_session_configured() -> Result<()> {
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let SkillLoadOutcomeInfo { skills, errors } = test
|
||||
.session_configured
|
||||
.skill_load_outcome
|
||||
.as_ref()
|
||||
.expect("skill outcome present");
|
||||
test.codex.submit(Op::ListSkills { cwds: None }).await?;
|
||||
let response =
|
||||
core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event {
|
||||
codex_core::protocol::EventMsg::ListSkillsResponse(response) => Some(response.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let cwd = test.cwd_path();
|
||||
let (skills, errors) = response
|
||||
.skills
|
||||
.iter()
|
||||
.find(|entry| entry.cwd.as_path() == cwd)
|
||||
.map(|entry| (entry.skills.clone(), entry.errors.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
assert!(
|
||||
skills.is_empty(),
|
||||
|
||||
@@ -68,6 +68,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc
|
||||
- `Op::UserInput` – Any input from the user to kick off a `Task`
|
||||
- `Op::Interrupt` – Interrupts a running task
|
||||
- `Op::ExecApproval` – Approve or deny code execution
|
||||
- `Op::ListSkills` – Request skills for one or more cwd values
|
||||
- `EventMsg`
|
||||
- `EventMsg::AgentMessage` – Messages from the `Model`
|
||||
- `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command
|
||||
@@ -75,6 +76,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc
|
||||
- `EventMsg::Error` – A task stopped with an error
|
||||
- `EventMsg::Warning` – A non-fatal warning that the client should surface to the user
|
||||
- `EventMsg::TurnComplete` – Contains a `response_id` bookmark for last `response_id` executed by the task. This can be used to continue the task at a later point in time, perhaps with additional user input.
|
||||
- `EventMsg::ListSkillsResponse` – Response payload with per-cwd skill entries (`cwd`, `skills`, `errors`)
|
||||
|
||||
The `response_id` returned from each task matches the OpenAI `response_id` stored in the API's `/responses` endpoint. It can be stored and used in future `Sessions` to resume threads of work.
|
||||
|
||||
|
||||
@@ -572,6 +572,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::UserMessage(_)
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
|
||||
@@ -85,7 +85,6 @@ fn session_configured_produces_thread_started_event() {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
skill_load_outcome: None,
|
||||
rollout_path,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -279,6 +279,7 @@ async fn run_codex_tool_session_inner(
|
||||
| EventMsg::McpToolCallEnd(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
|
||||
@@ -266,7 +266,6 @@ mod tests {
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
initial_messages: None,
|
||||
skill_load_outcome: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
}),
|
||||
};
|
||||
@@ -306,7 +305,6 @@ mod tests {
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
initial_messages: None,
|
||||
skill_load_outcome: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
};
|
||||
let event = Event {
|
||||
|
||||
@@ -184,6 +184,13 @@ pub enum Op {
|
||||
/// Request the list of available custom prompts.
|
||||
ListCustomPrompts,
|
||||
|
||||
/// Request the list of skills for the provided `cwd` values or the session default.
|
||||
ListSkills {
|
||||
/// Optional working directories to scope repo skills discovery.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cwds: Option<Vec<PathBuf>>,
|
||||
},
|
||||
|
||||
/// Request the agent to summarize the current conversation context.
|
||||
/// The agent will use its existing context (either conversation history or previous response id)
|
||||
/// to generate a summary which will be returned as an AgentMessage event.
|
||||
@@ -562,6 +569,9 @@ pub enum EventMsg {
|
||||
/// List of custom prompts available to the agent.
|
||||
ListCustomPromptsResponse(ListCustomPromptsResponseEvent),
|
||||
|
||||
/// List of skills available to the agent.
|
||||
ListSkillsResponse(ListSkillsResponseEvent),
|
||||
|
||||
PlanUpdate(UpdatePlanArgs),
|
||||
|
||||
TurnAborted(TurnAbortedEvent),
|
||||
@@ -1624,11 +1634,26 @@ pub struct ListCustomPromptsResponseEvent {
|
||||
pub custom_prompts: Vec<CustomPrompt>,
|
||||
}
|
||||
|
||||
/// Response payload for `Op::ListSkills`.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct SkillInfo {
|
||||
pub struct ListSkillsResponseEvent {
|
||||
pub skills: Vec<SkillsListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
pub enum SkillScope {
|
||||
User,
|
||||
Repo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct SkillMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub path: PathBuf,
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
@@ -1637,9 +1662,10 @@ pub struct SkillErrorInfo {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)]
|
||||
pub struct SkillLoadOutcomeInfo {
|
||||
pub skills: Vec<SkillInfo>,
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct SkillsListEntry {
|
||||
pub cwd: PathBuf,
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
pub errors: Vec<SkillErrorInfo>,
|
||||
}
|
||||
|
||||
@@ -1678,9 +1704,6 @@ pub struct SessionConfiguredEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub initial_messages: Option<Vec<EventMsg>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub skill_load_outcome: Option<SkillLoadOutcomeInfo>,
|
||||
|
||||
pub rollout_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -1808,7 +1831,6 @@ mod tests {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
skill_load_outcome: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -31,9 +31,10 @@ use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFI
|
||||
use codex_core::openai_models::models_manager::ModelsManager;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::FinalOutput;
|
||||
use codex_core::protocol::ListSkillsResponseEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_core::protocol::SkillLoadOutcomeInfo;
|
||||
use codex_core::protocol::SkillErrorInfo;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::skills::SkillError;
|
||||
use codex_protocol::ConversationId;
|
||||
@@ -50,6 +51,7 @@ use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -86,9 +88,8 @@ fn session_summary(
|
||||
})
|
||||
}
|
||||
|
||||
fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec<SkillError> {
|
||||
outcome
|
||||
.errors
|
||||
fn skill_errors_from_info(errors: &[SkillErrorInfo]) -> Vec<SkillError> {
|
||||
errors
|
||||
.iter()
|
||||
.map(|err| SkillError {
|
||||
path: err.path.clone(),
|
||||
@@ -97,6 +98,15 @@ fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec<SkillError>
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec<SkillErrorInfo> {
|
||||
response
|
||||
.skills
|
||||
.iter()
|
||||
.find(|entry| entry.cwd.as_path() == cwd)
|
||||
.map(|entry| entry.errors.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SessionSummary {
|
||||
usage_line: String,
|
||||
@@ -688,11 +698,14 @@ impl App {
|
||||
self.suppress_shutdown_complete = false;
|
||||
return Ok(true);
|
||||
}
|
||||
if let EventMsg::SessionConfigured(cfg) = &event.msg
|
||||
&& let Some(outcome) = cfg.skill_load_outcome.as_ref()
|
||||
&& !outcome.errors.is_empty()
|
||||
{
|
||||
let errors = skill_errors_from_outcome(outcome);
|
||||
if let EventMsg::ListSkillsResponse(response) = &event.msg {
|
||||
let cwd = self.chat_widget.config_ref().cwd.clone();
|
||||
let errors = errors_for_cwd(&cwd, response);
|
||||
if errors.is_empty() {
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
return Ok(true);
|
||||
}
|
||||
let errors = skill_errors_from_info(&errors);
|
||||
match run_skill_error_prompt(tui, &errors).await {
|
||||
SkillErrorPromptOutcome::Exit => {
|
||||
self.chat_widget.submit_op(Op::Shutdown);
|
||||
@@ -1382,7 +1395,6 @@ mod tests {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
skill_load_outcome: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
};
|
||||
Arc::new(new_session_info(
|
||||
@@ -1438,7 +1450,6 @@ mod tests {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
skill_load_outcome: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::ExecCommandSource;
|
||||
use codex_core::protocol::ExitedReviewModeEvent;
|
||||
use codex_core::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_core::protocol::ListSkillsResponseEvent;
|
||||
use codex_core::protocol::McpListToolsResponseEvent;
|
||||
use codex_core::protocol::McpStartupCompleteEvent;
|
||||
use codex_core::protocol::McpStartupStatus;
|
||||
@@ -44,7 +45,7 @@ use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::SkillLoadOutcomeInfo;
|
||||
use codex_core::protocol::SkillsListEntry;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TerminalInteractionEvent;
|
||||
@@ -392,7 +393,7 @@ impl ChatWidget {
|
||||
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
self.set_skills_from_outcome(event.skill_load_outcome.as_ref());
|
||||
self.set_skills(None);
|
||||
self.conversation_id = Some(event.session_id);
|
||||
self.current_rollout_path = Some(event.rollout_path.clone());
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
@@ -409,6 +410,7 @@ impl ChatWidget {
|
||||
}
|
||||
// Ask codex-core to enumerate custom prompts for this session.
|
||||
self.submit_op(Op::ListCustomPrompts);
|
||||
self.submit_op(Op::ListSkills { cwds: None });
|
||||
if let Some(user_message) = self.initial_user_message.take() {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
@@ -417,11 +419,15 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) {
|
||||
let skills = outcome.map(skills_from_outcome);
|
||||
fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
|
||||
self.bottom_pane.set_skills(skills);
|
||||
}
|
||||
|
||||
fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
|
||||
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
|
||||
self.set_skills(Some(skills));
|
||||
}
|
||||
|
||||
pub(crate) fn open_feedback_note(
|
||||
&mut self,
|
||||
category: crate::app_event::FeedbackCategory,
|
||||
@@ -1879,6 +1885,7 @@ impl ChatWidget {
|
||||
EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev),
|
||||
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
|
||||
EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev),
|
||||
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
|
||||
EventMsg::ShutdownComplete => self.on_shutdown_complete(),
|
||||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff),
|
||||
EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev),
|
||||
@@ -3091,6 +3098,10 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_custom_prompts(ev.custom_prompts);
|
||||
}
|
||||
|
||||
fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) {
|
||||
self.set_skills_from_response(&ev);
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
|
||||
@@ -3475,16 +3486,23 @@ pub(crate) fn show_review_commit_picker_with_entries(
|
||||
});
|
||||
}
|
||||
|
||||
fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec<SkillMetadata> {
|
||||
outcome
|
||||
.skills
|
||||
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMetadata> {
|
||||
skills_entries
|
||||
.iter()
|
||||
.map(|skill| SkillMetadata {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
path: skill.path.clone(),
|
||||
.find(|entry| entry.cwd.as_path() == cwd)
|
||||
.map(|entry| {
|
||||
entry
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| SkillMetadata {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec<SkillMetadata> {
|
||||
|
||||
@@ -122,7 +122,6 @@ fn resumed_initial_messages_render_history() {
|
||||
message: "assistant reply".to_string(),
|
||||
}),
|
||||
]),
|
||||
skill_load_outcome: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1368,7 +1368,6 @@ mod tests {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
skill_load_outcome: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
};
|
||||
Arc::new(new_session_info(
|
||||
@@ -1424,7 +1423,6 @@ mod tests {
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
skill_load_outcome: None,
|
||||
rollout_path: PathBuf::new(),
|
||||
};
|
||||
|
||||
|
||||
@@ -135,6 +135,11 @@ impl BottomPane {
|
||||
self.status.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
|
||||
self.composer.set_skill_mentions(skills);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn context_window_percent(&self) -> Option<i64> {
|
||||
self.context_window_percent
|
||||
|
||||
@@ -33,6 +33,7 @@ use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::ExecCommandSource;
|
||||
use codex_core::protocol::ExitedReviewModeEvent;
|
||||
use codex_core::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_core::protocol::ListSkillsResponseEvent;
|
||||
use codex_core::protocol::McpListToolsResponseEvent;
|
||||
use codex_core::protocol::McpStartupCompleteEvent;
|
||||
use codex_core::protocol::McpStartupStatus;
|
||||
@@ -44,6 +45,7 @@ use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::SkillsListEntry;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TerminalInteractionEvent;
|
||||
@@ -408,6 +410,7 @@ impl ChatWidget {
|
||||
}
|
||||
// Ask codex-core to enumerate custom prompts for this session.
|
||||
self.submit_op(Op::ListCustomPrompts);
|
||||
self.submit_op(Op::ListSkills { cwds: None });
|
||||
if let Some(user_message) = self.initial_user_message.take() {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
@@ -1865,6 +1868,7 @@ impl ChatWidget {
|
||||
EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev),
|
||||
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
|
||||
EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev),
|
||||
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
|
||||
EventMsg::ShutdownComplete => self.on_shutdown_complete(),
|
||||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff),
|
||||
EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev),
|
||||
@@ -3075,6 +3079,11 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_custom_prompts(ev.custom_prompts);
|
||||
}
|
||||
|
||||
fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) {
|
||||
let skills = skills_for_cwd(&self.config.cwd, &ev.skills);
|
||||
self.bottom_pane.set_skills(Some(skills));
|
||||
}
|
||||
|
||||
pub(crate) fn open_review_popup(&mut self) {
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
|
||||
@@ -3459,5 +3468,24 @@ pub(crate) fn show_review_commit_picker_with_entries(
|
||||
});
|
||||
}
|
||||
|
||||
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMetadata> {
|
||||
skills_entries
|
||||
.iter()
|
||||
.find(|entry| entry.cwd.as_path() == cwd)
|
||||
.map(|entry| {
|
||||
entry
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| SkillMetadata {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
path: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
|
||||
@@ -122,7 +122,6 @@ fn resumed_initial_messages_render_history() {
|
||||
message: "assistant reply".to_string(),
|
||||
}),
|
||||
]),
|
||||
skill_load_outcome: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# Skills (experimental)
|
||||
|
||||
> **Warning:** This is an experimental and non-stable feature. If you depend on it, please expect breaking changes over the coming weeks and understand that there is currently no guarantee that this works well. Use at your own risk!
|
||||
# Skills
|
||||
|
||||
Codex can automatically discover reusable "skills" you keep on disk. A skill is a small bundle with a name, a short description (what it does and when to use it), and an optional body of instructions you can open when needed. Codex injects only the name, description, and file path into the runtime context; the body stays on disk.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user