Files
codex/codex-rs/core/src/agent/control.rs
T
tongzhou wang 1b6e0d8145 Track content provenance through prompt assembly and compaction (#53831)
## Why

Message roles and content classifications do not identify who supplied each context part. Source attribution needs to survive prompt assembly and history truncation, including messages that combine text from different producers.

## What changed

- Add per-part `ContentItemMetadata` for user input, developer instructions, `AGENTS.md`, skills, harness context, command hooks, and tool context with optional callable namespaces.
- Carry attribution alongside content and classifications through contextual fragments, extension prompts, hook output, and agent messages. Keep tool-provided context-window hints separate from harness text.
- Preserve the source of retained text slices during compaction and attribute generated truncation notices to the harness. Default missing historical attribution to unknown.
- Attribute host MCP calls using cached tool namespaces only when catalog revisions remain stable, without starting servers or refreshing catalogs for attribution.
- Send the new metadata to Responses Lite and strip it from requests to endpoints that do not support it.

## Testing

Extend compaction tests to cover mixed sources, missing or extra metadata entries, and discarded media. Update Responses Lite request assertions to verify endpoint-specific attribution handling.

GitOrigin-RevId: b0ebc929b7c60f3ec49d1affbf897312d63edcc8
2026-10-14 02:01:49 +00:00

764 lines
26 KiB
Rust

use crate::TurnInputRequest;
use crate::TurnInputSubmission;
use crate::TurnStartOptions;
use crate::agent::AgentStatus;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::resolve_role_config;
use crate::agent::status::is_final;
use crate::agent::types::AgentMetadata;
use crate::agent::types::LiveAgent;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::config::Config;
use crate::config::RolloutBudgetConfig;
use crate::context::ContextualUserFragment;
use crate::context::SubagentNotification;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::session::emit_subagent_session_started;
use crate::session_prefix::format_inter_agent_completion_message;
use crate::thread_manager::ResumeThreadWithHistoryOptions;
use crate::thread_manager::ThreadIdGenerator;
use crate::thread_manager::ThreadManagerState;
use crate::thread_manager::default_thread_id_generator;
use crate::turn_timing::now_unix_timestamp_ms;
use codex_history::InitialHistory;
use codex_history::ResumedHistory;
use codex_history::RolloutItem;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ContentItemMetadata;
use codex_protocol::models::ContentItemNamespace;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::HasLegacyEvent;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::user_input::UserInput;
use codex_thread_store::LoadThreadHistoryParams;
use codex_thread_store::ReadThreadParams;
use futures::StreamExt;
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::Weak;
use tracing::warn;
use uuid::Uuid;
pub(crate) use self::runtime::AgentControlInit;
pub(crate) use self::runtime::AgentTreeMembership;
pub(crate) use self::runtime::AgentTreeShutdownState;
pub(crate) use self::runtime::AgentTreeTeardownGuard;
pub(crate) use self::runtime::LocalAgentRuntime;
pub(crate) use self::watch::StatusSubscription;
mod api;
mod budget;
mod completion;
mod delivery;
mod execution;
mod inspection;
mod interrupt;
mod legacy;
mod mailbox;
mod residency;
pub use residency::ThreadEvictionOutcome;
mod resume;
mod root_handoff;
mod runtime;
mod runtime_context;
mod sender_context;
mod service_tier;
mod spawn;
mod spawn_guard;
mod spawn_telemetry;
mod target;
mod user_authorization;
mod watch;
/// Per-session controller handle for a local agent tree.
/// Handles retain a session identity and share their tree's `LocalAgentRuntime`.
/// Local startup preserves that state when creating or resuming children.
#[derive(Clone)]
pub(crate) struct LocalAgentControl {
/// session_id is equal to the root thread's ID.
session_id: SessionId,
pub(crate) runtime: LocalAgentRuntime,
}
impl Default for LocalAgentControl {
fn default() -> Self {
Self::new(
Weak::default(),
default_thread_id_generator(),
/*rollout_budget*/ None,
)
}
}
impl LocalAgentControl {
/// Construct a new `LocalAgentControl` that can spawn/message agents via the given manager state.
pub(crate) fn new(
manager: Weak<ThreadManagerState>,
thread_id_generator: ThreadIdGenerator,
rollout_budget: Option<RolloutBudgetConfig>,
) -> Self {
Self {
session_id: SessionId::default(),
runtime: LocalAgentRuntime::new(manager, thread_id_generator, rollout_budget),
}
}
pub(crate) fn with_session_id(mut self, session_id: SessionId, max_threads: usize) -> Self {
self.session_id = session_id;
self.runtime.agent_execution_limiter.initialize(max_threads);
self
}
pub(crate) fn session_id(&self) -> SessionId {
self.session_id
}
/// Send rich user input items to an existing agent thread.
pub(crate) async fn send_input(
&self,
agent_id: ThreadId,
input: Vec<UserInput>,
start_options: TurnStartOptions,
) -> CodexResult<String> {
let state = self.runtime.upgrade()?;
let thread = state.get_thread(agent_id).await?;
let result = match thread
.start_or_steer_turn(TurnInputRequest::user_input(input).on_start(start_options))
.await
{
Ok(TurnInputSubmission::Started { turn_id, .. }) => Ok(turn_id),
Ok(TurnInputSubmission::Steered { .. }) => {
// MAv1 exposes an opaque `submission_id` to the model. The legacy
// `Op::UserInput` path returned a fresh ID for every steer, while the
// turn-input API returns the active turn ID. Keep the tool-visible ID
// unique without adding a submission receipt back to Core.
Ok(Uuid::now_v7().to_string())
}
Ok(TurnInputSubmission::NotSubmitted { reason }) => Err(CodexErr::InvalidRequest(
format!("turn input was not submitted: {reason:?}"),
)),
Err(err) => Err(err),
};
self.handle_thread_request_result(agent_id, &state, result)
.await
}
pub(crate) async fn send_inter_agent_communication(
&self,
agent_id: ThreadId,
communication: InterAgentCommunication,
agent_communication_context: AgentCommunicationContext,
start_options: TurnStartOptions,
) -> CodexResult<String> {
let state = self.runtime.upgrade()?;
if communication.trigger_turn {
let thread = state.get_thread(agent_id).await?;
thread
.ensure_execution_capacity_for_turn_start(self)
.await?;
}
self.send_inter_agent_communication_after_capacity_check(
agent_id,
&state,
communication,
agent_communication_context,
start_options,
)
.await
}
pub(crate) async fn emit_sub_agent_activity(
&self,
thread_id: ThreadId,
turn_id: String,
item: SubAgentActivityItem,
) -> CodexResult<()> {
let state = self.runtime.upgrade()?;
let thread = state.get_thread(thread_id).await?;
let started_at_ms = now_unix_timestamp_ms();
let item = TurnItem::SubAgentActivity(item);
thread
.session
.send_event_raw(Event {
id: turn_id.clone(),
msg: EventMsg::ItemStarted(ItemStartedEvent {
thread_id,
turn_id: turn_id.clone(),
item: item.clone(),
started_at_ms,
}),
})
.await;
let completed_at_ms = now_unix_timestamp_ms();
let completed = ItemCompletedEvent {
thread_id,
turn_id: turn_id.clone(),
item,
started_at_ms: Some(started_at_ms),
completed_at_ms,
};
thread
.session
.send_event_raw(Event {
id: turn_id.clone(),
msg: EventMsg::ItemCompleted(completed.clone()),
})
.await;
for legacy in completed.as_legacy_events(/*show_raw_agent_reasoning*/ false) {
thread
.session
.send_event_raw(Event {
id: turn_id.clone(),
msg: legacy,
})
.await;
}
Ok(())
}
async fn send_inter_agent_communication_after_capacity_check(
&self,
agent_id: ThreadId,
state: &Arc<ThreadManagerState>,
communication: InterAgentCommunication,
context: AgentCommunicationContext,
start_options: TurnStartOptions,
) -> CodexResult<String> {
self.submit_inter_agent_communication(
agent_id,
state,
communication,
context,
start_options,
)
.await
}
async fn submit_inter_agent_communication(
&self,
agent_id: ThreadId,
state: &Arc<ThreadManagerState>,
communication: InterAgentCommunication,
context: AgentCommunicationContext,
start_options: TurnStartOptions,
) -> CodexResult<String> {
let communication_for_log =
crate::agent_communication::logging_enabled().then(|| communication.clone());
{
// Keep unloaded delivery atomic with publication of a reloaded session.
let threads = state.threads.read().await;
if !communication.trigger_turn && !threads.contains_key(&agent_id) {
let _membership = self.runtime.admit_start()?;
self.runtime.ensure_agent_known(agent_id)?;
let submission_id = uuid::Uuid::now_v7().to_string();
self.runtime.mailboxes.enqueue(
agent_id,
Some(submission_id.clone()),
vec![communication],
)?;
if let Some(communication) = communication_for_log {
crate::agent_communication::emit_agent_communication_send(
&submission_id,
&context,
&communication,
agent_id,
);
}
return Ok(submission_id);
}
}
// Loaded recipients retain submission ordering with follow-ups and interrupts.
let (parent_turn_id, root_turn_id) = if communication.trigger_turn {
(
start_options.parent_turn_id.clone(),
start_options.root_turn_id.clone(),
)
} else {
(None, None)
};
let result = self
.handle_thread_request_result(
agent_id,
state,
state
.send_op(
agent_id,
Op::InterAgentCommunication {
communication,
start_options,
},
parent_turn_id,
root_turn_id,
)
.await,
)
.await;
if let (Some(communication), Ok(communication_id)) =
(communication_for_log, result.as_ref())
{
crate::agent_communication::emit_agent_communication_send(
communication_id,
&context,
&communication,
agent_id,
);
}
result
}
/// Interrupt the current task for an existing agent thread.
pub(crate) async fn interrupt_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
let state = self.runtime.upgrade()?;
self.handle_thread_request_result(
agent_id,
&state,
state
.send_op(
agent_id,
Op::Interrupt,
/*parent_turn_id*/ None,
/*root_turn_id*/ None,
)
.await,
)
.await
}
async fn handle_thread_request_result(
&self,
agent_id: ThreadId,
state: &Arc<ThreadManagerState>,
result: CodexResult<String>,
) -> CodexResult<String> {
if result
.as_ref()
.is_err_and(|err| matches!(err.details(), CodexErrorDetails::InternalAgentDied))
{
let _ = state.remove_thread(&agent_id).await;
self.forget_v2_residency(agent_id);
self.runtime.registry.release_spawned_thread(agent_id);
}
result
}
/// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable.
pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus {
let Ok(state) = self.runtime.upgrade() else {
// No agent available if upgrade fails.
return AgentStatus::NotFound;
};
let Ok(thread) = state.get_thread(agent_id).await else {
return AgentStatus::NotFound;
};
thread.agent_status().await
}
pub(crate) fn get_agent_metadata(&self, agent_id: ThreadId) -> Option<AgentMetadata> {
self.runtime.registry.agent_metadata_for_thread(agent_id)
}
pub(crate) async fn list_agents(
&self,
current_session_source: &SessionSource,
path_prefix: Option<&str>,
) -> CodexResult<Vec<LiveAgent>> {
let state = self.runtime.upgrade()?;
let resolved_prefix = path_prefix
.map(|prefix| {
current_session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root)
.resolve(prefix)
.map_err(CodexErr::UnsupportedOperation)
})
.transpose()?;
let mut live_agents = self.runtime.registry.live_agents();
live_agents.sort_by(|left, right| {
left.agent_path
.as_deref()
.unwrap_or_default()
.cmp(right.agent_path.as_deref().unwrap_or_default())
.then_with(|| {
left.agent_id
.map(|id| id.to_string())
.unwrap_or_default()
.cmp(&right.agent_id.map(|id| id.to_string()).unwrap_or_default())
})
});
let root_path = AgentPath::root();
let mut agents = Vec::with_capacity(live_agents.len().saturating_add(1));
if resolved_prefix
.as_ref()
.is_none_or(|prefix| agent_matches_prefix(Some(&root_path), prefix))
&& let Some(root_thread_id) = self.runtime.registry.agent_id_for_path(&root_path)
&& let Ok(root_thread) = state.get_thread(root_thread_id).await
{
agents.push(LiveAgent {
thread_id: root_thread_id,
metadata: AgentMetadata {
agent_id: Some(root_thread_id),
agent_path: Some(root_path),
..Default::default()
},
status: root_thread.agent_status().await,
});
}
for metadata in live_agents {
let Some(thread_id) = metadata.agent_id else {
continue;
};
if resolved_prefix
.as_ref()
.is_some_and(|prefix| !agent_matches_prefix(metadata.agent_path.as_ref(), prefix))
{
continue;
}
let Ok(thread) = state.get_thread(thread_id).await else {
continue;
};
agents.push(LiveAgent {
thread_id,
metadata,
status: thread.agent_status().await,
});
}
Ok(agents)
}
/// Starts a detached watcher for sub-agents spawned from another thread.
///
/// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and
/// can receive completion notifications.
fn maybe_start_completion_watcher(
&self,
child_thread_id: ThreadId,
session_source: Option<SessionSource>,
child_reference: String,
child_agent_path: Option<AgentPath>,
) {
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
})) = session_source
else {
return;
};
let Ok(membership) = self.runtime.admit_start() else {
return;
};
let teardown = membership.into_teardown_guard("completion_watcher", Some(child_thread_id));
let control = self.clone();
let watcher = async move {
let status = match control.subscribe_status(child_thread_id).await {
Ok(mut updates) => {
let mut final_status = None;
while let Some(Ok(snapshot)) = tokio::select! {
biased;
_ = control.runtime.shutdown.cancelled() => return,
update = updates.next() => update,
} {
if let Some(status) = snapshot.status()
&& is_final(status)
{
final_status = Some(status.clone());
break;
}
}
match final_status {
Some(status) => status,
None => control.get_status(child_thread_id).await,
}
}
Err(_) => control.get_status(child_thread_id).await,
};
// Tree shutdown is a lifecycle handoff, not a child result for the parent.
if !is_final(&status)
|| (matches!(&status, AgentStatus::Shutdown)
&& control.runtime.shutdown.is_cancelled())
{
return;
}
let Ok(state) = control.runtime.upgrade() else {
return;
};
let child_thread = state.get_thread(child_thread_id).await.ok();
let child_uses_multi_agent_v2 = match child_thread.as_ref() {
Some(child_thread) => {
child_thread.multi_agent_version() == Some(MultiAgentVersion::V2)
}
None => true,
};
if child_agent_path.is_some() && child_uses_multi_agent_v2 {
let Some(child_agent_path) = child_agent_path.clone() else {
return;
};
let Some(parent_agent_path) = child_agent_path
.as_str()
.rsplit_once('/')
.and_then(|(parent, _)| AgentPath::try_from(parent).ok())
else {
return;
};
let Some(message) = format_inter_agent_completion_message(
parent_agent_path.clone(),
child_agent_path.clone(),
&status,
) else {
return;
};
let communication = InterAgentCommunication::new(
child_agent_path,
parent_agent_path,
Vec::new(),
message,
/*trigger_turn*/ false,
);
let context =
AgentCommunicationContext::new(AgentCommunicationKind::Result, child_thread_id);
let _ = control
.send_inter_agent_communication(
parent_thread_id,
communication,
context,
TurnStartOptions::default(),
)
.await;
return;
}
let Ok(parent_thread) = state.get_thread(parent_thread_id).await else {
return;
};
let config = parent_thread.config().await;
let namespace = match parent_thread
.multi_agent_version()
.unwrap_or_else(|| config.multi_agent_version_from_features())
{
MultiAgentVersion::Disabled => None,
MultiAgentVersion::V1 => Some(ContentItemNamespace::MultiAgentV1),
MultiAgentVersion::V2 => Some(
config
.multi_agent_v2
.tool_namespace
.clone()
.map(ContentItemNamespace::from)
.unwrap_or(ContentItemNamespace::Functions),
),
};
parent_thread
.inject_fragment_without_turn(
SubagentNotification::new(child_reference.as_str(), status)
.with_metadata(ContentItemMetadata::tool(namespace)),
)
.await;
};
tokio::spawn(async move {
watcher.await;
teardown.complete();
});
}
fn prepare_agent_metadata(
&self,
reservation: &mut crate::agent::registry::SpawnReservation,
config: &Config,
agent_path: Option<AgentPath>,
agent_role: Option<String>,
preferred_agent_nickname: Option<String>,
) -> CodexResult<AgentMetadata> {
if let Some(agent_path) = agent_path.as_ref() {
reservation.reserve_agent_path(agent_path)?;
}
let candidate_names = spawn::agent_nickname_candidates(config, agent_role.as_deref());
let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect();
let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference(
&candidate_name_refs,
preferred_agent_nickname.as_deref(),
)?);
Ok(AgentMetadata {
agent_id: None,
agent_path,
agent_nickname,
agent_role,
})
}
#[allow(clippy::too_many_arguments)]
fn prepare_thread_spawn(
&self,
reservation: &mut crate::agent::registry::SpawnReservation,
config: &Config,
parent_thread_id: ThreadId,
depth: i32,
agent_path: Option<AgentPath>,
agent_role: Option<String>,
preferred_agent_nickname: Option<String>,
) -> CodexResult<(SessionSource, AgentMetadata)> {
if depth == 1 {
self.runtime.registry.register_root_thread(parent_thread_id);
}
let agent_metadata = self.prepare_agent_metadata(
reservation,
config,
agent_path,
agent_role,
preferred_agent_nickname,
)?;
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_path: agent_metadata.agent_path.clone(),
agent_nickname: agent_metadata.agent_nickname.clone(),
agent_role: agent_metadata.agent_role.clone(),
});
Ok((session_source, agent_metadata))
}
async fn inherited_environments_for_source(
&self,
state: &Arc<ThreadManagerState>,
session_source: Option<&SessionSource>,
) -> Option<TurnEnvironmentSnapshot> {
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
})) = session_source
else {
return None;
};
let parent_thread = state.get_thread(*parent_thread_id).await.ok()?;
Some(
parent_thread
.session
.services
.turn_environments
.snapshot()
.await,
)
}
async fn inherited_exec_policy_for_source(
&self,
state: &Arc<ThreadManagerState>,
session_source: Option<&SessionSource>,
child_config: &Config,
) -> Option<Arc<crate::exec_policy::ExecPolicyManager>> {
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
})) = session_source
else {
return None;
};
let parent_thread = state.get_thread(*parent_thread_id).await.ok()?;
let parent_config = parent_thread.session.get_config().await;
if !crate::exec_policy::child_uses_parent_exec_policy(&parent_config, child_config) {
return None;
}
Some(Arc::clone(&parent_thread.session.services.exec_policy))
}
async fn persist_thread_spawn_edge_for_source(
&self,
child_thread: &crate::CodexThread,
child_thread_id: ThreadId,
session_source: Option<&SessionSource>,
) {
let Some(parent_thread_id) = session_source.and_then(SessionSource::parent_thread_id)
else {
return;
};
if child_thread.config_snapshot().await.ephemeral {
return;
}
let Ok(state) = self.runtime.upgrade() else {
return;
};
let Some(agent_graph_store) = state.agent_graph_store() else {
return;
};
if let Err(err) = agent_graph_store
.upsert_thread_spawn_edge(
parent_thread_id,
child_thread_id,
codex_agent_graph_store::ThreadSpawnEdgeStatus::Open,
)
.await
{
warn!("failed to persist thread-spawn edge: {err}");
}
}
}
fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool {
if prefix.is_root() {
return true;
}
agent_path.is_some_and(|agent_path| {
agent_path == prefix
|| agent_path
.as_str()
.strip_prefix(prefix.as_str())
.is_some_and(|suffix| suffix.starts_with('/'))
})
}
pub(crate) fn render_input_preview(input: &[UserInput]) -> String {
input
.iter()
.map(|item| match item {
UserInput::Text { text, .. } => text.clone(),
UserInput::Image { .. } => "[image]".to_string(),
UserInput::LocalImage { path, .. } => {
format!("[local_image:{}]", path.display())
}
UserInput::Audio { .. } => "[audio]".to_string(),
UserInput::LocalAudio { path } => {
format!("[local_audio:{}]", path.display())
}
UserInput::Skill { name, path, .. } => {
format!("[skill:${name}]({})", path.display())
}
UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"),
_ => "[input]".to_string(),
})
.collect::<Vec<_>>()
.join("\n")
}
fn thread_spawn_depth(session_source: &SessionSource) -> Option<i32> {
match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth),
_ => None,
}
}
#[cfg(test)]
#[path = "control_tests.rs"]
mod tests;