mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Render host-owned V2 subagent rosters in environment context (#59531)
## What changed - Expose `AgentSessionReader` and `AgentRef` for read-only access to host-owned agent session membership. - Read direct children when rendering V2 environment context, using the existing roster size limits. Refresh membership when rebuilding context after compaction, and omit the roster if the provider is absent or the read fails. - Preserve unrecorded initial input across suspension or interruption while waiting for a roster read. Resolve the roster before advancing the compaction window. ## Testing Add coverage for roster refreshes after compaction, unchanged environments avoiding extra reads, unavailable rosters, recoverable input during blocked reads, and stale compaction results rejected after a concurrent history replacement. GitOrigin-RevId: 3c7e3822af09e9295d405e53dabb87b020c8f00c
This commit is contained in:
@@ -97,15 +97,8 @@ impl WorldStateSection for TokenBudgetContext {
|
||||
) -> SectionTransition<Self::Snapshot> {
|
||||
let current = self.agent_path.clone();
|
||||
let fragment = matches!(previous, PreviousSectionState::Known(agent_path) if agent_path != &self.agent_path)
|
||||
.then(|| Box::new(self.clone()) as Box<dyn ContextualUserFragment>);
|
||||
(
|
||||
Some(current),
|
||||
fragment
|
||||
.into_iter()
|
||||
.map(WorldStateUpdate::boxed_fragment)
|
||||
.map(WorldStateUpdate::standalone)
|
||||
.collect(),
|
||||
)
|
||||
.then(|| WorldStateUpdate::fragment(self.clone()).standalone());
|
||||
(Some(current), fragment.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ use super::WorldStateSection;
|
||||
use super::WorldStateUpdate;
|
||||
use crate::context::APPROVED_COMMAND_PREFIX_SAVED_MESSAGE_PREFIX;
|
||||
use crate::context::ApprovedCommandPrefixSaved;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_protocol::models::format_allow_prefixes;
|
||||
use std::collections::BTreeSet;
|
||||
@@ -51,15 +50,14 @@ impl WorldStateSection for CompactPermissionsState {
|
||||
return (Some(current), Vec::new());
|
||||
}
|
||||
};
|
||||
let fragment: Option<Box<dyn ContextualUserFragment>> =
|
||||
format_allow_prefixes(added_prefixes)
|
||||
.filter(|prefixes| !prefixes.is_empty())
|
||||
.map(|prefixes| {
|
||||
Box::new(ApprovedCommandPrefixSaved::new(
|
||||
prefixes,
|
||||
self.saved_prefix_heading.as_deref(),
|
||||
)) as _
|
||||
});
|
||||
let fragment: Option<super::WorldStateFragment> = format_allow_prefixes(added_prefixes)
|
||||
.filter(|prefixes| !prefixes.is_empty())
|
||||
.map(|prefixes| {
|
||||
Box::new(ApprovedCommandPrefixSaved::new(
|
||||
prefixes,
|
||||
self.saved_prefix_heading.as_deref(),
|
||||
)) as _
|
||||
});
|
||||
(
|
||||
Some(current),
|
||||
WorldStateUpdate::optional_boxed_fragment(fragment),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::Placement;
|
||||
use super::PreviousSectionState;
|
||||
use super::SectionTransition;
|
||||
use super::WorldStateSection;
|
||||
use super::WorldStateUpdate;
|
||||
use super::WorldStateUpdateContent;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::environment_context::FileSystemContext;
|
||||
use crate::context::environment_context::NetworkContext;
|
||||
@@ -11,6 +13,7 @@ use crate::environment_selection::TurnEnvironmentState;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::shell::ShellType;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::input_text;
|
||||
@@ -179,7 +182,7 @@ impl WorldStateSection for EnvironmentsState {
|
||||
.values()
|
||||
.all(|update| matches!(update, EnvironmentUpdate::Current(_)));
|
||||
let fragment = (!updates.is_empty() || turn_context_values_changed).then(|| {
|
||||
Box::new(RenderedEnvironments {
|
||||
let environment = Box::new(RenderedEnvironments {
|
||||
updates,
|
||||
legacy_single,
|
||||
include_primary: multiple_environments || previous_multiple_environments,
|
||||
@@ -193,12 +196,13 @@ impl WorldStateSection for EnvironmentsState {
|
||||
network: self.network.clone(),
|
||||
filesystem: self.filesystem.clone(),
|
||||
subagents: self.subagents.clone(),
|
||||
}) as Box<dyn ContextualUserFragment>
|
||||
});
|
||||
WorldStateUpdate {
|
||||
placement: Placement::Mergeable,
|
||||
content: WorldStateUpdateContent::Environment(environment),
|
||||
}
|
||||
});
|
||||
(
|
||||
Some(current),
|
||||
WorldStateUpdate::optional_boxed_fragment(fragment),
|
||||
)
|
||||
(Some(current), fragment.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +228,7 @@ impl ContextualUserFragment for EnvironmentsState {
|
||||
}
|
||||
}
|
||||
|
||||
struct RenderedEnvironments {
|
||||
pub(crate) struct RenderedEnvironments {
|
||||
updates: BTreeMap<String, EnvironmentUpdate>,
|
||||
legacy_single: bool,
|
||||
include_primary: bool,
|
||||
@@ -235,7 +239,7 @@ struct RenderedEnvironments {
|
||||
timezone: Option<String>,
|
||||
network: Option<NetworkContext>,
|
||||
filesystem: Option<FileSystemContext>,
|
||||
subagents: Option<String>,
|
||||
pub(crate) subagents: Option<String>,
|
||||
}
|
||||
|
||||
enum EnvironmentUpdate {
|
||||
@@ -565,3 +569,22 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "environment_render_tests.rs"]
|
||||
mod render_tests;
|
||||
|
||||
pub(crate) fn format_agent_paths(paths: impl Iterator<Item = AgentPath>) -> String {
|
||||
const MAX_AGENTS: usize = 8;
|
||||
const MAX_BYTES: usize = 1_024;
|
||||
let mut lines = Vec::new();
|
||||
let mut rendered_bytes = " <subagents>\n </subagents>\n".len();
|
||||
for path in paths {
|
||||
if lines.len() == MAX_AGENTS {
|
||||
break;
|
||||
}
|
||||
let line = format!(r#"<agent name="{path}" />"#);
|
||||
let line_bytes = " \n".len() + line.len();
|
||||
if rendered_bytes + line_bytes <= MAX_BYTES {
|
||||
rendered_bytes += line_bytes;
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) mod test_support;
|
||||
mod tools;
|
||||
mod top_level_tools;
|
||||
|
||||
use crate::AgentSessionReader;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_extension_api::PreviousWorldStateSection;
|
||||
use codex_extension_api::RenderedWorldStateFragment;
|
||||
@@ -36,6 +37,7 @@ use sha1::Digest;
|
||||
use sha1::Sha1;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) use agents_md::AgentsMdState;
|
||||
pub(crate) use apps_instructions::AppsInstructionsState;
|
||||
@@ -43,6 +45,8 @@ pub(crate) use collaboration_mode::CollaborationModeState;
|
||||
pub(crate) use compact_permissions::CompactPermissionsState;
|
||||
pub(crate) use context_window_guidance::ContextWindowGuidanceState;
|
||||
pub(crate) use environment::EnvironmentsState;
|
||||
use environment::RenderedEnvironments;
|
||||
pub(crate) use environment::format_agent_paths;
|
||||
pub(crate) use environments_instructions::EnvironmentsInstructionsState;
|
||||
pub(crate) use managed_developer_instructions::ManagedDeveloperInstructions;
|
||||
pub(crate) use managed_developer_instructions::ManagedDeveloperInstructionsState;
|
||||
@@ -73,31 +77,42 @@ pub(crate) enum Placement {
|
||||
Mergeable,
|
||||
}
|
||||
|
||||
pub(crate) type WorldStateFragment = Box<dyn ContextualUserFragment + Send>;
|
||||
|
||||
pub(crate) enum WorldStateUpdateContent {
|
||||
Fragment(Box<dyn ContextualUserFragment>),
|
||||
Fragment(WorldStateFragment),
|
||||
Environment(Box<RenderedEnvironments>),
|
||||
Item(Box<ResponseItem>),
|
||||
}
|
||||
|
||||
impl WorldStateUpdateContent {
|
||||
pub(crate) fn into_fragment(self) -> Result<WorldStateFragment, Box<ResponseItem>> {
|
||||
match self {
|
||||
Self::Fragment(fragment) => Ok(fragment),
|
||||
Self::Environment(environment) => Ok(environment),
|
||||
Self::Item(item) => Err(item),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateUpdate {
|
||||
pub(crate) fn fragment(fragment: impl ContextualUserFragment + 'static) -> Self {
|
||||
pub(crate) fn fragment(fragment: impl ContextualUserFragment + Send + 'static) -> Self {
|
||||
Self::boxed_fragment(Box::new(fragment))
|
||||
}
|
||||
|
||||
pub(crate) fn boxed_fragment(fragment: Box<dyn ContextualUserFragment>) -> Self {
|
||||
fn boxed_fragment(fragment: WorldStateFragment) -> Self {
|
||||
Self {
|
||||
placement: Placement::Mergeable,
|
||||
content: WorldStateUpdateContent::Fragment(fragment),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn optional_boxed_fragment(
|
||||
fragment: Option<Box<dyn ContextualUserFragment>>,
|
||||
) -> Vec<Self> {
|
||||
pub(crate) fn optional_boxed_fragment(fragment: Option<WorldStateFragment>) -> Vec<Self> {
|
||||
fragment.into_iter().map(Self::boxed_fragment).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn optional_standalone_boxed_fragment(
|
||||
fragment: Option<Box<dyn ContextualUserFragment>>,
|
||||
fragment: Option<WorldStateFragment>,
|
||||
) -> Vec<Self> {
|
||||
fragment
|
||||
.into_iter()
|
||||
@@ -129,9 +144,9 @@ pub(crate) fn split_prefix_updates(
|
||||
let mut context = Vec::new();
|
||||
for update in updates {
|
||||
match update.placement {
|
||||
Placement::Prefix => prefix.push(match update.content {
|
||||
WorldStateUpdateContent::Item(item) => *item,
|
||||
WorldStateUpdateContent::Fragment(fragment) => fragment.into_boxed_response_item(),
|
||||
Placement::Prefix => prefix.push(match update.content.into_fragment() {
|
||||
Ok(fragment) => fragment.into_boxed_response_item(),
|
||||
Err(item) => *item,
|
||||
}),
|
||||
Placement::Standalone | Placement::Mergeable => context.push(update),
|
||||
}
|
||||
@@ -408,6 +423,7 @@ fn hash_component(hasher: &mut Sha1, value: &str) {
|
||||
/// Live model-visible state, keyed by the same stable section IDs used in rollouts.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct WorldState {
|
||||
pub(crate) host_roster_reader: Option<Arc<dyn AgentSessionReader>>,
|
||||
sections: IndexMap<&'static str, Box<dyn ErasedWorldStateSection>>,
|
||||
}
|
||||
|
||||
@@ -517,6 +533,30 @@ enum RenderScope {
|
||||
}
|
||||
|
||||
impl WorldState {
|
||||
/// Read the roster only when rendering an environment fragment. If the read fails,
|
||||
/// keep rendering the rest of the fragment.
|
||||
pub(crate) async fn resolve_host_roster(&self, updates: &mut [WorldStateUpdate]) {
|
||||
if let Some(reader) = &self.host_roster_reader
|
||||
&& let Some(environment) =
|
||||
updates
|
||||
.iter_mut()
|
||||
.find_map(|update| match &mut update.content {
|
||||
WorldStateUpdateContent::Environment(environment) => Some(environment),
|
||||
WorldStateUpdateContent::Fragment(_) | WorldStateUpdateContent::Item(_) => {
|
||||
None
|
||||
}
|
||||
})
|
||||
{
|
||||
match reader.children().await {
|
||||
Ok(children) => {
|
||||
let roster = format_agent_paths(children.into_iter().map(|agent| agent.path));
|
||||
environment.subagents = (!roster.is_empty()).then_some(roster);
|
||||
}
|
||||
Err(_) => tracing::warn!("failed to read host agent roster for prompt context"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn full_context_excluded_section_ids(
|
||||
&self,
|
||||
) -> impl Iterator<Item = &'static str> + '_ {
|
||||
@@ -551,6 +591,14 @@ impl WorldState {
|
||||
}
|
||||
|
||||
/// Renders restorable sections as new, preserving update order and placement.
|
||||
pub(crate) async fn render_full_with_host_roster(
|
||||
&self,
|
||||
) -> (WorldStateSnapshot, Vec<WorldStateUpdate>) {
|
||||
let (snapshot, mut updates) = self.render_full();
|
||||
self.resolve_host_roster(&mut updates).await;
|
||||
(snapshot, updates)
|
||||
}
|
||||
|
||||
pub(crate) fn render_full(&self) -> (WorldStateSnapshot, Vec<WorldStateUpdate>) {
|
||||
self.render_with(RenderScope::FullContext, /*stored*/ None, |_, _| {
|
||||
PreviousSectionState::Absent
|
||||
|
||||
@@ -125,7 +125,7 @@ impl WorldStateSection for ModelInstructionsState {
|
||||
if instructions_hash.is_some() && !announce_model && !instructions_changed {
|
||||
return (model_changed.then(|| self.snapshot.clone()), Vec::new());
|
||||
}
|
||||
let fragment: Option<Box<dyn ContextualUserFragment>> = if instructions_hash.is_some() {
|
||||
let fragment: Option<super::WorldStateFragment> = if instructions_hash.is_some() {
|
||||
Some(Box::new(ModelInstructions {
|
||||
model: announce_model.then(|| model.clone()),
|
||||
instructions: instructions_changed.then(|| self.instructions.clone()),
|
||||
@@ -133,7 +133,7 @@ impl WorldStateSection for ModelInstructionsState {
|
||||
} else {
|
||||
(model_changed && !self.instructions.is_empty()).then(|| {
|
||||
Box::new(ModelSwitchInstructions::new(self.instructions.clone()))
|
||||
as Box<dyn ContextualUserFragment>
|
||||
as super::WorldStateFragment
|
||||
})
|
||||
};
|
||||
(
|
||||
|
||||
@@ -93,7 +93,7 @@ impl WorldStateSection for MultiAgentModeState {
|
||||
|
||||
let fragment = MultiAgentModeInstructions::from_mode(mode).map(|instructions| {
|
||||
Box::new(instructions.with_metadata(ContentItemMetadata::tool(self.namespace.clone())))
|
||||
as Box<dyn ContextualUserFragment>
|
||||
as super::WorldStateFragment
|
||||
});
|
||||
(
|
||||
Some(self.clone()),
|
||||
|
||||
@@ -46,7 +46,7 @@ impl WorldStateSection for MultiAgentUsageHintState {
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> SectionTransition<Self::Snapshot> {
|
||||
let fragment: Option<Box<dyn ContextualUserFragment>> = match previous {
|
||||
let fragment: Option<super::WorldStateFragment> = match previous {
|
||||
PreviousSectionState::Known(previous) if previous == &self.fingerprint => {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ impl RealtimeState {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_start(&self) -> Box<dyn ContextualUserFragment> {
|
||||
fn render_start(&self) -> super::WorldStateFragment {
|
||||
match self.start_instructions.as_deref() {
|
||||
Some(instructions) => Box::new(RealtimeStartWithInstructions::new(instructions)),
|
||||
None => Box::new(RealtimeStartInstructions),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_transition(&self, previous_active: bool) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
fn render_transition(&self, previous_active: bool) -> Option<super::WorldStateFragment> {
|
||||
match (previous_active, self.snapshot.active) {
|
||||
(false, true) => Some(self.render_start()),
|
||||
(true, false) => Some(match self.end_instructions.as_deref() {
|
||||
|
||||
@@ -22,8 +22,8 @@ pub(super) fn render_section_cases<'a, S: WorldStateSection>(
|
||||
} else {
|
||||
updates
|
||||
.into_iter()
|
||||
.map(|update| match update.content {
|
||||
WorldStateUpdateContent::Fragment(fragment) => {
|
||||
.map(|update| match update.content.into_fragment() {
|
||||
Ok(fragment) => {
|
||||
let rendered = match fragment.render() {
|
||||
ContentItem::InputText { text }
|
||||
| ContentItem::OutputText { text, .. } => text,
|
||||
@@ -31,7 +31,7 @@ pub(super) fn render_section_cases<'a, S: WorldStateSection>(
|
||||
};
|
||||
format!(" (role - {})\n{rendered}", fragment.role())
|
||||
}
|
||||
WorldStateUpdateContent::Item(item) => {
|
||||
Err(item) => {
|
||||
let placement = match update.placement {
|
||||
Placement::Prefix => "prefix",
|
||||
Placement::Standalone => "standalone",
|
||||
@@ -125,9 +125,9 @@ pub(crate) fn expect_fragments(
|
||||
}
|
||||
|
||||
fn expect_fragment(content: WorldStateUpdateContent) -> Box<dyn ContextualUserFragment> {
|
||||
match content {
|
||||
WorldStateUpdateContent::Fragment(fragment) => fragment,
|
||||
WorldStateUpdateContent::Item(item) => panic!("expected a context fragment, got {item:?}"),
|
||||
match content.into_fragment() {
|
||||
Ok(fragment) => fragment,
|
||||
Err(item) => panic!("expected a context fragment, got {item:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ impl WorldStateSection for TestSection {
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> SectionTransition<Self::Snapshot> {
|
||||
let current = self.clone();
|
||||
let fragment: Option<Box<dyn ContextualUserFragment>> = match previous {
|
||||
let fragment: Option<super::WorldStateFragment> = match previous {
|
||||
PreviousSectionState::Known(previous) if self.value != previous.value => {
|
||||
Some(Box::new(TestFragment(self.value.clone())))
|
||||
}
|
||||
@@ -520,9 +520,9 @@ fn mixed_output_preserves_order_and_restores_its_snapshot() {
|
||||
assert_eq!(
|
||||
context
|
||||
.into_iter()
|
||||
.map(|update| match update.content {
|
||||
WorldStateUpdateContent::Fragment(fragment) => fragment.into_boxed_response_item(),
|
||||
WorldStateUpdateContent::Item(item) => *item,
|
||||
.map(|update| match update.content.into_fragment() {
|
||||
Ok(fragment) => fragment.into_boxed_response_item(),
|
||||
Err(item) => *item,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
|
||||
Reference in New Issue
Block a user