mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why Host skill discovery was still exposed as a manager even though it is a process-owned service shared by sessions, the app-server catalog, and file-watcher invalidation. The skills extension also consumed an ad hoc loaded-skills wrapper instead of a named immutable snapshot. ## What changed - replace `SkillsManager` with concrete `SkillsService` - make the service cache and return immutable `HostSkillsSnapshot` values - migrate the skills extension host provider to the snapshot boundary - migrate app-server catalog, watcher, and invalidation paths to the service This keeps the service limited to host discovery, caching, roots, and invalidation. Catalog rendering and invocation remain extension responsibilities for the next stacked change.
162 lines
5.3 KiB
Rust
162 lines
5.3 KiB
Rust
use std::sync::Arc;
|
|
|
|
use codex_extension_api::FunctionCallError;
|
|
use codex_extension_api::JsonToolOutput;
|
|
use codex_extension_api::ResponsesApiTool;
|
|
use codex_extension_api::ToolCall;
|
|
use codex_extension_api::ToolExecutor;
|
|
use codex_extension_api::ToolName;
|
|
use codex_extension_api::ToolOutput;
|
|
use codex_extension_api::ToolSpec;
|
|
use codex_extension_api::parse_tool_input_schema;
|
|
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
|
use codex_mcp::McpResourceClient;
|
|
use codex_tools::ResponsesApiNamespace;
|
|
use codex_tools::ResponsesApiNamespaceTool;
|
|
use codex_tools::default_namespace_description;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
|
|
use crate::catalog::SkillAuthority;
|
|
use crate::catalog::SkillCatalog;
|
|
use crate::catalog::SkillSourceKind;
|
|
use crate::provider::SkillListQuery;
|
|
use crate::sources::SkillProviders;
|
|
use crate::state::SkillsThreadState;
|
|
|
|
mod list;
|
|
mod read;
|
|
mod schema;
|
|
|
|
const SKILLS_NAMESPACE: &str = "skills";
|
|
const MAX_HANDLE_BYTES: usize = 2_048;
|
|
|
|
pub(crate) fn skill_tools(
|
|
providers: SkillProviders,
|
|
mcp_resources: Option<Arc<McpResourceClient>>,
|
|
thread_state: Arc<SkillsThreadState>,
|
|
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>> {
|
|
let context = SkillToolContext {
|
|
providers,
|
|
mcp_resources,
|
|
thread_state,
|
|
};
|
|
vec![
|
|
Arc::new(list::ListTool {
|
|
context: context.clone(),
|
|
}),
|
|
Arc::new(read::ReadTool { context }),
|
|
]
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SkillToolContext {
|
|
providers: SkillProviders,
|
|
mcp_resources: Option<Arc<McpResourceClient>>,
|
|
thread_state: Arc<SkillsThreadState>,
|
|
}
|
|
|
|
impl SkillToolContext {
|
|
async fn catalog(&self, turn_id: &str, authority: SkillToolAuthority) -> SkillCatalog {
|
|
match authority {
|
|
SkillToolAuthority::Orchestrator => {
|
|
self.thread_state
|
|
.orchestrator_catalog_snapshot(
|
|
self.mcp_resources.as_deref(),
|
|
self.providers.list_orchestrator_for_turn(SkillListQuery {
|
|
turn_id: turn_id.to_string(),
|
|
executor_roots: Vec::new(),
|
|
host_snapshot: None,
|
|
include_host_skills: false,
|
|
include_bundled_skills: false,
|
|
include_orchestrator_skills: true,
|
|
mcp_resources: self.mcp_resources.clone(),
|
|
}),
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
|
enum SkillToolAuthority {
|
|
Orchestrator,
|
|
}
|
|
|
|
impl SkillToolAuthority {
|
|
fn from_authority(authority: &SkillAuthority) -> Option<Self> {
|
|
if authority
|
|
!= &SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME)
|
|
{
|
|
return None;
|
|
}
|
|
Some(Self::Orchestrator)
|
|
}
|
|
|
|
fn into_authority(self) -> SkillAuthority {
|
|
match self {
|
|
Self::Orchestrator => {
|
|
SkillAuthority::new(SkillSourceKind::Orchestrator, CODEX_APPS_MCP_SERVER_NAME)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn skill_tool_name(name: &str) -> ToolName {
|
|
ToolName::namespaced(SKILLS_NAMESPACE, name)
|
|
}
|
|
|
|
fn skill_function_tool<I: JsonSchema, O: JsonSchema>(name: &str, description: &str) -> ToolSpec {
|
|
let tool = ResponsesApiTool {
|
|
name: name.to_string(),
|
|
description: description.to_string(),
|
|
strict: false,
|
|
defer_loading: None,
|
|
parameters: parse_tool_input_schema(&schema::input_schema_for::<I>())
|
|
.unwrap_or_else(|err| panic!("generated input schema for {name} should parse: {err}")),
|
|
output_schema: Some(schema::output_schema_for::<O>()),
|
|
};
|
|
|
|
ToolSpec::Namespace(ResponsesApiNamespace {
|
|
name: SKILLS_NAMESPACE.to_string(),
|
|
description: default_namespace_description(SKILLS_NAMESPACE),
|
|
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
|
|
})
|
|
}
|
|
|
|
fn parse_args<T: for<'de> Deserialize<'de>>(call: &ToolCall) -> Result<T, FunctionCallError> {
|
|
let arguments = call.function_arguments()?;
|
|
let value = if arguments.trim().is_empty() {
|
|
Value::Object(serde_json::Map::new())
|
|
} else {
|
|
serde_json::from_str(arguments)
|
|
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?
|
|
};
|
|
serde_json::from_value(value).map_err(|err| FunctionCallError::RespondToModel(err.to_string()))
|
|
}
|
|
|
|
fn validate_handle(name: &str, value: &str, max_bytes: usize) -> Result<(), FunctionCallError> {
|
|
if is_bounded_handle(value, max_bytes) {
|
|
return Ok(());
|
|
}
|
|
|
|
Err(FunctionCallError::RespondToModel(format!(
|
|
"{name} must be non-empty, contain no control characters, and be at most {max_bytes} bytes"
|
|
)))
|
|
}
|
|
|
|
fn is_bounded_handle(value: &str, max_bytes: usize) -> bool {
|
|
!value.is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control)
|
|
}
|
|
|
|
fn external_json_output<T: Serialize>(value: &T) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
|
|
let value = serde_json::to_value(value).map_err(|err| {
|
|
FunctionCallError::Fatal(format!("failed to serialize tool output: {err}"))
|
|
})?;
|
|
Ok(Box::new(JsonToolOutput::new(value).with_external_context()))
|
|
}
|