Files
codex/codex-rs/memories/write/src/runtime.rs
T
jif 89ac3ec27c Load selected executor skills through extensions (#27184)
## Why

CCA is moving toward a split runtime where the orchestrator may not have
a filesystem, while executors can expose preinstalled plugins and
skills. A thread therefore needs to select capabilities without asking
app-server or core to interpret executor-owned paths through the
orchestrator's filesystem.

The longer-term model is broader than executor skills:

- A plugin is a bundle of skills, MCP servers, connectors/apps, and
hooks.
- A plugin root can be local, executor-owned, or hosted by a backend.
- Components inside one plugin can use different access and execution
mechanisms. A skill may be read from a filesystem or through backend
tools; an HTTP MCP server can run without an executor; a stdio MCP
server or hook needs an execution environment.
- Core should carry generic extension initialization data. The extension
that owns a component should discover it, expose it to the model, and
invoke it through the appropriate runtime.

This PR establishes that architecture through one complete vertical:
selecting a root on an executor, discovering the skills beneath it,
exposing those skills to the model, and reading an explicitly invoked
`SKILL.md` through the same executor.

## Contract

`thread/start` gains an experimental `selectedCapabilityRoots` field:

```json
{
  "selectedCapabilityRoots": [
    {
      "id": "deploy-plugin@1",
      "location": {
        "type": "environment",
        "environmentId": "workspace",
        "path": "/opt/codex/plugins/deploy"
      }
    }
  ]
}
```

The root is intentionally not classified as a "plugin" or "skill" in the
API. It can point at a standalone skill, a directory containing several
skills, or a plugin containing skills and other components. This PR only
teaches the skills extension how to consume it; later extensions can
resolve MCP, connector, and hook components from the same selection.

The platform-supplied `id` is stable selection identity. The location
says which runtime owns the root and gives that runtime an opaque path.
App-server does not inspect or canonicalize the path.

## What changed

### Generic thread extension initialization

App-server converts selected roots into `ExtensionDataInit`. Core
carries that generic initialization value until the final thread ID is
known, then creates thread-scoped `ExtensionData` before lifecycle
contributors run.

This keeps `Session` and core independent of the capability-selection
contract. The initialization value is consumed during construction; it
is not retained as another long-lived `Session` field.

### Executor-backed skills

The skills extension now owns an `ExecutorSkillProvider` that:

- resolves the selected environment through `EnvironmentManager`
- discovers, canonicalizes, and reads skills through that environment's
`ExecutorFileSystem`
- contributes the bounded selected-skill catalog as stable developer
context
- reads an explicitly invoked skill body through the authority that
listed it
- warns when an environment or root is unavailable
- never falls back to the orchestrator filesystem for an executor-owned
root

Skill catalog and instruction fragments have hard byte bounds, which
also bound them below the 10K-token per-item context limit. If a
selected executor skill has the same name as a legacy local skill, the
executor selection owns that invocation and the local body is not
injected a second time.

Existing local and bundled skill loading remains in place. Omitting
`selectedCapabilityRoots` therefore preserves current local-only
behavior.

## Current semantics

- Only environment-owned locations are represented in this first
contract.
- Roots are resolved by the destination extension, not by app-server or
core.
- An unavailable executor or invalid root produces a warning and no
capabilities from that root; it does not trigger a local-filesystem
fallback.
- Selection applies to a newly started active thread.
- MCP servers, connectors, and hooks beneath a selected plugin root are
not activated yet.
- Selection is not yet persisted or inherited across resume, fork, or
subagent creation. Existing local capabilities continue to behave as
they do today in those flows.

## Planned vertical follow-ups

1. **Hosted HTTP MCP:** add an extension-backed HTTP MCP source that
works without an executor, then replace the special-purpose MCP plugins
loader with that implementation.
2. **Executor MCP:** register and execute stdio MCP servers through the
environment that owns the selected plugin root.
3. **Backend skills:** add a hosted skill source whose catalog and
bodies are accessed through extension tools rather than a filesystem.
4. **Connectors and hooks:** activate those components through their
owning extensions, using the same selected-root boundary and
component-specific runtime.
5. **Durable selection:** define the desired-selection lifecycle,
persist it, and make resume, fork, and subagent inheritance explicit
rather than accidental.
6. **Local convergence:** incrementally route existing local plugin,
skill, and MCP loading through the same extension model while preserving
current local behavior.

Each follow-up remains reviewable as an end-to-end capability. The
platform selects roots, generic thread extension data carries the
selection, and the owning extension resolves and operates its component.

## Verification

Coverage added for:

- app-server end-to-end discovery and explicit invocation of a skill
inside an executor-selected plugin root
- exclusive invocation when a selected executor skill collides with a
local skill name
- executor filesystem authority for discovery, canonicalization, and
reads
- thread extension initialization before lifecycle contributors run
- stable executor catalog context, explicit invocation, context
rebuilding, hidden skills, and preserved host/remote catalog behavior

Targeted protocol, core-skills, skills-extension, core lifecycle, and
app-server executor-skill tests were run during development.
2026-06-09 19:51:54 +02:00

301 lines
10 KiB
Rust

use codex_core::CodexThread;
use codex_core::ModelClient;
use codex_core::NewThread;
use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::content_items_to_text;
use codex_core::resolve_installation_id;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::user_input::UserInput;
use codex_rollout_trace::InferenceTraceContext;
use codex_state::StateRuntime;
use codex_terminal_detection::user_agent;
use futures::StreamExt;
use std::sync::Arc;
use std::time::Duration;
pub(crate) struct SpawnedConsolidationAgent {
pub(crate) thread_id: ThreadId,
pub(crate) thread: Arc<CodexThread>,
}
#[derive(Clone, Debug)]
pub(crate) struct StageOneRequestContext {
pub(crate) model_info: ModelInfo,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) reasoning_effort: Option<ReasoningEffort>,
pub(crate) reasoning_summary: ReasoningSummary,
pub(crate) service_tier: Option<String>,
pub(crate) turn_metadata_header: Option<String>,
}
impl StageOneRequestContext {
pub(crate) fn start_timer(&self, name: &str) -> Option<codex_otel::Timer> {
self.session_telemetry.start_timer(name, &[]).ok()
}
pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) {
self.session_telemetry.counter(name, inc, tags);
}
pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
self.session_telemetry.histogram(name, value, tags);
}
}
pub(crate) struct MemoryStartupContext {
thread_id: ThreadId,
thread: Arc<CodexThread>,
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
session_telemetry: SessionTelemetry,
}
impl MemoryStartupContext {
pub(crate) fn new(
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
thread_id: ThreadId,
thread: Arc<CodexThread>,
config: &Config,
source: SessionSource,
) -> Self {
let auth = auth_manager.auth_cached();
let auth = auth.as_ref();
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
let account_id = auth.and_then(CodexAuth::get_account_id);
let account_email = auth.and_then(CodexAuth::get_account_email);
let model = config.model.as_deref().unwrap_or("unknown");
let auth_env_telemetry = collect_auth_env_telemetry(
&config.model_provider,
auth_manager.codex_api_key_env_enabled(),
);
let session_telemetry = SessionTelemetry::new(
thread_id,
model,
model,
account_id,
account_email,
auth_mode,
originator().value,
config.otel.log_user_prompt,
user_agent(),
source,
)
.with_auth_env(auth_env_telemetry.to_otel_metadata());
Self {
thread_id,
thread,
thread_manager,
auth_manager,
session_telemetry,
}
}
pub(crate) fn thread_id(&self) -> ThreadId {
self.thread_id
}
pub(crate) fn state_db(&self) -> Option<Arc<StateRuntime>> {
self.thread.state_db()
}
pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) {
self.session_telemetry.counter(name, inc, tags);
}
pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
self.session_telemetry.histogram(name, value, tags);
}
pub(crate) fn start_timer(&self, name: &str) -> Option<codex_otel::Timer> {
self.session_telemetry.start_timer(name, &[]).ok()
}
pub(crate) async fn stage_one_request_context(
&self,
config: &Config,
model_name: &str,
reasoning_effort: ReasoningEffort,
) -> StageOneRequestContext {
let config_snapshot = self.thread.config_snapshot().await;
let model_info = self
.thread_manager
.get_models_manager()
.get_model_info(model_name, &config.to_models_manager_config())
.await;
let turn_metadata_header =
codex_core::build_turn_metadata_header(&config.cwd, /*sandbox*/ None).await;
let reasoning_summary = config
.model_reasoning_summary
.unwrap_or(model_info.default_reasoning_summary);
StageOneRequestContext {
model_info,
session_telemetry: self
.session_telemetry
.clone()
.with_model(model_name, model_name),
reasoning_effort: Some(reasoning_effort),
reasoning_summary,
service_tier: config_snapshot.service_tier,
turn_metadata_header,
}
}
pub(crate) async fn stream_stage_one_prompt(
&self,
config: &Config,
prompt: &Prompt,
context: &StageOneRequestContext,
) -> anyhow::Result<(String, Option<TokenUsage>)> {
let installation_id = resolve_installation_id(&config.codex_home).await?;
let config_snapshot = self.thread.config_snapshot().await;
let session_source = config_snapshot.session_source;
let model_client = ModelClient::new(
Some(Arc::clone(&self.auth_manager)),
SessionId::from(self.thread_id), // We use thread_id to detach this query from the foreground user session.
self.thread_id,
installation_id,
config.model_provider.clone(),
session_source,
config_snapshot.parent_thread_id,
config.model_verbosity,
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
/*beta_features_header*/ None,
/*attestation_provider*/ None,
);
let mut client_session = model_client.new_session();
let mut stream = client_session
.stream(
prompt,
&context.model_info,
&context.session_telemetry,
context.reasoning_effort.clone(),
context.reasoning_summary,
context.service_tier.clone(),
context.turn_metadata_header.as_deref(),
&InferenceTraceContext::disabled(),
)
.await?;
let mut result = String::new();
let mut token_usage = None;
while let Some(message) = stream.next().await.transpose()? {
match message {
ResponseEvent::OutputTextDelta(delta) => result.push_str(&delta),
ResponseEvent::OutputItemDone(item) => {
if result.is_empty()
&& let codex_protocol::models::ResponseItem::Message { content, .. } = item
&& let Some(text) = content_items_to_text(&content)
{
result.push_str(&text);
}
}
ResponseEvent::Completed {
token_usage: usage, ..
} => {
token_usage = usage;
break;
}
_ => {}
}
}
Ok((result, token_usage))
}
pub(crate) async fn spawn_consolidation_agent(
&self,
config: Config,
prompt: Vec<UserInput>,
) -> anyhow::Result<SpawnedConsolidationAgent> {
let environments = self
.thread_manager
.default_environment_selections(&config.cwd);
let NewThread {
thread_id, thread, ..
} = self
.thread_manager
.start_thread_with_options(StartThreadOptions {
config,
initial_history: InitialHistory::New,
session_source: Some(SessionSource::Internal(
InternalSessionSource::MemoryConsolidation,
)),
thread_source: Some(ThreadSource::MemoryConsolidation),
dynamic_tools: Vec::new(),
metrics_service_name: None,
parent_trace: None,
environments,
thread_extension_init: Default::default(),
})
.await?;
let agent = SpawnedConsolidationAgent { thread_id, thread };
if let Err(err) = agent
.thread
.submit(Op::UserInput {
items: prompt,
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await
{
if let Err(shutdown_err) = self.shutdown_consolidation_agent(agent).await {
tracing::warn!(
"failed to shut down consolidation agent after submit error: {shutdown_err}"
);
}
return Err(err.into());
}
Ok(agent)
}
pub(crate) async fn shutdown_consolidation_agent(
&self,
agent: SpawnedConsolidationAgent,
) -> anyhow::Result<()> {
let SpawnedConsolidationAgent { thread_id, thread } = agent;
let thread = self
.thread_manager
.remove_thread(&thread_id)
.await
.unwrap_or(thread);
tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait())
.await
.map_err(|_| {
anyhow::anyhow!("memory consolidation agent {thread_id} shutdown timed out")
})??;
Ok(())
}
}