mirror of
https://github.com/openai/codex.git
synced 2026-06-03 03:41:58 +00:00
Compare commits
4 Commits
cconger/co
...
itay/20260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98462cbf15 | ||
|
|
804b3db0e7 | ||
|
|
3c7f70a0cb | ||
|
|
4bf6575725 |
@@ -216,6 +216,7 @@ pub enum CompactionReason {
|
||||
pub enum CompactionImplementation {
|
||||
Responses,
|
||||
ResponsesCompact,
|
||||
Reflections,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
@@ -231,6 +232,7 @@ pub enum CompactionPhase {
|
||||
pub enum CompactionStrategy {
|
||||
Memento,
|
||||
PrefixCompaction,
|
||||
Reflections,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
|
||||
@@ -36,6 +36,7 @@ use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
|
||||
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_model_provider_info::OPENAI_PROVIDER_ID;
|
||||
use codex_protocol::config_types::CompactionStrategyConfig;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
@@ -144,6 +145,9 @@ pub struct ConfigToml {
|
||||
/// Compact prompt used for history compaction.
|
||||
pub compact_prompt: Option<String>,
|
||||
|
||||
/// Strategy used when compacting conversation history.
|
||||
pub compaction_strategy: Option<CompactionStrategyConfig>,
|
||||
|
||||
/// Optional commit attribution text for commit message co-author trailers.
|
||||
///
|
||||
/// Set to an empty string to disable automatic commit attribution.
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::types::ApprovalsReviewer;
|
||||
use crate::types::Personality;
|
||||
use crate::types::WindowsToml;
|
||||
use codex_features::FeaturesToml;
|
||||
use codex_protocol::config_types::CompactionStrategyConfig;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
@@ -40,6 +41,7 @@ pub struct ConfigProfile {
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
/// Optional path to a file containing model instructions.
|
||||
pub model_instructions_file: Option<AbsolutePathBuf>,
|
||||
pub compaction_strategy: Option<CompactionStrategyConfig>,
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
/// Ordered list of directories to search for Node modules in `js_repl`.
|
||||
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
|
||||
|
||||
@@ -38,6 +38,10 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
||||
.properties
|
||||
.insert(feature.key.to_string(), schema_gen.subschema_for::<bool>());
|
||||
}
|
||||
validation.properties.insert(
|
||||
"reflections".to_string(),
|
||||
schema_gen.subschema_for::<codex_features::ReflectionsConfigToml>(),
|
||||
);
|
||||
for legacy_key in legacy_feature_keys() {
|
||||
validation
|
||||
.properties
|
||||
|
||||
@@ -302,6 +302,13 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CompactionStrategyConfig": {
|
||||
"enum": [
|
||||
"default",
|
||||
"reflections"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ConfigProfile": {
|
||||
"additionalProperties": false,
|
||||
"description": "Collection of common configuration options that a user can define as a unit in `config.toml`.",
|
||||
@@ -318,6 +325,9 @@
|
||||
"chatgpt_base_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"compaction_strategy": {
|
||||
"$ref": "#/definitions/CompactionStrategyConfig"
|
||||
},
|
||||
"experimental_compact_prompt_file": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
@@ -437,6 +447,9 @@
|
||||
"realtime_conversation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reflections": {
|
||||
"$ref": "#/definitions/ReflectionsConfigToml"
|
||||
},
|
||||
"remote_control": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1654,6 +1667,24 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ReflectionsConfigToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shared_notes_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"storage_tools_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"usage_hint_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"usage_hint_text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SandboxMode": {
|
||||
"enum": [
|
||||
"read-only",
|
||||
@@ -2141,6 +2172,14 @@
|
||||
"description": "Compact prompt used for history compaction.",
|
||||
"type": "string"
|
||||
},
|
||||
"compaction_strategy": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CompactionStrategyConfig"
|
||||
}
|
||||
],
|
||||
"description": "Strategy used when compacting conversation history."
|
||||
},
|
||||
"default_permissions": {
|
||||
"description": "Default named permissions profile to apply from the `[permissions]` table.",
|
||||
"type": "string"
|
||||
@@ -2293,6 +2332,9 @@
|
||||
"realtime_conversation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reflections": {
|
||||
"$ref": "#/definitions/ReflectionsConfigToml"
|
||||
},
|
||||
"remote_control": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -1580,7 +1580,7 @@ where
|
||||
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
|
||||
let (tx_last_response, rx_last_response) = oneshot::channel::<LastResponse>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let task = tokio::spawn(async move {
|
||||
let mut logged_error = false;
|
||||
let mut tx_last_response = Some(tx_last_response);
|
||||
let mut items_added: Vec<ResponseItem> = Vec::new();
|
||||
@@ -1645,8 +1645,16 @@ where
|
||||
}
|
||||
}
|
||||
});
|
||||
let abort_handle = task.abort_handle();
|
||||
std::mem::drop(task);
|
||||
|
||||
(ResponseStream { rx_event }, rx_last_response)
|
||||
(
|
||||
ResponseStream {
|
||||
rx_event,
|
||||
abort_handle,
|
||||
},
|
||||
rx_last_response,
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles a 401 response by optionally refreshing ChatGPT tokens once.
|
||||
|
||||
@@ -158,6 +158,20 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
|
||||
|
||||
pub struct ResponseStream {
|
||||
pub(crate) rx_event: mpsc::Receiver<Result<ResponseEvent>>,
|
||||
pub(crate) abort_handle: tokio::task::AbortHandle,
|
||||
}
|
||||
|
||||
impl ResponseStream {
|
||||
pub(crate) fn close(&mut self) {
|
||||
self.abort_handle.abort();
|
||||
self.rx_event.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ResponseStream {
|
||||
fn drop(&mut self) {
|
||||
self.abort_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for ResponseStream {
|
||||
|
||||
@@ -3,7 +3,9 @@ use std::collections::HashSet;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
@@ -37,6 +39,7 @@ use crate::rollout::find_thread_name_by_id;
|
||||
use crate::session_prefix::format_subagent_notification_message;
|
||||
use crate::skills_load_input_from_config;
|
||||
use crate::stream_events_utils::HandleOutputCtx;
|
||||
use crate::stream_events_utils::ResponseTerminalControl;
|
||||
use crate::stream_events_utils::handle_non_tool_response_item;
|
||||
use crate::stream_events_utils::handle_output_item_done;
|
||||
use crate::stream_events_utils::last_assistant_message_from_item;
|
||||
@@ -52,6 +55,7 @@ use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::CompactionPhase;
|
||||
use codex_analytics::CompactionReason;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SubAgentThreadStartedInput;
|
||||
use codex_analytics::TurnResolvedConfigFact;
|
||||
@@ -96,6 +100,7 @@ use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::CompactionStrategyConfig;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
@@ -107,6 +112,7 @@ use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::format_allow_prefixes;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
@@ -339,6 +345,8 @@ use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
@@ -660,6 +668,8 @@ impl Codex {
|
||||
persist_extended_history,
|
||||
inherited_shell_snapshot,
|
||||
user_shell_override,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
|
||||
// Generate a unique ID for the lifetime of this Codex session.
|
||||
@@ -856,6 +866,8 @@ pub(crate) struct Session {
|
||||
pub(crate) services: SessionServices,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
reflections_context_window_reset_requested: AtomicBool,
|
||||
reflections_near_limit_reminder_recorded: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -920,6 +932,7 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
|
||||
pub(crate) turn_skills: TurnSkillsContext,
|
||||
pub(crate) turn_timing_state: Arc<TurnTimingState>,
|
||||
pub(crate) reflections_shared_notes_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
impl TurnContext {
|
||||
pub(crate) fn model_context_window(&self) -> Option<i64> {
|
||||
@@ -1044,6 +1057,7 @@ impl TurnContext {
|
||||
turn_metadata_state: self.turn_metadata_state.clone(),
|
||||
turn_skills: self.turn_skills.clone(),
|
||||
turn_timing_state: Arc::clone(&self.turn_timing_state),
|
||||
reflections_shared_notes_path: self.reflections_shared_notes_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1204,6 +1218,8 @@ pub(crate) struct SessionConfiguration {
|
||||
persist_extended_history: bool,
|
||||
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
|
||||
user_shell_override: Option<shell::Shell>,
|
||||
reflections_sidecar_path: Option<AbsolutePathBuf>,
|
||||
reflections_shared_notes_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
impl SessionConfiguration {
|
||||
@@ -1294,8 +1310,55 @@ impl SessionConfiguration {
|
||||
if let Some(app_server_client_version) = updates.app_server_client_version.clone() {
|
||||
next_configuration.app_server_client_version = Some(app_server_client_version);
|
||||
}
|
||||
next_configuration.add_reflections_sidecar_to_sandbox_policies();
|
||||
Ok(next_configuration)
|
||||
}
|
||||
|
||||
fn add_reflections_sidecar_to_sandbox_policies(&mut self) {
|
||||
let Some(sidecar_path) = self.reflections_sidecar_path.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.file_system_sandbox_policy = self
|
||||
.file_system_sandbox_policy
|
||||
.clone()
|
||||
.with_additional_writable_roots(
|
||||
self.cwd.as_path(),
|
||||
std::slice::from_ref(&sidecar_path),
|
||||
);
|
||||
|
||||
let mut sandbox_policy = self.sandbox_policy.get().clone();
|
||||
let mut sandbox_policy_changed = false;
|
||||
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy
|
||||
&& !writable_roots
|
||||
.iter()
|
||||
.any(|existing| existing == &sidecar_path)
|
||||
{
|
||||
writable_roots.push(sidecar_path);
|
||||
sandbox_policy_changed = true;
|
||||
}
|
||||
if sandbox_policy_changed && let Err(err) = self.sandbox_policy.set(sandbox_policy) {
|
||||
warn!("failed to add Reflections sidecar to sandbox policy: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_resolve_reflections_shared_notes_path(
|
||||
config: &Config,
|
||||
session_source: &SessionSource,
|
||||
) -> bool {
|
||||
if config.compaction_strategy != CompactionStrategyConfig::Reflections
|
||||
|| !config.reflections.storage_tools_enabled
|
||||
|| !config.reflections.shared_notes_enabled
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
config.features.enabled(Feature::Collab)
|
||||
|| matches!(
|
||||
session_source,
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
@@ -1319,6 +1382,12 @@ pub(crate) struct AppServerClientMetadata {
|
||||
pub(crate) client_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ReflectionsSidecarPreparation {
|
||||
Prepare,
|
||||
Skip,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata {
|
||||
let state = self.state.lock().await;
|
||||
@@ -1539,6 +1608,37 @@ impl Session {
|
||||
let auth_manager_for_context = auth_manager;
|
||||
let provider_for_context = provider;
|
||||
let session_telemetry_for_context = session_telemetry;
|
||||
let reflections_enabled = per_turn_config.compaction_strategy
|
||||
== CompactionStrategyConfig::Reflections
|
||||
&& session_configuration.reflections_sidecar_path.is_some();
|
||||
let shared_notes_available = reflections_enabled
|
||||
&& per_turn_config.reflections.storage_tools_enabled
|
||||
&& per_turn_config.reflections.shared_notes_enabled
|
||||
&& session_configuration
|
||||
.reflections_shared_notes_path
|
||||
.is_some();
|
||||
let reflections_usage_hint_text = if reflections_enabled
|
||||
&& per_turn_config.reflections.usage_hint_enabled
|
||||
{
|
||||
session_configuration
|
||||
.reflections_sidecar_path
|
||||
.as_ref()
|
||||
.map(|sidecar_path| {
|
||||
let context_window = model_info.context_window.map(|context_window| {
|
||||
context_window.saturating_mul(model_info.effective_context_window_percent)
|
||||
/ 100
|
||||
});
|
||||
crate::reflections::usage_hint(
|
||||
context_window,
|
||||
sidecar_path.as_path(),
|
||||
per_turn_config.reflections.usage_hint_text.as_deref(),
|
||||
per_turn_config.reflections.storage_tools_enabled,
|
||||
shared_notes_available,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &models_manager.try_list_models().unwrap_or_default(),
|
||||
@@ -1560,6 +1660,12 @@ impl Session {
|
||||
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_reflections(
|
||||
reflections_enabled,
|
||||
reflections_usage_hint_text,
|
||||
per_turn_config.reflections.storage_tools_enabled,
|
||||
shared_notes_available,
|
||||
)
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&per_turn_config.agent_roles,
|
||||
));
|
||||
@@ -1618,6 +1724,9 @@ impl Session {
|
||||
turn_metadata_state,
|
||||
turn_skills: TurnSkillsContext::new(skills_outcome),
|
||||
turn_timing_state: Arc::new(TurnTimingState::default()),
|
||||
reflections_shared_notes_path: session_configuration
|
||||
.reflections_shared_notes_path
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1776,6 +1885,30 @@ impl Session {
|
||||
let rollout_path = rollout_recorder
|
||||
.as_ref()
|
||||
.map(|rec| rec.rollout_path().to_path_buf());
|
||||
if config.compaction_strategy == CompactionStrategyConfig::Reflections
|
||||
&& let Some(sidecar_path) = rollout_path
|
||||
.as_ref()
|
||||
.map(|rollout_path| crate::reflections::sidecar_path_for_rollout(rollout_path))
|
||||
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
|
||||
{
|
||||
session_configuration.reflections_sidecar_path = Some(sidecar_path);
|
||||
session_configuration.add_reflections_sidecar_to_sandbox_policies();
|
||||
}
|
||||
if should_resolve_reflections_shared_notes_path(
|
||||
config.as_ref(),
|
||||
&session_configuration.session_source,
|
||||
) && let Some(rollout_path) = rollout_path.as_ref()
|
||||
{
|
||||
session_configuration.reflections_shared_notes_path =
|
||||
crate::reflections::resolve_reflections_shared_notes_path(
|
||||
config.codex_home.as_path(),
|
||||
rollout_path.as_path(),
|
||||
conversation_id,
|
||||
&session_configuration.session_source,
|
||||
)
|
||||
.await
|
||||
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok());
|
||||
}
|
||||
|
||||
let mut post_session_configured_events = Vec::<Event>::new();
|
||||
|
||||
@@ -1946,7 +2079,16 @@ impl Session {
|
||||
))
|
||||
.await;
|
||||
session_configuration.thread_name = thread_name.clone();
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let mut state = SessionState::new(session_configuration.clone());
|
||||
if let Some(sidecar_path) = session_configuration.reflections_sidecar_path.clone() {
|
||||
state.record_granted_permissions(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![sidecar_path]),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let managed_network_requirements_enabled = config.managed_network_requirements_enabled();
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
// The managed proxy can call back into core for allowlist-miss decisions.
|
||||
@@ -2112,6 +2254,8 @@ impl Session {
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
reflections_context_window_reset_requested: AtomicBool::new(false),
|
||||
reflections_near_limit_reminder_recorded: AtomicBool::new(false),
|
||||
});
|
||||
if let Some(network_policy_decider_session) = network_policy_decider_session {
|
||||
let mut guard = network_policy_decider_session.write().await;
|
||||
@@ -2295,6 +2439,75 @@ impl Session {
|
||||
format!("auto-compact-{id}")
|
||||
}
|
||||
|
||||
pub(crate) fn request_reflections_context_window_reset(&self) {
|
||||
self.reflections_context_window_reset_requested
|
||||
.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn take_reflections_context_window_reset_request(&self) -> bool {
|
||||
self.reflections_context_window_reset_requested
|
||||
.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub(crate) fn reset_reflections_near_limit_reminder(&self) {
|
||||
self.reflections_near_limit_reminder_recorded
|
||||
.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
async fn maybe_record_reflections_near_limit_reminder(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
total_usage_tokens: i64,
|
||||
auto_compact_limit: i64,
|
||||
) {
|
||||
if turn_context.config.compaction_strategy != CompactionStrategyConfig::Reflections
|
||||
|| auto_compact_limit == i64::MAX
|
||||
|| total_usage_tokens >= auto_compact_limit
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let reminder_threshold =
|
||||
crate::reflections::near_limit_reminder_threshold(auto_compact_limit);
|
||||
if total_usage_tokens < reminder_threshold
|
||||
|| self
|
||||
.reflections_near_limit_reminder_recorded
|
||||
.load(Ordering::SeqCst)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let history = self.clone_history().await;
|
||||
if history
|
||||
.raw_items()
|
||||
.iter()
|
||||
.any(crate::reflections::is_near_limit_reminder)
|
||||
{
|
||||
self.reflections_near_limit_reminder_recorded
|
||||
.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
if self
|
||||
.reflections_near_limit_reminder_recorded
|
||||
.swap(true, Ordering::SeqCst)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining_tokens = auto_compact_limit.saturating_sub(total_usage_tokens).max(0);
|
||||
let shared_notes_available = turn_context.reflections_shared_notes_path.is_some()
|
||||
&& turn_context.config.reflections.storage_tools_enabled
|
||||
&& turn_context.config.reflections.shared_notes_enabled;
|
||||
let reminder = crate::reflections::near_limit_reminder(
|
||||
Some(remaining_tokens),
|
||||
turn_context.config.reflections.storage_tools_enabled,
|
||||
shared_notes_available,
|
||||
);
|
||||
self.record_conversation_items(turn_context, std::slice::from_ref(&reminder))
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn route_realtime_text_input(self: &Arc<Self>, text: String) {
|
||||
handlers::user_input_or_turn_inner(
|
||||
self,
|
||||
@@ -2615,6 +2828,7 @@ impl Session {
|
||||
session_configuration,
|
||||
updates.final_output_json_schema,
|
||||
sandbox_policy_changed,
|
||||
ReflectionsSidecarPreparation::Prepare,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
@@ -2622,11 +2836,31 @@ impl Session {
|
||||
async fn new_turn_from_configuration(
|
||||
&self,
|
||||
sub_id: String,
|
||||
session_configuration: SessionConfiguration,
|
||||
mut session_configuration: SessionConfiguration,
|
||||
final_output_json_schema: Option<Option<Value>>,
|
||||
sandbox_policy_changed: bool,
|
||||
reflections_sidecar_preparation: ReflectionsSidecarPreparation,
|
||||
) -> Arc<TurnContext> {
|
||||
let per_turn_config = Self::build_per_turn_config(&session_configuration);
|
||||
if matches!(
|
||||
reflections_sidecar_preparation,
|
||||
ReflectionsSidecarPreparation::Prepare
|
||||
) && per_turn_config.compaction_strategy == CompactionStrategyConfig::Reflections
|
||||
&& let Some(sidecar_path) = session_configuration.reflections_sidecar_path.clone()
|
||||
{
|
||||
let ensure_result = match self.try_ensure_rollout_materialized().await {
|
||||
Ok(()) => crate::reflections::ensure_sidecar_dirs(sidecar_path.as_path()).await,
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
if let Err(err) = ensure_result {
|
||||
warn!(
|
||||
"failed to prepare Reflections sidecar {}: {err}",
|
||||
sidecar_path.as_path().display()
|
||||
);
|
||||
session_configuration.reflections_sidecar_path = None;
|
||||
session_configuration.reflections_shared_notes_path = None;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
|
||||
mcp_connection_manager.set_approval_policy(&session_configuration.approval_policy);
|
||||
@@ -2798,6 +3032,7 @@ impl Session {
|
||||
session_configuration,
|
||||
/*final_output_json_schema*/ None,
|
||||
/*sandbox_policy_changed*/ false,
|
||||
ReflectionsSidecarPreparation::Skip,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -6025,6 +6260,7 @@ async fn spawn_review_thread(
|
||||
turn_metadata_state,
|
||||
turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()),
|
||||
turn_timing_state: Arc::new(TurnTimingState::default()),
|
||||
reflections_shared_notes_path: parent_turn_context.reflections_shared_notes_path.clone(),
|
||||
};
|
||||
|
||||
// Seed the child task with the review prompt as the initial user message.
|
||||
@@ -6406,6 +6642,13 @@ pub(crate) async fn run_turn(
|
||||
}
|
||||
|
||||
// Construct the input that we will send to the model.
|
||||
let total_usage_tokens = sess.get_total_token_usage().await;
|
||||
sess.maybe_record_reflections_near_limit_reminder(
|
||||
turn_context.as_ref(),
|
||||
total_usage_tokens,
|
||||
auto_compact_limit,
|
||||
)
|
||||
.await;
|
||||
let sampling_request_input: Vec<ResponseItem> = {
|
||||
sess.clone_history()
|
||||
.await
|
||||
@@ -6765,7 +7008,16 @@ async fn run_auto_compact(
|
||||
reason: CompactionReason,
|
||||
phase: CompactionPhase,
|
||||
) -> CodexResult<()> {
|
||||
if should_use_remote_compact_task(&turn_context.provider) {
|
||||
if turn_context.config.compaction_strategy == CompactionStrategyConfig::Reflections {
|
||||
crate::reflections::run_inline_reflections_auto_compact_task(
|
||||
Arc::clone(sess),
|
||||
Arc::clone(turn_context),
|
||||
initial_context_injection,
|
||||
reason,
|
||||
phase,
|
||||
)
|
||||
.await?;
|
||||
} else if should_use_remote_compact_task(&turn_context.provider) {
|
||||
run_inline_remote_auto_compact_task(
|
||||
Arc::clone(sess),
|
||||
Arc::clone(turn_context),
|
||||
@@ -7746,19 +7998,87 @@ async fn drain_in_flight(
|
||||
in_flight: &mut FuturesOrdered<BoxFuture<'static, CodexResult<ResponseInputItem>>>,
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
) -> CodexResult<()> {
|
||||
) -> CodexResult<bool> {
|
||||
while let Some(res) = in_flight.next().await {
|
||||
match res {
|
||||
Ok(response_input) => {
|
||||
sess.record_conversation_items(&turn_context, &[response_input.into()])
|
||||
.await;
|
||||
if sess.take_reflections_context_window_reset_request() {
|
||||
crate::reflections::run_reflections_compact_task_inner(
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
InitialContextInjection::DoNotInject,
|
||||
CompactionTrigger::Manual,
|
||||
CompactionReason::UserRequested,
|
||||
CompactionPhase::MidTurn,
|
||||
)
|
||||
.await?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error_or_panic(format!("in-flight tool future failed during drain: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn run_reflections_terminal_reset(
|
||||
tool_future: BoxFuture<'static, CodexResult<ResponseInputItem>>,
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
) -> CodexResult<bool> {
|
||||
let response_input = tool_future.await?;
|
||||
|
||||
if !sess.take_reflections_context_window_reset_request() {
|
||||
sess.record_conversation_items(&turn_context, &[response_input.into()])
|
||||
.await;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match crate::reflections::run_reflections_compact_task_inner(
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
InitialContextInjection::DoNotInject,
|
||||
CompactionTrigger::Manual,
|
||||
CompactionReason::UserRequested,
|
||||
CompactionPhase::MidTurn,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(true),
|
||||
Err(err) => {
|
||||
error!("reflections_new_context_window compaction failed: {err}");
|
||||
let failure_output = reflections_terminal_reset_failure_output(response_input, &err);
|
||||
sess.record_conversation_items(&turn_context, &[failure_output.into()])
|
||||
.await;
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reflections_terminal_reset_failure_output(
|
||||
response_input: ResponseInputItem,
|
||||
err: &CodexErr,
|
||||
) -> ResponseInputItem {
|
||||
let call_id = match response_input {
|
||||
ResponseInputItem::FunctionCallOutput { call_id, .. }
|
||||
| ResponseInputItem::CustomToolCallOutput { call_id, .. }
|
||||
| ResponseInputItem::McpToolCallOutput { call_id, .. }
|
||||
| ResponseInputItem::ToolSearchOutput { call_id, .. } => call_id,
|
||||
ResponseInputItem::Message { .. } => String::new(),
|
||||
};
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id,
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(format!(
|
||||
"Failed to start a fresh Reflections context window: {err}"
|
||||
)),
|
||||
success: Some(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -7892,8 +8212,37 @@ async fn try_run_sampling_request(
|
||||
Ok(output_result) => output_result,
|
||||
Err(err) => break Err(err),
|
||||
};
|
||||
if let Some(tool_future) = output_result.tool_future {
|
||||
in_flight.push_back(tool_future);
|
||||
match output_result.terminal_control {
|
||||
Some(ResponseTerminalControl::ReflectionsContextWindowReset) => {
|
||||
in_flight = FuturesOrdered::new();
|
||||
stream.close();
|
||||
client_session.reset_websocket_session();
|
||||
let reset_succeeded = if let Some(tool_future) = output_result.tool_future {
|
||||
run_reflections_terminal_reset(
|
||||
tool_future,
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if reset_succeeded {
|
||||
break Ok(SamplingRequestResult {
|
||||
needs_follow_up: true,
|
||||
last_agent_message,
|
||||
});
|
||||
}
|
||||
break Ok(SamplingRequestResult {
|
||||
needs_follow_up: true,
|
||||
last_agent_message,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
if let Some(tool_future) = output_result.tool_future {
|
||||
in_flight.push_back(tool_future);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(agent_message) = output_result.last_agent_message {
|
||||
last_agent_message = Some(agent_message);
|
||||
@@ -8087,7 +8436,10 @@ async fn try_run_sampling_request(
|
||||
)
|
||||
.await;
|
||||
|
||||
drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
|
||||
let compacted = drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
|
||||
if compacted {
|
||||
client_session.reset_websocket_session();
|
||||
}
|
||||
|
||||
if cancellation_token.is_cancelled() {
|
||||
return Err(CodexErr::TurnAborted);
|
||||
|
||||
@@ -1930,6 +1930,8 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
persist_extended_history: false,
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
@@ -2032,6 +2034,8 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
persist_extended_history: false,
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
@@ -2314,6 +2318,51 @@ async fn build_test_config(codex_home: &Path) -> Config {
|
||||
.expect("load default test config")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflections_shared_notes_path_gate_allows_thread_spawn_without_collab_tools() {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
let mut config = build_test_config(codex_home.path()).await;
|
||||
config.compaction_strategy = CompactionStrategyConfig::Reflections;
|
||||
config.reflections.storage_tools_enabled = true;
|
||||
config.reflections.shared_notes_enabled = true;
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
|
||||
assert!(!should_resolve_reflections_shared_notes_path(
|
||||
&config,
|
||||
&SessionSource::Cli
|
||||
));
|
||||
|
||||
let _ = config.features.enable(Feature::Collab);
|
||||
assert!(should_resolve_reflections_shared_notes_path(
|
||||
&config,
|
||||
&SessionSource::Cli
|
||||
));
|
||||
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
assert!(should_resolve_reflections_shared_notes_path(
|
||||
&config,
|
||||
&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: ThreadId::new(),
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})
|
||||
));
|
||||
|
||||
config.reflections.storage_tools_enabled = false;
|
||||
assert!(!should_resolve_reflections_shared_notes_path(
|
||||
&config,
|
||||
&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: ThreadId::new(),
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
fn session_telemetry(
|
||||
conversation_id: ThreadId,
|
||||
config: &Config,
|
||||
@@ -2384,6 +2433,8 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
|
||||
persist_extended_history: false,
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2558,6 +2609,53 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_configuration_apply_preserves_reflections_sidecar_write_root() {
|
||||
let mut session_configuration = make_session_configuration_for_tests().await;
|
||||
let workspace = tempfile::tempdir().expect("create temp dir");
|
||||
let project_root = workspace.path().join("project");
|
||||
let original_cwd = project_root.join("subdir");
|
||||
let sidecar_path = workspace.path().join("rollout.reflections").abs();
|
||||
std::fs::create_dir_all(&original_cwd).expect("create cwd");
|
||||
|
||||
session_configuration.cwd = original_cwd.abs();
|
||||
session_configuration.reflections_sidecar_path = Some(sidecar_path.clone());
|
||||
session_configuration.sandbox_policy =
|
||||
codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
});
|
||||
session_configuration.file_system_sandbox_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
session_configuration.sandbox_policy.get(),
|
||||
&session_configuration.cwd,
|
||||
);
|
||||
|
||||
let updated = session_configuration
|
||||
.apply(&SessionSettingsUpdate {
|
||||
cwd: Some(project_root),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("cwd-only update should succeed");
|
||||
|
||||
assert_eq!(
|
||||
updated
|
||||
.file_system_sandbox_policy
|
||||
.resolve_access_with_cwd(sidecar_path.as_path(), updated.cwd.as_path()),
|
||||
FileSystemAccessMode::Write
|
||||
);
|
||||
let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = updated.sandbox_policy.get() else {
|
||||
panic!("expected workspace-write sandbox policy");
|
||||
};
|
||||
assert!(writable_roots.contains(&sidecar_path));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_update_settings_keeps_runtime_cwds_absolute() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -2647,6 +2745,8 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
persist_extended_history: false,
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
|
||||
let (tx_event, _rx_event) = async_channel::unbounded();
|
||||
@@ -2751,6 +2851,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
persist_extended_history: false,
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
let per_turn_config = Session::build_per_turn_config(&session_configuration);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
@@ -2884,6 +2986,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
reflections_context_window_reset_requested: AtomicBool::new(false),
|
||||
reflections_near_limit_reminder_recorded: AtomicBool::new(false),
|
||||
};
|
||||
|
||||
(session, turn_context)
|
||||
@@ -3597,6 +3701,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
persist_extended_history: false,
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
reflections_sidecar_path: None,
|
||||
reflections_shared_notes_path: None,
|
||||
};
|
||||
let per_turn_config = Session::build_per_turn_config(&session_configuration);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
@@ -3730,6 +3836,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
reflections_context_window_reset_requested: AtomicBool::new(false),
|
||||
reflections_near_limit_reminder_recorded: AtomicBool::new(false),
|
||||
});
|
||||
|
||||
(session, turn_context, rx_event)
|
||||
@@ -5197,6 +5305,94 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflections_new_context_window_marks_terminal_control() {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.tools_config.reflections = true;
|
||||
let sess = Arc::new(session);
|
||||
let tc = Arc::new(turn_context);
|
||||
|
||||
let item = ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: codex_tools::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME.to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-reflections-reset".to_string(),
|
||||
};
|
||||
let mut ctx = HandleOutputCtx {
|
||||
sess: Arc::clone(&sess),
|
||||
turn_context: Arc::clone(&tc),
|
||||
tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
};
|
||||
|
||||
let output = handle_output_item_done(&mut ctx, item, /*previously_active_item*/ None)
|
||||
.await
|
||||
.expect("reflections reset tool call should be handled");
|
||||
|
||||
assert!(output.needs_follow_up);
|
||||
assert!(output.tool_future.is_some());
|
||||
assert_eq!(
|
||||
output.terminal_control,
|
||||
Some(crate::stream_events_utils::ResponseTerminalControl::ReflectionsContextWindowReset)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflections_terminal_reset_compaction_failure_allows_same_context_continuation() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
sess.request_reflections_context_window_reset();
|
||||
|
||||
let tool_future: BoxFuture<'static, CodexResult<ResponseInputItem>> = Box::pin(async {
|
||||
Ok(ResponseInputItem::FunctionCallOutput {
|
||||
call_id: "call-reflections-reset".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(
|
||||
"A fresh Reflections context window will start after this tool result is recorded."
|
||||
.to_string(),
|
||||
),
|
||||
success: Some(true),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
let compacted = run_reflections_terminal_reset(tool_future, Arc::clone(&sess), Arc::clone(&tc))
|
||||
.await
|
||||
.expect("compaction failure should be surfaced without failing the sampling loop");
|
||||
|
||||
assert!(!compacted);
|
||||
assert!(!sess.take_reflections_context_window_reset_request());
|
||||
let history = sess.clone_history().await;
|
||||
assert!(
|
||||
history.raw_items().iter().any(|item| {
|
||||
let ResponseItem::FunctionCallOutput { call_id, output } = item else {
|
||||
return false;
|
||||
};
|
||||
call_id == "call-reflections-reset"
|
||||
&& output.success == Some(false)
|
||||
&& matches!(
|
||||
&output.body,
|
||||
FunctionCallOutputBody::Text(text)
|
||||
if text.contains("Failed to start a fresh Reflections context window")
|
||||
)
|
||||
}),
|
||||
"compaction failure should be recorded as a failed tool output"
|
||||
);
|
||||
tokio::time::timeout(StdDuration::from_secs(2), async {
|
||||
loop {
|
||||
let event = rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("expected reflections compaction error event");
|
||||
if matches!(event.msg, EventMsg::Error(_)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for reflections compaction error event");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
@@ -300,6 +300,7 @@ pub(crate) struct CompactionAnalyticsAttempt {
|
||||
reason: CompactionReason,
|
||||
implementation: CompactionImplementation,
|
||||
phase: CompactionPhase,
|
||||
strategy: CompactionStrategy,
|
||||
active_context_tokens_before: i64,
|
||||
started_at: u64,
|
||||
start_instant: Instant,
|
||||
@@ -313,6 +314,27 @@ impl CompactionAnalyticsAttempt {
|
||||
reason: CompactionReason,
|
||||
implementation: CompactionImplementation,
|
||||
phase: CompactionPhase,
|
||||
) -> Self {
|
||||
Self::begin_with_strategy(
|
||||
sess,
|
||||
turn_context,
|
||||
trigger,
|
||||
reason,
|
||||
implementation,
|
||||
phase,
|
||||
CompactionStrategy::Memento,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn begin_with_strategy(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
trigger: CompactionTrigger,
|
||||
reason: CompactionReason,
|
||||
implementation: CompactionImplementation,
|
||||
phase: CompactionPhase,
|
||||
strategy: CompactionStrategy,
|
||||
) -> Self {
|
||||
let enabled = sess.enabled(Feature::GeneralAnalytics);
|
||||
let active_context_tokens_before = sess.get_total_token_usage().await;
|
||||
@@ -324,6 +346,7 @@ impl CompactionAnalyticsAttempt {
|
||||
reason,
|
||||
implementation,
|
||||
phase,
|
||||
strategy,
|
||||
active_context_tokens_before,
|
||||
started_at: now_unix_seconds(),
|
||||
start_instant: Instant::now(),
|
||||
@@ -349,7 +372,7 @@ impl CompactionAnalyticsAttempt {
|
||||
reason: self.reason,
|
||||
implementation: self.implementation,
|
||||
phase: self.phase,
|
||||
strategy: CompactionStrategy::Memento,
|
||||
strategy: self.strategy,
|
||||
status,
|
||||
error,
|
||||
active_context_tokens_before: self.active_context_tokens_before,
|
||||
|
||||
@@ -48,6 +48,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_protocol::config_types::CompactionStrategyConfig;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
@@ -4635,6 +4636,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
compaction_strategy: CompactionStrategyConfig::Default,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
@@ -4645,6 +4647,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
|
||||
ghost_snapshot: GhostSnapshotConfig::default(),
|
||||
multi_agent_v2: MultiAgentV2Config::default(),
|
||||
reflections: ReflectionsConfig::default(),
|
||||
features: Features::with_defaults().into(),
|
||||
suppress_unstable_features_warning: false,
|
||||
active_profile: Some("o3".to_string()),
|
||||
@@ -4784,6 +4787,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
compaction_strategy: CompactionStrategyConfig::Default,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
@@ -4794,6 +4798,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
|
||||
ghost_snapshot: GhostSnapshotConfig::default(),
|
||||
multi_agent_v2: MultiAgentV2Config::default(),
|
||||
reflections: ReflectionsConfig::default(),
|
||||
features: Features::with_defaults().into(),
|
||||
suppress_unstable_features_warning: false,
|
||||
active_profile: Some("gpt3".to_string()),
|
||||
@@ -4931,6 +4936,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
compaction_strategy: CompactionStrategyConfig::Default,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
@@ -4941,6 +4947,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
|
||||
ghost_snapshot: GhostSnapshotConfig::default(),
|
||||
multi_agent_v2: MultiAgentV2Config::default(),
|
||||
reflections: ReflectionsConfig::default(),
|
||||
features: Features::with_defaults().into(),
|
||||
suppress_unstable_features_warning: false,
|
||||
active_profile: Some("zdr".to_string()),
|
||||
@@ -5064,6 +5071,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
compaction_strategy: CompactionStrategyConfig::Default,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
@@ -5074,6 +5082,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
|
||||
ghost_snapshot: GhostSnapshotConfig::default(),
|
||||
multi_agent_v2: MultiAgentV2Config::default(),
|
||||
reflections: ReflectionsConfig::default(),
|
||||
features: Features::with_defaults().into(),
|
||||
suppress_unstable_features_warning: false,
|
||||
active_profile: Some("gpt5".to_string()),
|
||||
@@ -6297,6 +6306,112 @@ hide_spawn_agent_metadata = false
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflections_config_from_strategy_and_feature_table() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"compaction_strategy = "reflections"
|
||||
|
||||
[features.reflections]
|
||||
usage_hint_enabled = false
|
||||
usage_hint_text = "Custom recovery guidance."
|
||||
storage_tools_enabled = false
|
||||
shared_notes_enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.compaction_strategy,
|
||||
CompactionStrategyConfig::Reflections
|
||||
);
|
||||
assert!(!config.reflections.usage_hint_enabled);
|
||||
assert_eq!(
|
||||
config.reflections.usage_hint_text.as_deref(),
|
||||
Some("Custom recovery guidance.")
|
||||
);
|
||||
assert!(!config.reflections.storage_tools_enabled);
|
||||
assert!(!config.reflections.shared_notes_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reflections_config_defaults_enable_storage_tools() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"compaction_strategy = "reflections"
|
||||
|
||||
[features.reflections]
|
||||
usage_hint_enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.reflections.storage_tools_enabled);
|
||||
assert!(config.reflections.shared_notes_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_reflections_config_overrides_base() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"profile = "default_compaction"
|
||||
compaction_strategy = "reflections"
|
||||
|
||||
[features.reflections]
|
||||
usage_hint_enabled = true
|
||||
usage_hint_text = "base hint"
|
||||
storage_tools_enabled = true
|
||||
shared_notes_enabled = true
|
||||
|
||||
[profiles.default_compaction]
|
||||
compaction_strategy = "default"
|
||||
|
||||
[profiles.default_compaction.features.reflections]
|
||||
usage_hint_enabled = false
|
||||
usage_hint_text = "profile hint"
|
||||
storage_tools_enabled = false
|
||||
shared_notes_enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.compaction_strategy,
|
||||
CompactionStrategyConfig::Default
|
||||
);
|
||||
assert!(!config.reflections.usage_hint_enabled);
|
||||
assert_eq!(
|
||||
config.reflections.usage_hint_text.as_deref(),
|
||||
Some("profile hint")
|
||||
);
|
||||
assert!(!config.reflections.storage_tools_enabled);
|
||||
assert!(!config.reflections.shared_notes_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -55,6 +55,7 @@ use codex_features::FeatureToml;
|
||||
use codex_features::Features;
|
||||
use codex_features::FeaturesToml;
|
||||
use codex_features::MultiAgentV2ConfigToml;
|
||||
use codex_features::ReflectionsConfigToml;
|
||||
use codex_login::AuthManagerConfig;
|
||||
use codex_mcp::McpConfig;
|
||||
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
|
||||
@@ -63,6 +64,7 @@ use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
|
||||
use codex_model_provider_info::built_in_model_providers;
|
||||
use codex_models_manager::ModelsManagerConfig;
|
||||
use codex_protocol::config_types::AltScreenMode;
|
||||
use codex_protocol::config_types::CompactionStrategyConfig;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
@@ -550,9 +552,15 @@ pub struct Config {
|
||||
/// Settings for ghost snapshots (used for undo).
|
||||
pub ghost_snapshot: GhostSnapshotConfig,
|
||||
|
||||
/// Strategy used when compacting conversation history.
|
||||
pub compaction_strategy: CompactionStrategyConfig,
|
||||
|
||||
/// Settings specific to the task-path-based multi-agent tool surface.
|
||||
pub multi_agent_v2: MultiAgentV2Config,
|
||||
|
||||
/// Settings for the Reflections compaction strategy.
|
||||
pub reflections: ReflectionsConfig,
|
||||
|
||||
/// Centralized feature flags; source of truth for feature gating.
|
||||
pub features: ManagedFeatures,
|
||||
|
||||
@@ -614,6 +622,25 @@ impl Default for MultiAgentV2Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReflectionsConfig {
|
||||
pub usage_hint_enabled: bool,
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub storage_tools_enabled: bool,
|
||||
pub shared_notes_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for ReflectionsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
usage_hint_enabled: true,
|
||||
usage_hint_text: None,
|
||||
storage_tools_enabled: true,
|
||||
shared_notes_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthManagerConfig for Config {
|
||||
fn codex_home(&self) -> PathBuf {
|
||||
self.codex_home.to_path_buf()
|
||||
@@ -1375,6 +1402,44 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_reflections_config(
|
||||
config_toml: &ConfigToml,
|
||||
config_profile: &ConfigProfile,
|
||||
) -> ReflectionsConfig {
|
||||
let base = reflections_toml_config(config_toml.features.as_ref());
|
||||
let profile = reflections_toml_config(config_profile.features.as_ref());
|
||||
let default = ReflectionsConfig::default();
|
||||
|
||||
let usage_hint_enabled = profile
|
||||
.and_then(|config| config.usage_hint_enabled)
|
||||
.or_else(|| base.and_then(|config| config.usage_hint_enabled))
|
||||
.unwrap_or(default.usage_hint_enabled);
|
||||
let usage_hint_text = profile
|
||||
.and_then(|config| config.usage_hint_text.as_ref())
|
||||
.or_else(|| base.and_then(|config| config.usage_hint_text.as_ref()))
|
||||
.cloned()
|
||||
.or(default.usage_hint_text);
|
||||
let storage_tools_enabled = profile
|
||||
.and_then(|config| config.storage_tools_enabled)
|
||||
.or_else(|| base.and_then(|config| config.storage_tools_enabled))
|
||||
.unwrap_or(default.storage_tools_enabled);
|
||||
let shared_notes_enabled = profile
|
||||
.and_then(|config| config.shared_notes_enabled)
|
||||
.or_else(|| base.and_then(|config| config.shared_notes_enabled))
|
||||
.unwrap_or(default.shared_notes_enabled);
|
||||
|
||||
ReflectionsConfig {
|
||||
usage_hint_enabled,
|
||||
usage_hint_text,
|
||||
storage_tools_enabled,
|
||||
shared_notes_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn reflections_toml_config(features: Option<&FeaturesToml>) -> Option<&ReflectionsConfigToml> {
|
||||
features?.reflections.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_web_search_mode_for_turn(
|
||||
web_search_mode: &Constrained<WebSearchMode>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
@@ -1706,6 +1771,11 @@ impl Config {
|
||||
.unwrap_or(WebSearchMode::Cached);
|
||||
let web_search_config = resolve_web_search_config(&cfg, &config_profile);
|
||||
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile);
|
||||
let reflections = resolve_reflections_config(&cfg, &config_profile);
|
||||
let compaction_strategy = config_profile
|
||||
.compaction_strategy
|
||||
.or(cfg.compaction_strategy)
|
||||
.unwrap_or_default();
|
||||
|
||||
let agent_roles =
|
||||
agent_roles::load_agent_roles(&cfg, &config_layer_stack, &mut startup_warnings)?;
|
||||
@@ -2146,7 +2216,9 @@ impl Config {
|
||||
use_experimental_unified_exec_tool,
|
||||
background_terminal_max_timeout,
|
||||
ghost_snapshot,
|
||||
compaction_strategy,
|
||||
multi_agent_v2,
|
||||
reflections,
|
||||
features,
|
||||
suppress_unstable_features_warning: cfg
|
||||
.suppress_unstable_features_warning
|
||||
|
||||
@@ -199,6 +199,7 @@ pub use file_watcher::FileWatcherEvent;
|
||||
pub use turn_metadata::build_turn_metadata_header;
|
||||
pub mod compact;
|
||||
pub(crate) mod memory_trace;
|
||||
pub(crate) mod reflections;
|
||||
pub use memory_trace::BuiltMemory;
|
||||
pub use memory_trace::build_memories_from_trace_files;
|
||||
pub mod otel_init;
|
||||
|
||||
704
codex-rs/core/src/reflections/log_entries.rs
Normal file
704
codex-rs/core/src/reflections/log_entries.rs
Normal file
@@ -0,0 +1,704 @@
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_tools::REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_LIST_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub(crate) struct LogEntry {
|
||||
pub(crate) entry_id: String,
|
||||
pub(crate) kind: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) role: Option<String>,
|
||||
pub(crate) content: String,
|
||||
pub(crate) metadata: Value,
|
||||
}
|
||||
|
||||
pub(crate) fn log_entries_from_items(window: &str, items: &[RolloutItem]) -> Vec<LogEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut call_tools = std::collections::HashMap::new();
|
||||
for item in items {
|
||||
let entry = match item {
|
||||
RolloutItem::EventMsg(event) => log_entry_from_event(event),
|
||||
RolloutItem::ResponseItem(response_item) => {
|
||||
log_entry_from_response_item(response_item, &mut call_tools)
|
||||
}
|
||||
RolloutItem::SessionMeta(_)
|
||||
| RolloutItem::Compacted(_)
|
||||
| RolloutItem::TurnContext(_) => None,
|
||||
};
|
||||
if let Some(mut entry) = entry {
|
||||
let entry_index = entries.len() + 1;
|
||||
entry.entry_id = entry_id(window, entry_index);
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn log_entry_from_event(event: &EventMsg) -> Option<LogEntry> {
|
||||
match event {
|
||||
EventMsg::UserMessage(event) => {
|
||||
let mut text = event.message.clone();
|
||||
if let Some(images) = event.images.as_ref().filter(|images| !images.is_empty()) {
|
||||
push_blank_line_if_needed(&mut text);
|
||||
text.push_str("images:\n");
|
||||
for image in images {
|
||||
text.push_str(&format!("- {image}\n"));
|
||||
}
|
||||
}
|
||||
if !event.local_images.is_empty() {
|
||||
push_blank_line_if_needed(&mut text);
|
||||
text.push_str("local_images:\n");
|
||||
for image in &event.local_images {
|
||||
text.push_str(&format!("- {}\n", image.display()));
|
||||
}
|
||||
}
|
||||
Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "user_message".to_string(),
|
||||
role: Some("user".to_string()),
|
||||
content: text,
|
||||
metadata: json!({}),
|
||||
})
|
||||
}
|
||||
EventMsg::AgentMessage(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "assistant_message".to_string(),
|
||||
role: Some("assistant".to_string()),
|
||||
content: event.message.clone(),
|
||||
metadata: json!({
|
||||
"phase": event.phase.as_ref().map(|phase| format!("{phase:?}").to_lowercase()),
|
||||
}),
|
||||
}),
|
||||
EventMsg::McpToolCallBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"arguments": event.invocation.arguments,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({
|
||||
"tool_name": format!("mcp.{}.{}", event.invocation.server, event.invocation.tool),
|
||||
}),
|
||||
}),
|
||||
EventMsg::McpToolCallEnd(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"arguments": event.invocation.arguments,
|
||||
"success": event.is_success(),
|
||||
"result": event.result,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({
|
||||
"tool_name": format!("mcp.{}.{}", event.invocation.server, event.invocation.tool),
|
||||
}),
|
||||
}),
|
||||
EventMsg::WebSearchBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(event).unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "web_search" }),
|
||||
}),
|
||||
EventMsg::WebSearchEnd(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"query": event.query,
|
||||
"action": event.action,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "web_search" }),
|
||||
}),
|
||||
EventMsg::ImageGenerationBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(event).unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "image_generation" }),
|
||||
}),
|
||||
EventMsg::ImageGenerationEnd(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"status": event.status,
|
||||
"revised_prompt": event.revised_prompt,
|
||||
"result": event.result,
|
||||
"saved_path": event.saved_path.as_ref().map(|path| path.display().to_string()),
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "image_generation" }),
|
||||
}),
|
||||
EventMsg::ExecCommandBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: format!(
|
||||
"call_id: {}\ncwd: {}\ncommand: {}\n",
|
||||
event.call_id,
|
||||
event.cwd.display(),
|
||||
serde_json::to_string(&event.command).unwrap_or_else(|_| "[]".to_string())
|
||||
),
|
||||
metadata: json!({ "tool_name": "exec_command" }),
|
||||
}),
|
||||
EventMsg::ExecCommandEnd(event) => {
|
||||
let mut content = format!(
|
||||
"call_id: {}\nstatus: {:?}\nexit_code: {}\ncwd: {}\ncommand: {}\n",
|
||||
event.call_id,
|
||||
event.status,
|
||||
event.exit_code,
|
||||
event.cwd.display(),
|
||||
serde_json::to_string(&event.command).unwrap_or_else(|_| "[]".to_string())
|
||||
);
|
||||
let output = exec_output(event);
|
||||
if !output.is_empty() {
|
||||
content.push_str("\noutput:\n");
|
||||
content.push_str(&output);
|
||||
}
|
||||
Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content,
|
||||
metadata: json!({ "tool_name": "exec_command" }),
|
||||
})
|
||||
}
|
||||
EventMsg::ViewImageToolCall(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"path": event.path.display().to_string(),
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "view_image" }),
|
||||
}),
|
||||
EventMsg::DynamicToolCallRequest(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: dynamic_tool_call_content(&event.tool, &event.call_id, &event.arguments),
|
||||
metadata: json!({ "tool_name": event.tool }),
|
||||
}),
|
||||
EventMsg::DynamicToolCallResponse(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: dynamic_tool_response_content(
|
||||
&event.tool,
|
||||
&event.call_id,
|
||||
event.success,
|
||||
event.error.as_deref(),
|
||||
&event.content_items,
|
||||
&event.arguments,
|
||||
),
|
||||
metadata: json!({ "tool_name": event.tool }),
|
||||
}),
|
||||
EventMsg::PatchApplyBegin(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"auto_approved": event.auto_approved,
|
||||
"changes": event.changes,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "apply_patch" }),
|
||||
}),
|
||||
EventMsg::PatchApplyEnd(event) => Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: serde_json::to_string_pretty(&json!({
|
||||
"call_id": event.call_id,
|
||||
"success": event.success,
|
||||
"status": event.status,
|
||||
"stdout": event.stdout,
|
||||
"stderr": event.stderr,
|
||||
"changes": event.changes,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
metadata: json!({ "tool_name": "apply_patch" }),
|
||||
}),
|
||||
EventMsg::Error(_)
|
||||
| EventMsg::Warning(_)
|
||||
| EventMsg::RealtimeConversationStarted(_)
|
||||
| EventMsg::RealtimeConversationRealtime(_)
|
||||
| EventMsg::RealtimeConversationClosed(_)
|
||||
| EventMsg::RealtimeConversationSdp(_)
|
||||
| EventMsg::ModelReroute(_)
|
||||
| EventMsg::ContextCompacted(_)
|
||||
| EventMsg::ThreadRolledBack(_)
|
||||
| EventMsg::TurnStarted(_)
|
||||
| EventMsg::TurnComplete(_)
|
||||
| EventMsg::TokenCount(_)
|
||||
| EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoning(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::AgentReasoningRawContent(_)
|
||||
| EventMsg::AgentReasoningRawContentDelta(_)
|
||||
| EventMsg::AgentReasoningSectionBreak(_)
|
||||
| EventMsg::SessionConfigured(_)
|
||||
| EventMsg::ThreadNameUpdated(_)
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::RequestPermissions(_)
|
||||
| EventMsg::RequestUserInput(_)
|
||||
| EventMsg::ElicitationRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::GuardianAssessment(_)
|
||||
| EventMsg::DeprecationNotice(_)
|
||||
| EventMsg::BackgroundEvent(_)
|
||||
| EventMsg::UndoStarted(_)
|
||||
| EventMsg::UndoCompleted(_)
|
||||
| EventMsg::StreamError(_)
|
||||
| EventMsg::TurnDiff(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::RealtimeConversationListVoicesResponse(_)
|
||||
| EventMsg::SkillsUpdateAvailable
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::TurnAborted(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::ItemStarted(_)
|
||||
| EventMsg::ItemCompleted(_)
|
||||
| EventMsg::HookStarted(_)
|
||||
| EventMsg::HookCompleted(_)
|
||||
| EventMsg::AgentMessageContentDelta(_)
|
||||
| EventMsg::PlanDelta(_)
|
||||
| EventMsg::ReasoningContentDelta(_)
|
||||
| EventMsg::ReasoningRawContentDelta(_)
|
||||
| EventMsg::CollabAgentSpawnBegin(_)
|
||||
| EventMsg::CollabAgentSpawnEnd(_)
|
||||
| EventMsg::CollabAgentInteractionBegin(_)
|
||||
| EventMsg::CollabAgentInteractionEnd(_)
|
||||
| EventMsg::CollabWaitingBegin(_)
|
||||
| EventMsg::CollabWaitingEnd(_)
|
||||
| EventMsg::CollabCloseBegin(_)
|
||||
| EventMsg::CollabCloseEnd(_)
|
||||
| EventMsg::CollabResumeBegin(_)
|
||||
| EventMsg::CollabResumeEnd(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_entry_from_response_item(
|
||||
item: &ResponseItem,
|
||||
call_tools: &mut std::collections::HashMap<String, String>,
|
||||
) -> Option<LogEntry> {
|
||||
match item {
|
||||
ResponseItem::FunctionCall {
|
||||
name,
|
||||
arguments,
|
||||
call_id,
|
||||
namespace,
|
||||
..
|
||||
} if is_reflections_storage_tool(name) => {
|
||||
call_tools.insert(call_id.clone(), name.clone());
|
||||
let arguments_json = serde_json::from_str(arguments).unwrap_or(Value::Null);
|
||||
Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_call".to_string(),
|
||||
role: None,
|
||||
content: reflections_storage_tool_call_summary(name, call_id, &arguments_json),
|
||||
metadata: json!({
|
||||
"tool_name": name,
|
||||
"namespace": namespace,
|
||||
}),
|
||||
})
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { call_id, output }
|
||||
if call_tools
|
||||
.get(call_id)
|
||||
.is_some_and(|tool_name| is_reflections_storage_tool(tool_name)) =>
|
||||
{
|
||||
let tool_name = call_tools.get(call_id).cloned().unwrap_or_default();
|
||||
Some(LogEntry {
|
||||
entry_id: String::new(),
|
||||
kind: "tool_result".to_string(),
|
||||
role: None,
|
||||
content: reflections_storage_tool_output_summary(&tool_name, call_id, output),
|
||||
metadata: json!({
|
||||
"tool_name": tool_name,
|
||||
"success": output.success,
|
||||
}),
|
||||
})
|
||||
}
|
||||
ResponseItem::Message { .. }
|
||||
| ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_blank_line_if_needed(text: &mut String) {
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn exec_output(event: &codex_protocol::protocol::ExecCommandEndEvent) -> String {
|
||||
if !event.aggregated_output.is_empty() {
|
||||
return event.aggregated_output.clone();
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
if !event.stdout.is_empty() {
|
||||
output.push_str("stdout:\n");
|
||||
output.push_str(&event.stdout);
|
||||
if !event.stdout.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
if !event.stderr.is_empty() {
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str("stderr:\n");
|
||||
output.push_str(&event.stderr);
|
||||
if !event.stderr.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub(super) fn dynamic_tool_output_items_to_text(
|
||||
items: &[DynamicToolCallOutputContentItem],
|
||||
) -> String {
|
||||
let mut pieces = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
DynamicToolCallOutputContentItem::InputText { text } => pieces.push(text.clone()),
|
||||
DynamicToolCallOutputContentItem::InputImage { .. } => {
|
||||
pieces.push("[image omitted from Reflections transcript]".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
pieces.join("\n")
|
||||
}
|
||||
|
||||
fn dynamic_tool_call_content(tool: &str, call_id: &str, arguments: &Value) -> String {
|
||||
let value = if is_reflections_storage_tool(tool) {
|
||||
reflections_storage_tool_call_metadata(tool, call_id, arguments)
|
||||
} else {
|
||||
json!({
|
||||
"call_id": call_id,
|
||||
"arguments": arguments,
|
||||
})
|
||||
};
|
||||
serde_json::to_string_pretty(&value).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn dynamic_tool_response_content(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
content_items: &[DynamicToolCallOutputContentItem],
|
||||
arguments: &Value,
|
||||
) -> String {
|
||||
if is_reflections_storage_tool(tool) {
|
||||
return serde_json::to_string_pretty(&reflections_storage_tool_response_metadata(
|
||||
tool,
|
||||
call_id,
|
||||
success,
|
||||
error,
|
||||
content_items,
|
||||
arguments,
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
format!(
|
||||
"call_id: {call_id}\nsuccess: {success}\nerror: {}\n\n{}",
|
||||
error.unwrap_or(""),
|
||||
dynamic_tool_output_items_to_text(content_items)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn is_reflections_storage_tool(tool: &str) -> bool {
|
||||
matches!(
|
||||
tool,
|
||||
REFLECTIONS_LIST_TOOL_NAME
|
||||
| REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME
|
||||
| REFLECTIONS_READ_TOOL_NAME
|
||||
| REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME
|
||||
| REFLECTIONS_SEARCH_TOOL_NAME
|
||||
| REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME
|
||||
| REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME
|
||||
| REFLECTIONS_WRITE_NOTE_TOOL_NAME
|
||||
)
|
||||
}
|
||||
|
||||
fn reflections_storage_tool_call_summary(tool: &str, call_id: &str, arguments: &Value) -> String {
|
||||
serde_json::to_string_pretty(&reflections_storage_tool_call_metadata(
|
||||
tool, call_id, arguments,
|
||||
))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn reflections_storage_tool_output_summary(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
output: &FunctionCallOutputPayload,
|
||||
) -> String {
|
||||
let text = output.body.to_text().unwrap_or_default();
|
||||
let parsed = serde_json::from_str::<Value>(&text).unwrap_or(Value::Null);
|
||||
serde_json::to_string_pretty(&reflections_storage_output_value_metadata(
|
||||
tool,
|
||||
call_id,
|
||||
output.success.unwrap_or(true),
|
||||
None,
|
||||
&parsed,
|
||||
))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn reflections_storage_tool_call_metadata(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
arguments: &Value,
|
||||
) -> Value {
|
||||
match tool {
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME | REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": arguments.get("note_id"),
|
||||
"operation": arguments.get("operation"),
|
||||
"content_chars": arguments
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::chars)
|
||||
.map(Iterator::count)
|
||||
.unwrap_or(0),
|
||||
}),
|
||||
REFLECTIONS_READ_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"kind": arguments.get("kind"),
|
||||
"id": arguments.get("id"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": arguments.get("note_id"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_LIST_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"collection": arguments.get("collection"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_SEARCH_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"scope": arguments.get("scope"),
|
||||
"query": arguments.get("query"),
|
||||
"log_id": arguments.get("log_id"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"query": arguments.get("query"),
|
||||
"start": arguments.get("start"),
|
||||
"stop": arguments.get("stop"),
|
||||
}),
|
||||
_ => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"arguments": arguments,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reflections_storage_tool_response_metadata(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
content_items: &[DynamicToolCallOutputContentItem],
|
||||
arguments: &Value,
|
||||
) -> Value {
|
||||
let text = dynamic_tool_output_items_to_text(content_items);
|
||||
let parsed = serde_json::from_str::<Value>(&text).unwrap_or(Value::Null);
|
||||
let mut metadata =
|
||||
reflections_storage_output_value_metadata(tool, call_id, success, error, &parsed);
|
||||
if metadata.get("kind").is_none()
|
||||
&& let Some(kind) = arguments.get("kind")
|
||||
{
|
||||
metadata["kind"] = kind.clone();
|
||||
}
|
||||
if metadata.get("id").is_none()
|
||||
&& let Some(id) = arguments.get("id")
|
||||
{
|
||||
metadata["id"] = id.clone();
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
fn reflections_storage_output_value_metadata(
|
||||
tool: &str,
|
||||
call_id: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
value: &Value,
|
||||
) -> Value {
|
||||
match tool {
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME | REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": value.get("id"),
|
||||
"operation": value.get("operation"),
|
||||
"content_chars": value.get("content_chars"),
|
||||
"total_content_chars": value.get("total_content_chars"),
|
||||
"line_count": value.get("line_count"),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_READ_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"kind": value.get("kind"),
|
||||
"id": value.get("id"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"returned_entries": value
|
||||
.get("entries")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
"content_chars": value.get("content_chars"),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"note_id": value.get("id"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"content_chars": value.get("content_chars"),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_LIST_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"collection": value.get("collection"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"returned_items": value
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"collection": value.get("collection"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"returned_items": value
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_SEARCH_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"scope": value.get("scope"),
|
||||
"query": value.get("query"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"returned_results": value
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"scope": value.get("scope"),
|
||||
"query": value.get("query"),
|
||||
"start": value.pointer("/range/start"),
|
||||
"stop": value.pointer("/range/stop"),
|
||||
"returned_results": value
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
_ => json!({
|
||||
"tool_name": tool,
|
||||
"call_id": call_id,
|
||||
"success": success,
|
||||
"error": error,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_id(window: &str, index: usize) -> String {
|
||||
format!("{window}:msg-{index:06}")
|
||||
}
|
||||
234
codex-rs/core/src/reflections/mod.rs
Normal file
234
codex-rs/core/src/reflections/mod.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
mod log_entries;
|
||||
mod prompt;
|
||||
mod storage;
|
||||
mod storage_tools;
|
||||
mod transcript;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::RolloutRecorder;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::compact::CompactionAnalyticsAttempt;
|
||||
use crate::compact::InitialContextInjection;
|
||||
use crate::compact::compaction_status_from_result;
|
||||
use crate::compact::insert_initial_context_before_last_real_user_or_summary;
|
||||
use codex_analytics::CompactionImplementation;
|
||||
use codex_analytics::CompactionPhase;
|
||||
use codex_analytics::CompactionReason;
|
||||
use codex_analytics::CompactionStrategy;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::items::ContextCompactionItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
|
||||
pub(crate) use prompt::is_near_limit_reminder;
|
||||
pub(crate) use prompt::near_limit_reminder;
|
||||
pub(crate) use prompt::near_limit_reminder_threshold;
|
||||
pub(crate) use prompt::usage_hint;
|
||||
pub(crate) use storage::ensure_sidecar_dirs;
|
||||
pub(crate) use storage::resolve_reflections_shared_notes_path;
|
||||
pub(crate) use storage::sidecar_path_for_rollout;
|
||||
pub(crate) use storage_tools::StorageToolError;
|
||||
pub(crate) use storage_tools::list_logs;
|
||||
pub(crate) use storage_tools::list_notes;
|
||||
pub(crate) use storage_tools::list_shared_notes;
|
||||
pub(crate) use storage_tools::read_log;
|
||||
pub(crate) use storage_tools::read_note;
|
||||
pub(crate) use storage_tools::read_shared_note;
|
||||
pub(crate) use storage_tools::search;
|
||||
pub(crate) use storage_tools::search_shared_notes;
|
||||
pub(crate) use storage_tools::write_note;
|
||||
pub(crate) use storage_tools::write_shared_note;
|
||||
|
||||
pub(crate) async fn run_reflections_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
) -> CodexResult<()> {
|
||||
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
|
||||
model_context_window: turn_context.model_context_window(),
|
||||
collaboration_mode_kind: turn_context.collaboration_mode.mode,
|
||||
});
|
||||
sess.send_event(&turn_context, start_event).await;
|
||||
|
||||
run_reflections_compact_task_inner(
|
||||
sess,
|
||||
turn_context,
|
||||
InitialContextInjection::DoNotInject,
|
||||
CompactionTrigger::Manual,
|
||||
CompactionReason::UserRequested,
|
||||
CompactionPhase::StandaloneTurn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_inline_reflections_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
reason: CompactionReason,
|
||||
phase: CompactionPhase,
|
||||
) -> CodexResult<()> {
|
||||
run_reflections_compact_task_inner(
|
||||
sess,
|
||||
turn_context,
|
||||
initial_context_injection,
|
||||
CompactionTrigger::Auto,
|
||||
reason,
|
||||
phase,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_reflections_compact_task_inner(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
trigger: CompactionTrigger,
|
||||
reason: CompactionReason,
|
||||
phase: CompactionPhase,
|
||||
) -> CodexResult<()> {
|
||||
let attempt = CompactionAnalyticsAttempt::begin_with_strategy(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
trigger,
|
||||
reason,
|
||||
CompactionImplementation::Reflections,
|
||||
phase,
|
||||
CompactionStrategy::Reflections,
|
||||
)
|
||||
.await;
|
||||
let result = run_reflections_compact_task_inner_impl(
|
||||
Arc::clone(&sess),
|
||||
Arc::clone(&turn_context),
|
||||
initial_context_injection,
|
||||
trigger,
|
||||
)
|
||||
.await;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
compaction_status_from_result(&result),
|
||||
result.as_ref().err().map(ToString::to_string),
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = &result {
|
||||
let event = EventMsg::Error(
|
||||
err.to_error_event(Some("Error running reflections compaction".to_string())),
|
||||
);
|
||||
sess.send_event(&turn_context, event).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_reflections_compact_task_inner_impl(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
trigger: CompactionTrigger,
|
||||
) -> CodexResult<()> {
|
||||
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
|
||||
sess.emit_turn_item_started(&turn_context, &compaction_item)
|
||||
.await;
|
||||
|
||||
let window = write_current_window(&sess, &turn_context, trigger).await?;
|
||||
let handoff = prompt::post_compaction_handoff(
|
||||
turn_context.model_context_window(),
|
||||
&window.logs_path,
|
||||
&window.notes_path,
|
||||
turn_context.config.reflections.storage_tools_enabled,
|
||||
turn_context.reflections_shared_notes_path.is_some()
|
||||
&& turn_context.config.reflections.storage_tools_enabled
|
||||
&& turn_context.config.reflections.shared_notes_enabled,
|
||||
);
|
||||
let mut new_history = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: handoff.clone(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}];
|
||||
|
||||
if matches!(
|
||||
initial_context_injection,
|
||||
InitialContextInjection::BeforeLastUserMessage
|
||||
) {
|
||||
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
|
||||
new_history =
|
||||
insert_initial_context_before_last_real_user_or_summary(new_history, initial_context);
|
||||
}
|
||||
|
||||
let history_snapshot = sess.clone_history().await;
|
||||
let ghost_snapshots: Vec<ResponseItem> = history_snapshot
|
||||
.raw_items()
|
||||
.iter()
|
||||
.filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. }))
|
||||
.cloned()
|
||||
.collect();
|
||||
new_history.extend(ghost_snapshots);
|
||||
|
||||
let reference_context_item = match initial_context_injection {
|
||||
InitialContextInjection::DoNotInject => None,
|
||||
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
|
||||
};
|
||||
let compacted_item = CompactedItem {
|
||||
message: handoff,
|
||||
replacement_history: Some(new_history.clone()),
|
||||
};
|
||||
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
||||
.await;
|
||||
sess.reset_reflections_near_limit_reminder();
|
||||
sess.recompute_token_usage(&turn_context).await;
|
||||
sess.emit_turn_item_completed(&turn_context, compaction_item)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn write_current_window(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
trigger: CompactionTrigger,
|
||||
) -> CodexResult<storage::WrittenWindow> {
|
||||
sess.try_ensure_rollout_materialized().await?;
|
||||
sess.flush_rollout().await?;
|
||||
let Some(rollout_path) = sess.current_rollout_path().await else {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"Reflections requires a persisted rollout path and is unavailable for ephemeral sessions"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
let sidecar_path = storage::sidecar_path_for_rollout(&rollout_path);
|
||||
let (rollout_items, _, _) = RolloutRecorder::load_rollout_items(&rollout_path).await?;
|
||||
let item_range = transcript::item_range_since_last_compaction(&rollout_items);
|
||||
let rollout_start_line = item_range.start.saturating_add(1);
|
||||
let rollout_end_line = item_range.end;
|
||||
let events = transcript::events_since_last_compaction(&rollout_items);
|
||||
let transcript = transcript::render(transcript::TranscriptInput {
|
||||
events: &events,
|
||||
trigger,
|
||||
context_window_size: turn_context.model_context_window(),
|
||||
rollout_path: &rollout_path,
|
||||
});
|
||||
|
||||
storage::write_window(
|
||||
&sidecar_path,
|
||||
&rollout_path,
|
||||
trigger,
|
||||
turn_context.model_context_window(),
|
||||
rollout_start_line,
|
||||
rollout_end_line,
|
||||
transcript,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
324
codex-rs/core/src/reflections/prompt.rs
Normal file
324
codex-rs/core/src/reflections/prompt.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use std::path::Path;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use super::storage::WINDOW_DIR_PATTERN;
|
||||
|
||||
const NEAR_LIMIT_REMINDER_HEADROOM_TOKENS: i64 = 4096;
|
||||
const NEAR_LIMIT_REMINDER_PREFIX: &str = "Your context window is nearly exhausted";
|
||||
|
||||
pub(crate) fn usage_hint(
|
||||
context_window_size: Option<i64>,
|
||||
sidecar_path: &Path,
|
||||
custom_text: Option<&str>,
|
||||
storage_tools_enabled: bool,
|
||||
shared_notes_available: bool,
|
||||
) -> String {
|
||||
if let Some(custom_text) = custom_text {
|
||||
return custom_text.to_string();
|
||||
}
|
||||
|
||||
if storage_tools_enabled {
|
||||
let shared_notes_text = if shared_notes_available {
|
||||
"\n\nShared Reflections notes are available across agents in the same agent tree. Use `reflections_write_shared_note` for coordination state that should be visible to other agents. Use `reflections_write_note` for thread-local recovery notes."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return format!(
|
||||
"{context_window_text}\n\n\
|
||||
Reflections is enabled. Codex automatically records visible messages and tool events from each context window.\n\n\
|
||||
Use `reflections_write_note` for durable recovery notes. Use `reflections_list`, `reflections_read`, and `reflections_search` to inspect previous context-window logs and notes by stable IDs.\n\n\
|
||||
You may want to keep concise notes about your progress incrementally so you can more easily resume after the context window resets. Having things in context is useful, but when details may be needed later, prefer storing references to specific messages, files, commands, findings, or important decisions in notes rather than repeatedly reading complete log windows.\n\n\
|
||||
Future context windows will not automatically include the full previous context. If the current task, current user request, important tool result, or relevant instruction detail may matter later, record a concise note and reference the relevant log ID and entry ID.{shared_notes_text}",
|
||||
context_window_text = context_window_text(context_window_size),
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"{context_window_text}\n\n\
|
||||
Reflections is enabled. Codex automatically records visible messages and tool events from each context window under:\n\n\
|
||||
{logs_path}/{window_dir_pattern}/transcript.md\n\n\
|
||||
Use this directory for durable recovery notes:\n\n\
|
||||
{notes_path}\n\n\
|
||||
You may want to keep concise notes about your progress incrementally so you can more easily resume after the context window resets. Having things in context is useful, but when details may be needed later, prefer storing references to specific messages, files, commands, findings, or important decisions in notes rather than repeatedly reading complete files or full transcript logs.\n\n\
|
||||
Future context windows will not automatically include the full previous transcript. If the current task, current user request, important tool result, or relevant instruction detail may matter later, record a concise note and reference the relevant transcript path and message heading.",
|
||||
context_window_text = context_window_text(context_window_size),
|
||||
logs_path = sidecar_path.join("logs").display(),
|
||||
notes_path = sidecar_path.join("notes").display(),
|
||||
window_dir_pattern = WINDOW_DIR_PATTERN,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn near_limit_reminder(
|
||||
remaining_tokens: Option<i64>,
|
||||
storage_tools_enabled: bool,
|
||||
shared_notes_available: bool,
|
||||
) -> ResponseItem {
|
||||
let opening = match remaining_tokens {
|
||||
Some(remaining_tokens) => format!(
|
||||
"{NEAR_LIMIT_REMINDER_PREFIX} ({remaining_tokens} tokens remain before your context will be reset)."
|
||||
),
|
||||
None => format!("{NEAR_LIMIT_REMINDER_PREFIX} and will be reset soon."),
|
||||
};
|
||||
let note_location = if storage_tools_enabled {
|
||||
"with `reflections_write_note`"
|
||||
} else {
|
||||
"under the Reflections notes directory"
|
||||
};
|
||||
let shared_notes_text = if storage_tools_enabled && shared_notes_available {
|
||||
"\n\nUse thread-local notes for your own recovery state. Use shared notes only for coordination details that other agents should see."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let text = format!(
|
||||
"{opening}\n\n\
|
||||
You may want to pause task work and write concise recovery notes {note_location} before continuing. Include the current task, progress made, important files, commands, findings, decisions, and next steps. If you have not finished the user's task, you are advised NOT to send a final answer and to use this time to write or clean up notes so you can resume work in the next context window after the context reset.\n\n\
|
||||
After saving notes, you may call `reflections_new_context_window` to start a fresh context window. If you continue without calling it, your context will automatically reset once the compaction limit is reached.{shared_notes_text}"
|
||||
);
|
||||
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn near_limit_reminder_threshold(auto_compact_limit: i64) -> i64 {
|
||||
auto_compact_limit
|
||||
.saturating_sub(NEAR_LIMIT_REMINDER_HEADROOM_TOKENS)
|
||||
.max(0)
|
||||
}
|
||||
|
||||
pub(crate) fn is_near_limit_reminder(item: &ResponseItem) -> bool {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
role == "developer"
|
||||
&& content.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ContentItem::InputText { text } if text.starts_with(NEAR_LIMIT_REMINDER_PREFIX)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn post_compaction_handoff(
|
||||
context_window_size: Option<i64>,
|
||||
logs_path: &Path,
|
||||
notes_path: &Path,
|
||||
storage_tools_enabled: bool,
|
||||
shared_notes_available: bool,
|
||||
) -> String {
|
||||
if storage_tools_enabled {
|
||||
let shared_notes_text = if shared_notes_available {
|
||||
"\n\nIf this task involves multiple agents, inspect shared notes with `reflections_list_shared_notes`, `reflections_read_shared_note`, or `reflections_search_shared_notes` for cross-agent coordination state."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return format!(
|
||||
"{context_window_text}\n\n\
|
||||
Reflections is enabled. Codex automatically recorded visible messages and tool events from previous context windows.\n\n\
|
||||
Use `reflections_list` to list notes and previous log windows. Use `reflections_read` to read an explicit note or log window by ID. Use `reflections_search` to search notes and previous log windows. Use `reflections_write_note` to update durable recovery notes.\n\n\
|
||||
Your context window was reset, and you are continuing in a fresh context window. The current task may only be available in Reflections logs from previous context windows. There may be no new user message in this context window, so do not assume one exists.\n\n\
|
||||
To recover context, first inspect notes with `reflections_list` and `reflections_read`. If notes are empty, missing, or do not clearly identify the current task and status, inspect or search the full conversation context logs for the previous context windows with `reflections_list`, `reflections_read`, and `reflections_search`. The logs are organized by context window with explicit IDs like `cw00000`. Prefer recovering only the details you need rather than rereading every log window.{shared_notes_text}",
|
||||
context_window_text = context_window_text(context_window_size),
|
||||
);
|
||||
}
|
||||
|
||||
format!(
|
||||
"{context_window_text}\n\n\
|
||||
Reflections is enabled. Codex automatically recorded visible messages and tool events from previous context windows here:\n\n\
|
||||
{logs_path}\n\n\
|
||||
Use this directory for durable recovery notes:\n\n\
|
||||
{notes_path}\n\n\
|
||||
Your context window was reset, and you are continuing in a fresh context window. The current task may only be available in the transcript logs from previous context windows. There may be no new user message in this context window, so do not assume one exists.\n\n\
|
||||
To recover context, first inspect `notes/`. If `notes/` is empty, missing, or does not clearly identify the current task and status, inspect or search the full conversation context logs under `logs/` for the previous context windows. The logs are organized by context window as `logs/{window_dir_pattern}/transcript.md`. Prefer recovering only the details you need rather than rereading every transcript file.",
|
||||
context_window_text = context_window_text(context_window_size),
|
||||
logs_path = logs_path.display(),
|
||||
notes_path = notes_path.display(),
|
||||
window_dir_pattern = WINDOW_DIR_PATTERN,
|
||||
)
|
||||
}
|
||||
|
||||
fn context_window_text(context_window_size: Option<i64>) -> String {
|
||||
match context_window_size {
|
||||
Some(context_window_size) => {
|
||||
format!("Your context window size is {context_window_size} tokens.")
|
||||
}
|
||||
None => "Your context window size is not available for this model.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_near_limit_reminder;
|
||||
use super::near_limit_reminder;
|
||||
use super::post_compaction_handoff;
|
||||
use super::usage_hint;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn usage_hint_mentions_sidecar_paths() {
|
||||
let hint = usage_hint(
|
||||
Some(98304),
|
||||
Path::new("/tmp/rollout.reflections"),
|
||||
None,
|
||||
/*storage_tools_enabled*/ false,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
|
||||
assert!(hint.contains("Your context window size is 98304 tokens."));
|
||||
assert!(hint.contains("/tmp/rollout.reflections/logs/cwNNNNN/transcript.md"));
|
||||
assert!(hint.contains("/tmp/rollout.reflections/notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_hint_mentions_storage_tools_when_enabled() {
|
||||
let hint = usage_hint(
|
||||
Some(98304),
|
||||
Path::new("/tmp/rollout.reflections"),
|
||||
None,
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
|
||||
assert!(hint.contains("Your context window size is 98304 tokens."));
|
||||
assert!(hint.contains("reflections_write_note"));
|
||||
assert!(hint.contains("reflections_list"));
|
||||
assert!(hint.contains("reflections_read"));
|
||||
assert!(hint.contains("reflections_search"));
|
||||
assert!(!hint.contains("/tmp/rollout.reflections"));
|
||||
assert!(!hint.contains("reflections_write_shared_note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_hint_mentions_shared_note_tools_when_available() {
|
||||
let hint = usage_hint(
|
||||
Some(98304),
|
||||
Path::new("/tmp/rollout.reflections"),
|
||||
None,
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ true,
|
||||
);
|
||||
|
||||
assert!(hint.contains("reflections_write_shared_note"));
|
||||
assert!(hint.contains("thread-local recovery notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_mentions_logs_and_notes() {
|
||||
let handoff = post_compaction_handoff(
|
||||
None,
|
||||
Path::new("/tmp/rollout.reflections/logs"),
|
||||
Path::new("/tmp/rollout.reflections/notes"),
|
||||
/*storage_tools_enabled*/ false,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
|
||||
assert!(handoff.contains("Your context window size is not available for this model."));
|
||||
assert!(handoff.contains("previous context windows here:"));
|
||||
assert!(handoff.contains("/tmp/rollout.reflections/logs"));
|
||||
assert!(handoff.contains("logs/cwNNNNN/transcript.md"));
|
||||
assert!(!handoff.contains("/tmp/rollout.reflections/logs/cw00000/transcript.md"));
|
||||
assert!(handoff.contains("/tmp/rollout.reflections/notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_mentions_storage_tools_when_enabled() {
|
||||
let handoff = post_compaction_handoff(
|
||||
None,
|
||||
Path::new("/tmp/rollout.reflections/logs"),
|
||||
Path::new("/tmp/rollout.reflections/notes"),
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
|
||||
assert!(handoff.contains("reflections_list"));
|
||||
assert!(handoff.contains("reflections_read"));
|
||||
assert!(handoff.contains("reflections_search"));
|
||||
assert!(handoff.contains("reflections_write_note"));
|
||||
assert!(!handoff.contains("/tmp/rollout.reflections"));
|
||||
assert!(!handoff.contains("reflections_list_shared_notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_mentions_shared_note_tools_when_available() {
|
||||
let handoff = post_compaction_handoff(
|
||||
None,
|
||||
Path::new("/tmp/rollout.reflections/logs"),
|
||||
Path::new("/tmp/rollout.reflections/notes"),
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ true,
|
||||
);
|
||||
|
||||
assert!(handoff.contains("reflections_list_shared_notes"));
|
||||
assert!(handoff.contains("cross-agent coordination state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_is_developer_message() {
|
||||
let reminder = near_limit_reminder(
|
||||
Some(4058),
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ false,
|
||||
);
|
||||
let ResponseItem::Message { role, content, .. } = &reminder else {
|
||||
panic!("near-limit reminder should be a message");
|
||||
};
|
||||
assert_eq!(role, "developer");
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
panic!("near-limit reminder should contain exactly one text item");
|
||||
};
|
||||
assert!(text.contains(
|
||||
"Your context window is nearly exhausted (4058 tokens remain before your context will be reset)."
|
||||
));
|
||||
assert!(text.contains("advised NOT to send a final answer"));
|
||||
assert!(text.contains("reflections_write_note"));
|
||||
assert!(text.contains("reflections_new_context_window"));
|
||||
assert!(!text.contains("shared notes"));
|
||||
assert!(is_near_limit_reminder(&reminder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_supports_unknown_remaining_tokens() {
|
||||
let reminder = near_limit_reminder(
|
||||
None, /*storage_tools_enabled*/ false, /*shared_notes_available*/ false,
|
||||
);
|
||||
let ResponseItem::Message { content, .. } = &reminder else {
|
||||
panic!("near-limit reminder should be a message");
|
||||
};
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
panic!("near-limit reminder should contain exactly one text item");
|
||||
};
|
||||
assert!(
|
||||
text.starts_with("Your context window is nearly exhausted and will be reset soon.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_mentions_shared_notes_when_available() {
|
||||
let reminder = near_limit_reminder(
|
||||
Some(4058),
|
||||
/*storage_tools_enabled*/ true,
|
||||
/*shared_notes_available*/ true,
|
||||
);
|
||||
let ResponseItem::Message { content, .. } = &reminder else {
|
||||
panic!("near-limit reminder should be a message");
|
||||
};
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
panic!("near-limit reminder should contain exactly one text item");
|
||||
};
|
||||
assert!(text.contains("Use shared notes only for coordination details"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_reminder_threshold_never_goes_negative() {
|
||||
assert_eq!(super::near_limit_reminder_threshold(10_000), 5904);
|
||||
assert_eq!(super::near_limit_reminder_threshold(100), 0);
|
||||
}
|
||||
}
|
||||
421
codex-rs/core/src/reflections/storage.rs
Normal file
421
codex-rs/core/src/reflections/storage.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const STATE_SCHEMA: &str = "reflections.state.v1";
|
||||
const TRANSCRIPT_FILE: &str = "transcript.md";
|
||||
const WINDOW_DIR_WIDTH: usize = 5;
|
||||
const MAX_SHARED_NOTES_PARENT_DEPTH: usize = 64;
|
||||
pub(crate) const WINDOW_DIR_PATTERN: &str = "cwNNNNN";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct WrittenWindow {
|
||||
pub(crate) window: String,
|
||||
pub(crate) logs_path: PathBuf,
|
||||
pub(crate) transcript_path: PathBuf,
|
||||
pub(crate) notes_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct ReflectionsState {
|
||||
schema: String,
|
||||
next_window_index: u64,
|
||||
latest_window: Option<String>,
|
||||
rollout_path: PathBuf,
|
||||
pub(super) windows: Vec<WindowState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct WindowState {
|
||||
pub(super) window: String,
|
||||
pub(super) trigger: String,
|
||||
pub(super) created_at: String,
|
||||
transcript_path: PathBuf,
|
||||
pub(super) context_window_size: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) rollout_start_line: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(super) rollout_end_line: Option<usize>,
|
||||
}
|
||||
|
||||
pub(crate) fn sidecar_path_for_rollout(rollout_path: &Path) -> PathBuf {
|
||||
rollout_path.with_extension("reflections")
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_sidecar_dirs(sidecar_path: &Path) -> std::io::Result<()> {
|
||||
tokio::fs::create_dir_all(sidecar_path).await?;
|
||||
tokio::fs::create_dir_all(sidecar_path.join("notes")).await?;
|
||||
tokio::fs::create_dir_all(sidecar_path.join("logs")).await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_reflections_shared_notes_path(
|
||||
codex_home: &Path,
|
||||
current_rollout_path: &Path,
|
||||
current_thread_id: ThreadId,
|
||||
session_source: &SessionSource,
|
||||
) -> Option<PathBuf> {
|
||||
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) = session_source
|
||||
else {
|
||||
return Some(sidecar_path_for_rollout(current_rollout_path).join("shared_notes"));
|
||||
};
|
||||
|
||||
let mut next_parent_thread_id = *parent_thread_id;
|
||||
let mut seen_thread_ids = std::collections::HashSet::new();
|
||||
seen_thread_ids.insert(current_thread_id.to_string());
|
||||
|
||||
for _ in 0..MAX_SHARED_NOTES_PARENT_DEPTH {
|
||||
let parent_thread_id = next_parent_thread_id.to_string();
|
||||
if !seen_thread_ids.insert(parent_thread_id.clone()) {
|
||||
return None;
|
||||
}
|
||||
let parent_rollout_path =
|
||||
crate::rollout::find_thread_path_by_id_str(codex_home, &parent_thread_id)
|
||||
.await
|
||||
.ok()??;
|
||||
let parent_session_meta =
|
||||
crate::rollout::read_session_meta_line(parent_rollout_path.as_path())
|
||||
.await
|
||||
.ok()?;
|
||||
match parent_session_meta.meta.source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) => {
|
||||
next_parent_thread_id = parent_thread_id;
|
||||
}
|
||||
SessionSource::Cli
|
||||
| SessionSource::VSCode
|
||||
| SessionSource::Exec
|
||||
| SessionSource::Mcp
|
||||
| SessionSource::Custom(_)
|
||||
| SessionSource::SubAgent(_)
|
||||
| SessionSource::Unknown => {
|
||||
return Some(sidecar_path_for_rollout(&parent_rollout_path).join("shared_notes"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) async fn write_window(
|
||||
sidecar_path: &Path,
|
||||
rollout_path: &Path,
|
||||
trigger: CompactionTrigger,
|
||||
context_window_size: Option<i64>,
|
||||
rollout_start_line: usize,
|
||||
rollout_end_line: usize,
|
||||
transcript: String,
|
||||
) -> std::io::Result<WrittenWindow> {
|
||||
ensure_sidecar_dirs(sidecar_path).await?;
|
||||
let notes_path = sidecar_path.join("notes");
|
||||
let logs_path = sidecar_path.join("logs");
|
||||
|
||||
let mut state = read_state(sidecar_path, rollout_path).await?;
|
||||
let window_index = allocate_window_index(&state, &logs_path).await;
|
||||
let window = window_dir_name(window_index);
|
||||
let window_path = logs_path.join(&window);
|
||||
tokio::fs::create_dir_all(&window_path).await?;
|
||||
let transcript_path = window_path.join(TRANSCRIPT_FILE);
|
||||
write_atomic(&transcript_path, transcript.as_bytes()).await?;
|
||||
|
||||
let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||
state.next_window_index = window_index.saturating_add(1);
|
||||
state.latest_window = Some(window.clone());
|
||||
state.windows.push(WindowState {
|
||||
window: window.clone(),
|
||||
trigger: trigger_label(trigger).to_string(),
|
||||
created_at,
|
||||
transcript_path: PathBuf::from("logs").join(&window).join(TRANSCRIPT_FILE),
|
||||
context_window_size,
|
||||
rollout_start_line: Some(rollout_start_line),
|
||||
rollout_end_line: Some(rollout_end_line),
|
||||
});
|
||||
write_state(sidecar_path, &state).await?;
|
||||
|
||||
Ok(WrittenWindow {
|
||||
window,
|
||||
logs_path,
|
||||
transcript_path,
|
||||
notes_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn read_state(
|
||||
sidecar_path: &Path,
|
||||
rollout_path: &Path,
|
||||
) -> std::io::Result<ReflectionsState> {
|
||||
let state_path = sidecar_path.join("state.json");
|
||||
match tokio::fs::read_to_string(&state_path).await {
|
||||
Ok(contents) => {
|
||||
let mut state: ReflectionsState = serde_json::from_str(&contents)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
|
||||
if state.schema != STATE_SCHEMA {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("unsupported Reflections state schema `{}`", state.schema),
|
||||
));
|
||||
}
|
||||
state.rollout_path = rollout_path.to_path_buf();
|
||||
Ok(state)
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ReflectionsState {
|
||||
schema: STATE_SCHEMA.to_string(),
|
||||
next_window_index: 0,
|
||||
latest_window: None,
|
||||
rollout_path: rollout_path.to_path_buf(),
|
||||
windows: Vec::new(),
|
||||
}),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn allocate_window_index(state: &ReflectionsState, logs_path: &Path) -> u64 {
|
||||
let mut candidate = state.next_window_index;
|
||||
while tokio::fs::try_exists(logs_path.join(window_dir_name(candidate)))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
candidate = candidate.saturating_add(1);
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
fn window_dir_name(index: u64) -> String {
|
||||
format!("cw{index:0WINDOW_DIR_WIDTH$}")
|
||||
}
|
||||
|
||||
async fn write_state(sidecar_path: &Path, state: &ReflectionsState) -> std::io::Result<()> {
|
||||
let state_json = serde_json::to_vec_pretty(state)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
|
||||
write_atomic(&sidecar_path.join("state.json"), &state_json).await
|
||||
}
|
||||
|
||||
pub(super) async fn write_atomic(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||
file.write_all(contents).await?;
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
tokio::fs::rename(&tmp_path, path).await
|
||||
}
|
||||
|
||||
fn trigger_label(trigger: CompactionTrigger) -> &'static str {
|
||||
match trigger {
|
||||
CompactionTrigger::Manual => "manual_compact",
|
||||
CompactionTrigger::Auto => "auto_compact",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_reflections_shared_notes_path;
|
||||
use super::sidecar_path_for_rollout;
|
||||
use super::write_window;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_window_allocates_transcript_and_state() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let rollout = temp.path().join("rollout-2026-04-14T00-00-00-thread.jsonl");
|
||||
let sidecar = sidecar_path_for_rollout(&rollout);
|
||||
|
||||
let first = write_window(
|
||||
&sidecar,
|
||||
&rollout,
|
||||
CompactionTrigger::Manual,
|
||||
Some(98304),
|
||||
1,
|
||||
1,
|
||||
"first".to_string(),
|
||||
)
|
||||
.await?;
|
||||
let second = write_window(
|
||||
&sidecar,
|
||||
&rollout,
|
||||
CompactionTrigger::Auto,
|
||||
Some(98304),
|
||||
2,
|
||||
2,
|
||||
"second".to_string(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(first.window, "cw00000");
|
||||
assert_eq!(second.window, "cw00001");
|
||||
assert_eq!(first.logs_path, sidecar.join("logs"));
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(first.transcript_path).await?,
|
||||
"first"
|
||||
);
|
||||
assert!(sidecar.join("notes").is_dir());
|
||||
let state = tokio::fs::read_to_string(sidecar.join("state.json")).await?;
|
||||
assert!(state.contains("\"latest_window\": \"cw00001\""));
|
||||
assert!(state.contains("\"rollout_start_line\": 1"));
|
||||
assert!(state.contains("\"rollout_end_line\": 2"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_notes_resolve_to_current_sidecar_for_root_thread() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let thread_id = ThreadId::new();
|
||||
let rollout = temp.path().join(format!(
|
||||
"sessions/2026/04/16/rollout-2026-04-16T00-00-00-{thread_id}.jsonl"
|
||||
));
|
||||
|
||||
let shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&rollout,
|
||||
thread_id,
|
||||
&SessionSource::Cli,
|
||||
)
|
||||
.await
|
||||
.expect("root shared notes should resolve");
|
||||
|
||||
assert_eq!(
|
||||
shared_notes,
|
||||
sidecar_path_for_rollout(&rollout).join("shared_notes")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_notes_resolve_to_root_sidecar_for_descendants() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let root_id = ThreadId::new();
|
||||
let child_id = ThreadId::new();
|
||||
let grandchild_id = ThreadId::new();
|
||||
let root_rollout = write_minimal_rollout(temp.path(), root_id, SessionSource::Cli)?;
|
||||
let child_rollout =
|
||||
write_minimal_rollout(temp.path(), child_id, thread_spawn_source(root_id, 1))?;
|
||||
let grandchild_rollout = temp.path().join(format!(
|
||||
"sessions/2026/04/16/rollout-2026-04-16T00-02-00-{grandchild_id}.jsonl"
|
||||
));
|
||||
|
||||
let child_shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&child_rollout,
|
||||
child_id,
|
||||
&thread_spawn_source(root_id, 1),
|
||||
)
|
||||
.await
|
||||
.expect("child shared notes should resolve");
|
||||
let grandchild_shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&grandchild_rollout,
|
||||
grandchild_id,
|
||||
&thread_spawn_source(child_id, 2),
|
||||
)
|
||||
.await
|
||||
.expect("grandchild shared notes should resolve");
|
||||
|
||||
let expected = sidecar_path_for_rollout(&root_rollout).join("shared_notes");
|
||||
assert_eq!(child_shared_notes, expected);
|
||||
assert_eq!(grandchild_shared_notes, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_notes_resolution_failure_returns_none() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let current_id = ThreadId::new();
|
||||
let missing_parent_id = ThreadId::new();
|
||||
let rollout = temp.path().join("rollout.jsonl");
|
||||
|
||||
let shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&rollout,
|
||||
current_id,
|
||||
&thread_spawn_source(missing_parent_id, 1),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(shared_notes, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_notes_resolution_cycle_returns_none() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let current_id = ThreadId::new();
|
||||
let parent_id = ThreadId::new();
|
||||
write_minimal_rollout(temp.path(), parent_id, thread_spawn_source(current_id, 1))?;
|
||||
let rollout = temp.path().join("rollout.jsonl");
|
||||
|
||||
let shared_notes = resolve_reflections_shared_notes_path(
|
||||
temp.path(),
|
||||
&rollout,
|
||||
current_id,
|
||||
&thread_spawn_source(parent_id, 1),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(shared_notes, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn thread_spawn_source(parent_thread_id: ThreadId, depth: i32) -> SessionSource {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_minimal_rollout(
|
||||
codex_home: &Path,
|
||||
thread_id: ThreadId,
|
||||
source: SessionSource,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let sessions_dir = codex_home.join("sessions/2026/04/16");
|
||||
std::fs::create_dir_all(&sessions_dir)?;
|
||||
let rollout = sessions_dir.join(format!("rollout-2026-04-16T00-00-00-{thread_id}.jsonl"));
|
||||
let line = RolloutLine {
|
||||
timestamp: "2026-04-16T00:00:00.000Z".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
id: thread_id,
|
||||
forked_from_id: None,
|
||||
timestamp: "2026-04-16T00:00:00Z".to_string(),
|
||||
cwd: codex_home.to_path_buf(),
|
||||
originator: "test".to_string(),
|
||||
cli_version: "test".to_string(),
|
||||
source,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
agent_path: None,
|
||||
model_provider: None,
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
memory_mode: None,
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
};
|
||||
std::fs::write(&rollout, format!("{}\n", serde_json::to_string(&line)?))?;
|
||||
Ok(rollout)
|
||||
}
|
||||
}
|
||||
1188
codex-rs/core/src/reflections/storage_tools.rs
Normal file
1188
codex-rs/core/src/reflections/storage_tools.rs
Normal file
File diff suppressed because it is too large
Load Diff
553
codex-rs/core/src/reflections/transcript.rs
Normal file
553
codex-rs/core/src/reflections/transcript.rs
Normal file
@@ -0,0 +1,553 @@
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) use super::log_entries::LogEntry;
|
||||
use super::log_entries::dynamic_tool_output_items_to_text;
|
||||
use super::log_entries::exec_output;
|
||||
use super::log_entries::is_reflections_storage_tool;
|
||||
pub(crate) use super::log_entries::log_entries_from_items;
|
||||
use super::log_entries::push_blank_line_if_needed;
|
||||
use super::log_entries::reflections_storage_tool_call_metadata;
|
||||
use super::log_entries::reflections_storage_tool_response_metadata;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
pub(crate) struct TranscriptInput<'a> {
|
||||
pub(crate) events: &'a [EventMsg],
|
||||
pub(crate) trigger: CompactionTrigger,
|
||||
pub(crate) context_window_size: Option<i64>,
|
||||
pub(crate) rollout_path: &'a Path,
|
||||
}
|
||||
|
||||
pub(crate) fn events_since_last_compaction(items: &[RolloutItem]) -> Vec<EventMsg> {
|
||||
let range = item_range_since_last_compaction(items);
|
||||
|
||||
items[range]
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
RolloutItem::EventMsg(event) => Some(event.clone()),
|
||||
RolloutItem::SessionMeta(_)
|
||||
| RolloutItem::ResponseItem(_)
|
||||
| RolloutItem::Compacted(_)
|
||||
| RolloutItem::TurnContext(_) => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn item_range_since_last_compaction(items: &[RolloutItem]) -> Range<usize> {
|
||||
let start_index = items
|
||||
.iter()
|
||||
.rposition(|item| matches!(item, RolloutItem::Compacted(_)))
|
||||
.map_or(0, |index| index + 1);
|
||||
start_index..items.len()
|
||||
}
|
||||
|
||||
pub(crate) fn render(input: TranscriptInput<'_>) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("# Reflections Log\n\n");
|
||||
out.push_str("- schema: reflections.transcript.v1\n");
|
||||
out.push_str(&format!("- trigger: {}\n", trigger_label(input.trigger)));
|
||||
out.push_str(&format!(
|
||||
"- context_window_size: {}\n",
|
||||
input
|
||||
.context_window_size
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "unavailable".to_string())
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"- source_rollout: {}\n",
|
||||
input.rollout_path.display()
|
||||
));
|
||||
out.push('\n');
|
||||
|
||||
let mut index = 1usize;
|
||||
for event in input.events {
|
||||
if push_event(&mut out, index, event) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn push_event(out: &mut String, index: usize, event: &EventMsg) -> bool {
|
||||
match event {
|
||||
EventMsg::UserMessage(event) => {
|
||||
let mut text = event.message.clone();
|
||||
if let Some(images) = event.images.as_ref().filter(|images| !images.is_empty()) {
|
||||
push_blank_line_if_needed(&mut text);
|
||||
text.push_str("images:\n");
|
||||
for image in images {
|
||||
text.push_str(&format!("- {image}\n"));
|
||||
}
|
||||
}
|
||||
if !event.local_images.is_empty() {
|
||||
push_blank_line_if_needed(&mut text);
|
||||
text.push_str("local_images:\n");
|
||||
for image in &event.local_images {
|
||||
text.push_str(&format!("- {}\n", image.display()));
|
||||
}
|
||||
}
|
||||
push_message(out, index, "user", &text);
|
||||
true
|
||||
}
|
||||
EventMsg::AgentMessage(event) => {
|
||||
let heading = event.phase.as_ref().map_or_else(
|
||||
|| "assistant".to_string(),
|
||||
|phase| format!("assistant {phase:?}").to_lowercase(),
|
||||
);
|
||||
push_message(out, index, &heading, &event.message);
|
||||
true
|
||||
}
|
||||
EventMsg::McpToolCallBegin(event) => {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
&format!(
|
||||
"tool_call mcp.{}.{}",
|
||||
event.invocation.server, event.invocation.tool
|
||||
),
|
||||
&json!({
|
||||
"call_id": event.call_id,
|
||||
"arguments": event.invocation.arguments,
|
||||
}),
|
||||
);
|
||||
true
|
||||
}
|
||||
EventMsg::McpToolCallEnd(event) => {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
&format!(
|
||||
"tool_result mcp.{}.{}",
|
||||
event.invocation.server, event.invocation.tool
|
||||
),
|
||||
&json!({
|
||||
"call_id": event.call_id,
|
||||
"arguments": event.invocation.arguments,
|
||||
"success": event.is_success(),
|
||||
"result": event.result,
|
||||
}),
|
||||
);
|
||||
true
|
||||
}
|
||||
EventMsg::WebSearchBegin(event) => {
|
||||
push_json(out, index, "tool_call web_search", event);
|
||||
true
|
||||
}
|
||||
EventMsg::WebSearchEnd(event) => {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
"tool_result web_search",
|
||||
&json!({
|
||||
"call_id": event.call_id,
|
||||
"query": event.query,
|
||||
"action": event.action,
|
||||
}),
|
||||
);
|
||||
true
|
||||
}
|
||||
EventMsg::ImageGenerationBegin(event) => {
|
||||
push_json(out, index, "tool_call image_generation", event);
|
||||
true
|
||||
}
|
||||
EventMsg::ImageGenerationEnd(event) => {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
"tool_result image_generation",
|
||||
&json!({
|
||||
"call_id": event.call_id,
|
||||
"status": event.status,
|
||||
"revised_prompt": event.revised_prompt,
|
||||
"result": event.result,
|
||||
"saved_path": event.saved_path.as_ref().map(|path| path.display().to_string()),
|
||||
}),
|
||||
);
|
||||
true
|
||||
}
|
||||
EventMsg::ExecCommandBegin(event) => {
|
||||
let mut text = String::new();
|
||||
text.push_str(&format!("call_id: {}\n", event.call_id));
|
||||
text.push_str(&format!("cwd: {}\n", event.cwd.display()));
|
||||
text.push_str(&format!(
|
||||
"command: {}\n",
|
||||
serde_json::to_string(&event.command).unwrap_or_else(|_| "[]".to_string())
|
||||
));
|
||||
if let Some(input) = event.interaction_input.as_deref() {
|
||||
text.push_str(&format!("interaction_input: {input}\n"));
|
||||
}
|
||||
push_fenced(out, index, "tool_call exec_command", "text", &text);
|
||||
true
|
||||
}
|
||||
EventMsg::ExecCommandEnd(event) => {
|
||||
let mut text = String::new();
|
||||
text.push_str(&format!("call_id: {}\n", event.call_id));
|
||||
text.push_str(&format!("status: {:?}\n", event.status));
|
||||
text.push_str(&format!("exit_code: {}\n", event.exit_code));
|
||||
text.push_str(&format!("cwd: {}\n", event.cwd.display()));
|
||||
text.push_str(&format!(
|
||||
"command: {}\n",
|
||||
serde_json::to_string(&event.command).unwrap_or_else(|_| "[]".to_string())
|
||||
));
|
||||
let output = exec_output(event);
|
||||
if !output.is_empty() {
|
||||
text.push_str("\noutput:\n");
|
||||
text.push_str(&output);
|
||||
if !output.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
push_fenced(out, index, "tool_result exec_command", "text", &text);
|
||||
true
|
||||
}
|
||||
EventMsg::ViewImageToolCall(event) => {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
"tool_call view_image",
|
||||
&json!({
|
||||
"call_id": event.call_id,
|
||||
"path": event.path.display().to_string(),
|
||||
}),
|
||||
);
|
||||
true
|
||||
}
|
||||
EventMsg::DynamicToolCallRequest(event) => {
|
||||
if is_reflections_storage_tool(&event.tool) {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
&format!("tool_call {}", event.tool),
|
||||
&reflections_storage_tool_call_metadata(
|
||||
&event.tool,
|
||||
&event.call_id,
|
||||
&event.arguments,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
&format!("tool_call {}", event.tool),
|
||||
&json!({
|
||||
"call_id": event.call_id,
|
||||
"arguments": event.arguments,
|
||||
}),
|
||||
);
|
||||
true
|
||||
}
|
||||
EventMsg::DynamicToolCallResponse(event) => {
|
||||
if is_reflections_storage_tool(&event.tool) {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
&format!("tool_result {}", event.tool),
|
||||
&reflections_storage_tool_response_metadata(
|
||||
&event.tool,
|
||||
&event.call_id,
|
||||
event.success,
|
||||
event.error.as_deref(),
|
||||
&event.content_items,
|
||||
&event.arguments,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
let text = format!(
|
||||
"call_id: {}\nsuccess: {}\nerror: {}\n\n{}",
|
||||
event.call_id,
|
||||
event.success,
|
||||
event.error.as_deref().unwrap_or(""),
|
||||
dynamic_tool_output_items_to_text(&event.content_items)
|
||||
);
|
||||
push_fenced(
|
||||
out,
|
||||
index,
|
||||
&format!("tool_result {}", event.tool),
|
||||
"text",
|
||||
&text,
|
||||
);
|
||||
true
|
||||
}
|
||||
EventMsg::PatchApplyBegin(event) => {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
"tool_call apply_patch",
|
||||
&json!({
|
||||
"call_id": event.call_id,
|
||||
"auto_approved": event.auto_approved,
|
||||
"changes": event.changes,
|
||||
}),
|
||||
);
|
||||
true
|
||||
}
|
||||
EventMsg::PatchApplyEnd(event) => {
|
||||
push_json(
|
||||
out,
|
||||
index,
|
||||
"tool_result apply_patch",
|
||||
&json!({
|
||||
"call_id": event.call_id,
|
||||
"success": event.success,
|
||||
"status": event.status,
|
||||
"stdout": event.stdout,
|
||||
"stderr": event.stderr,
|
||||
"changes": event.changes,
|
||||
}),
|
||||
);
|
||||
true
|
||||
}
|
||||
EventMsg::Error(_)
|
||||
| EventMsg::Warning(_)
|
||||
| EventMsg::RealtimeConversationStarted(_)
|
||||
| EventMsg::RealtimeConversationRealtime(_)
|
||||
| EventMsg::RealtimeConversationClosed(_)
|
||||
| EventMsg::RealtimeConversationSdp(_)
|
||||
| EventMsg::ModelReroute(_)
|
||||
| EventMsg::ContextCompacted(_)
|
||||
| EventMsg::ThreadRolledBack(_)
|
||||
| EventMsg::TurnStarted(_)
|
||||
| EventMsg::TurnComplete(_)
|
||||
| EventMsg::TokenCount(_)
|
||||
| EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoning(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::AgentReasoningRawContent(_)
|
||||
| EventMsg::AgentReasoningRawContentDelta(_)
|
||||
| EventMsg::AgentReasoningSectionBreak(_)
|
||||
| EventMsg::SessionConfigured(_)
|
||||
| EventMsg::ThreadNameUpdated(_)
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::RequestPermissions(_)
|
||||
| EventMsg::RequestUserInput(_)
|
||||
| EventMsg::ElicitationRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::GuardianAssessment(_)
|
||||
| EventMsg::DeprecationNotice(_)
|
||||
| EventMsg::BackgroundEvent(_)
|
||||
| EventMsg::UndoStarted(_)
|
||||
| EventMsg::UndoCompleted(_)
|
||||
| EventMsg::StreamError(_)
|
||||
| EventMsg::TurnDiff(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::RealtimeConversationListVoicesResponse(_)
|
||||
| EventMsg::SkillsUpdateAvailable
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::TurnAborted(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::ItemStarted(_)
|
||||
| EventMsg::ItemCompleted(_)
|
||||
| EventMsg::HookStarted(_)
|
||||
| EventMsg::HookCompleted(_)
|
||||
| EventMsg::AgentMessageContentDelta(_)
|
||||
| EventMsg::PlanDelta(_)
|
||||
| EventMsg::ReasoningContentDelta(_)
|
||||
| EventMsg::ReasoningRawContentDelta(_)
|
||||
| EventMsg::CollabAgentSpawnBegin(_)
|
||||
| EventMsg::CollabAgentSpawnEnd(_)
|
||||
| EventMsg::CollabAgentInteractionBegin(_)
|
||||
| EventMsg::CollabAgentInteractionEnd(_)
|
||||
| EventMsg::CollabWaitingBegin(_)
|
||||
| EventMsg::CollabWaitingEnd(_)
|
||||
| EventMsg::CollabCloseBegin(_)
|
||||
| EventMsg::CollabCloseEnd(_)
|
||||
| EventMsg::CollabResumeBegin(_)
|
||||
| EventMsg::CollabResumeEnd(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_message(out: &mut String, index: usize, heading: &str, text: &str) {
|
||||
push_fenced(out, index, heading, "text", text);
|
||||
}
|
||||
|
||||
fn push_fenced(out: &mut String, index: usize, heading: &str, language: &str, text: &str) {
|
||||
out.push_str(&format!("## msg-{index:06} {heading}\n\n"));
|
||||
let fence = fence_for(text);
|
||||
out.push_str(&format!("{fence}{language}\n"));
|
||||
out.push_str(text);
|
||||
if !text.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&format!("{fence}\n\n"));
|
||||
}
|
||||
|
||||
fn push_json<T: Serialize>(out: &mut String, index: usize, heading: &str, value: &T) {
|
||||
let text = serde_json::to_string_pretty(value)
|
||||
.unwrap_or_else(|err| format!("<failed to serialize visible event: {err}>"));
|
||||
push_fenced(out, index, heading, "json", &text);
|
||||
}
|
||||
|
||||
fn fence_for(text: &str) -> String {
|
||||
let longest_run = longest_backtick_run(text);
|
||||
"`".repeat(longest_run.max(2) + 1)
|
||||
}
|
||||
|
||||
fn longest_backtick_run(text: &str) -> usize {
|
||||
let mut longest = 0usize;
|
||||
let mut current = 0usize;
|
||||
for ch in text.chars() {
|
||||
if ch == '`' {
|
||||
current += 1;
|
||||
longest = longest.max(current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
longest
|
||||
}
|
||||
|
||||
fn trigger_label(trigger: CompactionTrigger) -> &'static str {
|
||||
match trigger {
|
||||
CompactionTrigger::Manual => "manual_compact",
|
||||
CompactionTrigger::Auto => "auto_compact",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TranscriptInput;
|
||||
use super::events_since_last_compaction;
|
||||
use super::render;
|
||||
use codex_analytics::CompactionTrigger;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::AgentReasoningEvent;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::DynamicToolCallResponseEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn transcript_renders_visible_events_only() {
|
||||
let items = vec![
|
||||
RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "AGENTS.md instructions should be omitted".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}),
|
||||
RolloutItem::ResponseItem(ResponseItem::Reasoning {
|
||||
id: "rs_1".to_string(),
|
||||
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
||||
text: "checked tests".to_string(),
|
||||
}],
|
||||
content: Some(vec![ReasoningItemContent::ReasoningText {
|
||||
text: "hidden chain of thought".to_string(),
|
||||
}]),
|
||||
encrypted_content: None,
|
||||
}),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "please test".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::DynamicToolCallRequest(DynamicToolCallRequest {
|
||||
call_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
tool: "lookup_ticket".to_string(),
|
||||
arguments: serde_json::json!({"id": "T-1"}),
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::DynamicToolCallResponse(
|
||||
DynamicToolCallResponseEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
tool: "lookup_ticket".to_string(),
|
||||
arguments: serde_json::json!({"id": "T-1"}),
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: "ok".to_string(),
|
||||
}],
|
||||
success: true,
|
||||
error: None,
|
||||
duration: Duration::from_millis(10),
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "done".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
})),
|
||||
RolloutItem::EventMsg(EventMsg::AgentReasoning(AgentReasoningEvent {
|
||||
text: "reasoning summary should be omitted".to_string(),
|
||||
})),
|
||||
];
|
||||
|
||||
let events = events_since_last_compaction(&items);
|
||||
let transcript = render(TranscriptInput {
|
||||
events: &events,
|
||||
trigger: CompactionTrigger::Manual,
|
||||
context_window_size: Some(98304),
|
||||
rollout_path: Path::new("/tmp/rollout.jsonl"),
|
||||
});
|
||||
|
||||
assert!(transcript.contains("## msg-000001 user"));
|
||||
assert!(transcript.contains("## msg-000002 tool_call lookup_ticket"));
|
||||
assert!(transcript.contains("## msg-000003 tool_result lookup_ticket"));
|
||||
assert!(transcript.contains("## msg-000004 assistant"));
|
||||
assert!(transcript.contains("done"));
|
||||
assert!(!transcript.contains("AGENTS.md instructions should be omitted"));
|
||||
assert!(!transcript.contains("checked tests"));
|
||||
assert!(!transcript.contains("hidden chain of thought"));
|
||||
assert!(!transcript.contains("reasoning summary should be omitted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_uses_events_after_last_compaction() {
|
||||
let items = vec![
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "old window".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
RolloutItem::Compacted(CompactedItem {
|
||||
message: "handoff".to_string(),
|
||||
replacement_history: None,
|
||||
}),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: "current window".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
})),
|
||||
];
|
||||
|
||||
let events = events_since_last_compaction(&items);
|
||||
let transcript = render(TranscriptInput {
|
||||
events: &events,
|
||||
trigger: CompactionTrigger::Auto,
|
||||
context_window_size: None,
|
||||
rollout_path: Path::new("/tmp/rollout.jsonl"),
|
||||
});
|
||||
|
||||
assert!(transcript.contains("current window"));
|
||||
assert!(!transcript.contains("old window"));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_tools::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
use codex_utils_stream_parser::strip_citations;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -191,6 +192,12 @@ pub(crate) struct OutputItemResult {
|
||||
pub last_agent_message: Option<String>,
|
||||
pub needs_follow_up: bool,
|
||||
pub tool_future: Option<InFlightFuture<'static>>,
|
||||
pub terminal_control: Option<ResponseTerminalControl>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ResponseTerminalControl {
|
||||
ReflectionsContextWindowReset,
|
||||
}
|
||||
|
||||
pub(crate) struct HandleOutputCtx {
|
||||
@@ -227,6 +234,13 @@ pub(crate) async fn handle_output_item_done(
|
||||
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)
|
||||
.await;
|
||||
|
||||
let terminal_control = if call.tool_name.namespace.is_none()
|
||||
&& call.tool_name.name.as_str() == REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME
|
||||
{
|
||||
Some(ResponseTerminalControl::ReflectionsContextWindowReset)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cancellation_token = ctx.cancellation_token.child_token();
|
||||
let tool_future: InFlightFuture<'static> = Box::pin(
|
||||
ctx.tool_runtime
|
||||
@@ -235,6 +249,7 @@ pub(crate) async fn handle_output_item_done(
|
||||
);
|
||||
|
||||
output.needs_follow_up = true;
|
||||
output.terminal_control = terminal_control;
|
||||
output.tool_future = Some(tool_future);
|
||||
}
|
||||
// No tool call: convert messages/reasoning into turn items and mark them as complete.
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::SessionTask;
|
||||
use super::SessionTaskContext;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::state::TaskKind;
|
||||
use codex_protocol::config_types::CompactionStrategyConfig;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -27,7 +28,14 @@ impl SessionTask for CompactTask {
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Option<String> {
|
||||
let session = session.clone_session();
|
||||
let _ = if crate::compact::should_use_remote_compact_task(&ctx.provider) {
|
||||
let _ = if ctx.config.compaction_strategy == CompactionStrategyConfig::Reflections {
|
||||
session.services.session_telemetry.counter(
|
||||
"codex.task.compact",
|
||||
/*inc*/ 1,
|
||||
&[("type", "reflections")],
|
||||
);
|
||||
crate::reflections::run_reflections_compact_task(session.clone(), ctx).await
|
||||
} else if crate::compact::should_use_remote_compact_task(&ctx.provider) {
|
||||
session.services.session_telemetry.counter(
|
||||
"codex.task.compact",
|
||||
/*inc*/ 1,
|
||||
|
||||
@@ -9,6 +9,7 @@ pub(crate) mod multi_agents;
|
||||
pub(crate) mod multi_agents_common;
|
||||
pub(crate) mod multi_agents_v2;
|
||||
mod plan;
|
||||
mod reflections;
|
||||
mod request_permissions;
|
||||
mod request_user_input;
|
||||
mod shell;
|
||||
@@ -42,6 +43,16 @@ pub use list_dir::ListDirHandler;
|
||||
pub use mcp::McpHandler;
|
||||
pub use mcp_resource::McpResourceHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use reflections::ReflectionsGetContextRemainingHandler;
|
||||
pub use reflections::ReflectionsListHandler;
|
||||
pub use reflections::ReflectionsListSharedNotesHandler;
|
||||
pub use reflections::ReflectionsNewContextWindowHandler;
|
||||
pub use reflections::ReflectionsReadHandler;
|
||||
pub use reflections::ReflectionsReadSharedNoteHandler;
|
||||
pub use reflections::ReflectionsSearchHandler;
|
||||
pub use reflections::ReflectionsSearchSharedNotesHandler;
|
||||
pub use reflections::ReflectionsWriteNoteHandler;
|
||||
pub use reflections::ReflectionsWriteSharedNoteHandler;
|
||||
pub use request_permissions::RequestPermissionsHandler;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
pub use shell::ShellCommandHandler;
|
||||
|
||||
@@ -10,6 +10,9 @@ use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::handlers::ReflectionsReadSharedNoteHandler;
|
||||
use crate::tools::handlers::ReflectionsSearchSharedNotesHandler;
|
||||
use crate::tools::handlers::ReflectionsWriteSharedNoteHandler;
|
||||
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
@@ -46,6 +49,7 @@ use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::TempDirExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
@@ -84,6 +88,99 @@ fn parse_agent_id(id: &str) -> ThreadId {
|
||||
ThreadId::from_string(id).expect("agent id should be valid")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_reflections_shared_notes_are_visible_across_turn_contexts() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
||||
let shared_notes_path =
|
||||
AbsolutePathBuf::from_absolute_path(temp_dir.path().join("shared_notes"))
|
||||
.expect("shared notes path should be absolute");
|
||||
let (parent_session, mut parent_turn) = make_session_and_context().await;
|
||||
parent_turn.reflections_shared_notes_path = Some(shared_notes_path.clone());
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let (child_session, mut child_turn) = make_session_and_context().await;
|
||||
child_turn.reflections_shared_notes_path = Some(shared_notes_path);
|
||||
child_turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
let parent_session = Arc::new(parent_session);
|
||||
let child_session = Arc::new(child_session);
|
||||
let parent_turn = Arc::new(parent_turn);
|
||||
let child_turn = Arc::new(child_turn);
|
||||
|
||||
ReflectionsWriteSharedNoteHandler
|
||||
.handle(invocation(
|
||||
Arc::clone(&parent_session),
|
||||
Arc::clone(&parent_turn),
|
||||
"reflections_write_shared_note",
|
||||
function_payload(json!({
|
||||
"note_id": "handoff",
|
||||
"operation": "create_only",
|
||||
"content": "Parent progress: inspect parser tests\n"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("parent should write shared note");
|
||||
|
||||
let read_output = ReflectionsReadSharedNoteHandler
|
||||
.handle(invocation(
|
||||
Arc::clone(&child_session),
|
||||
Arc::clone(&child_turn),
|
||||
"reflections_read_shared_note",
|
||||
function_payload(json!({
|
||||
"note_id": "handoff",
|
||||
"start": 1,
|
||||
"stop": 1
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("child should read shared note");
|
||||
let (content, _) = expect_text_output(read_output);
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&content).expect("read output should be json");
|
||||
assert_eq!(value["kind"], "shared_note");
|
||||
assert_eq!(value["content"], "Parent progress: inspect parser tests\n");
|
||||
|
||||
ReflectionsWriteSharedNoteHandler
|
||||
.handle(invocation(
|
||||
Arc::clone(&child_session),
|
||||
Arc::clone(&child_turn),
|
||||
"reflections_write_shared_note",
|
||||
function_payload(json!({
|
||||
"note_id": "handoff",
|
||||
"operation": "append",
|
||||
"content": "Child progress: parser tests pass\n"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("child should append shared note");
|
||||
|
||||
let search_output = ReflectionsSearchSharedNotesHandler
|
||||
.handle(invocation(
|
||||
parent_session,
|
||||
parent_turn,
|
||||
"reflections_search_shared_notes",
|
||||
function_payload(json!({
|
||||
"query": "parser tests pass",
|
||||
"start": 1,
|
||||
"stop": 20
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("parent should search shared notes");
|
||||
let (content, _) = expect_text_output(search_output);
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&content).expect("search output should be json");
|
||||
assert_eq!(value["results"][0]["kind"], "shared_note");
|
||||
assert_eq!(
|
||||
value["results"][0]["read"]["tool"],
|
||||
"reflections_read_shared_note"
|
||||
);
|
||||
}
|
||||
|
||||
fn thread_manager() -> ThreadManager {
|
||||
ThreadManager::with_models_provider_for_tests(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
|
||||
435
codex-rs/core/src/tools/handlers/reflections.rs
Normal file
435
codex-rs/core/src/tools/handlers/reflections.rs
Normal file
@@ -0,0 +1,435 @@
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_tools::REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_LIST_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_READ_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
use codex_tools::REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListArgs {
|
||||
collection: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReadArgs {
|
||||
kind: String,
|
||||
id: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchArgs {
|
||||
scope: String,
|
||||
query: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
log_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WriteNoteArgs {
|
||||
note_id: String,
|
||||
operation: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SharedListArgs {
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SharedReadArgs {
|
||||
note_id: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SharedSearchArgs {
|
||||
query: String,
|
||||
start: Option<usize>,
|
||||
stop: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct ReflectionsNewContextWindowHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsNewContextWindowHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
if !matches!(invocation.payload, ToolPayload::Function { .. }) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME} handler received unsupported payload"
|
||||
)));
|
||||
}
|
||||
|
||||
invocation
|
||||
.session
|
||||
.request_reflections_context_window_reset();
|
||||
Ok(FunctionToolOutput::from_text(
|
||||
"A fresh Reflections context window will start after this tool result is recorded."
|
||||
.to_string(),
|
||||
Some(true),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsGetContextRemainingHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsGetContextRemainingHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
if !matches!(invocation.payload, ToolPayload::Function { .. }) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME} handler received unsupported payload"
|
||||
)));
|
||||
}
|
||||
|
||||
let used_tokens = invocation.session.get_total_token_usage().await;
|
||||
let context_window_size = invocation.turn.model_context_window();
|
||||
let remaining_tokens =
|
||||
context_window_size.map(|size| size.saturating_sub(used_tokens).max(0));
|
||||
let content = json!({
|
||||
"context_window_size": context_window_size,
|
||||
"used_tokens": used_tokens,
|
||||
"remaining_tokens": remaining_tokens,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
Ok(FunctionToolOutput::from_text(content, Some(true)))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsListHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsListHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: ListArgs = parse_function_args(&invocation, REFLECTIONS_LIST_TOOL_NAME)?;
|
||||
let (sidecar_path, rollout_path) = reflections_paths(&invocation).await?;
|
||||
let output = match args.collection.as_str() {
|
||||
"logs" => {
|
||||
crate::reflections::list_logs(&sidecar_path, &rollout_path, args.start, args.stop)
|
||||
.await
|
||||
}
|
||||
"notes" => crate::reflections::list_notes(&sidecar_path, args.start, args.stop).await,
|
||||
other => Err(crate::reflections::StorageToolError::Invalid(format!(
|
||||
"unsupported Reflections collection `{other}`"
|
||||
))),
|
||||
}
|
||||
.map_err(storage_error)?;
|
||||
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsReadHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsReadHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: ReadArgs = parse_function_args(&invocation, REFLECTIONS_READ_TOOL_NAME)?;
|
||||
let (sidecar_path, rollout_path) = reflections_paths(&invocation).await?;
|
||||
match args.kind.as_str() {
|
||||
"log" => {
|
||||
let output = crate::reflections::read_log(
|
||||
&sidecar_path,
|
||||
&rollout_path,
|
||||
&args.id,
|
||||
args.start,
|
||||
args.stop,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
"note" => {
|
||||
let output =
|
||||
crate::reflections::read_note(&sidecar_path, &args.id, args.start, args.stop)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
other => Err(FunctionCallError::RespondToModel(format!(
|
||||
"unsupported Reflections read kind `{other}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsSearchHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsSearchHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: SearchArgs = parse_function_args(&invocation, REFLECTIONS_SEARCH_TOOL_NAME)?;
|
||||
if !matches!(args.scope.as_str(), "all" | "logs" | "notes") {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"unsupported Reflections search scope `{}`",
|
||||
args.scope
|
||||
)));
|
||||
}
|
||||
let (sidecar_path, rollout_path) = reflections_paths(&invocation).await?;
|
||||
let output = crate::reflections::search(
|
||||
&sidecar_path,
|
||||
&rollout_path,
|
||||
&args.scope,
|
||||
&args.query,
|
||||
args.log_id.as_deref(),
|
||||
args.start,
|
||||
args.stop,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsWriteNoteHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsWriteNoteHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: WriteNoteArgs =
|
||||
parse_function_args(&invocation, REFLECTIONS_WRITE_NOTE_TOOL_NAME)?;
|
||||
let (sidecar_path, _) = reflections_paths(&invocation).await?;
|
||||
let output = crate::reflections::write_note(
|
||||
&sidecar_path,
|
||||
&args.note_id,
|
||||
&args.operation,
|
||||
&args.content,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsListSharedNotesHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsListSharedNotesHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: SharedListArgs =
|
||||
parse_function_args(&invocation, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME)?;
|
||||
let shared_notes_path = shared_notes_path(&invocation)?;
|
||||
let output =
|
||||
crate::reflections::list_shared_notes(&shared_notes_path, args.start, args.stop)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsReadSharedNoteHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsReadSharedNoteHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: SharedReadArgs =
|
||||
parse_function_args(&invocation, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME)?;
|
||||
let shared_notes_path = shared_notes_path(&invocation)?;
|
||||
let output = crate::reflections::read_shared_note(
|
||||
&shared_notes_path,
|
||||
&args.note_id,
|
||||
args.start,
|
||||
args.stop,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsSearchSharedNotesHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsSearchSharedNotesHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: SharedSearchArgs =
|
||||
parse_function_args(&invocation, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME)?;
|
||||
let shared_notes_path = shared_notes_path(&invocation)?;
|
||||
let output = crate::reflections::search_shared_notes(
|
||||
&shared_notes_path,
|
||||
&args.query,
|
||||
args.start,
|
||||
args.stop,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReflectionsWriteSharedNoteHandler;
|
||||
|
||||
impl ToolHandler for ReflectionsWriteSharedNoteHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let args: WriteNoteArgs =
|
||||
parse_function_args(&invocation, REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME)?;
|
||||
let shared_notes_path = shared_notes_path(&invocation)?;
|
||||
let output = crate::reflections::write_shared_note(
|
||||
&shared_notes_path,
|
||||
&args.note_id,
|
||||
&args.operation,
|
||||
&args.content,
|
||||
)
|
||||
.await
|
||||
.map_err(storage_error)?;
|
||||
serialize_output(output)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_function_args<T>(
|
||||
invocation: &ToolInvocation,
|
||||
tool_name: &str,
|
||||
) -> Result<T, FunctionCallError>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let ToolPayload::Function { arguments } = &invocation.payload else {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{tool_name} handler received unsupported payload"
|
||||
)));
|
||||
};
|
||||
parse_arguments(arguments)
|
||||
}
|
||||
|
||||
async fn reflections_paths(
|
||||
invocation: &ToolInvocation,
|
||||
) -> Result<(PathBuf, PathBuf), FunctionCallError> {
|
||||
invocation
|
||||
.session
|
||||
.try_ensure_rollout_materialized()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"Reflections requires a persisted rollout path: {err}"
|
||||
))
|
||||
})?;
|
||||
invocation.session.flush_rollout().await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to flush Reflections rollout before reading storage: {err}"
|
||||
))
|
||||
})?;
|
||||
let rollout_path = invocation.session.current_rollout_path().await.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"Reflections storage requires a persisted rollout path and is unavailable for ephemeral sessions"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let sidecar_path = crate::reflections::sidecar_path_for_rollout(&rollout_path);
|
||||
Ok((sidecar_path, rollout_path))
|
||||
}
|
||||
|
||||
fn shared_notes_path(invocation: &ToolInvocation) -> Result<PathBuf, FunctionCallError> {
|
||||
invocation
|
||||
.turn
|
||||
.reflections_shared_notes_path
|
||||
.as_ref()
|
||||
.map(|path| path.as_path().to_path_buf())
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"Shared Reflections notes are unavailable in this thread.".to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn storage_error(err: crate::reflections::StorageToolError) -> FunctionCallError {
|
||||
FunctionCallError::RespondToModel(err.to_string())
|
||||
}
|
||||
|
||||
fn serialize_output<T>(output: T) -> Result<FunctionToolOutput, FunctionCallError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
serde_json::to_string(&output)
|
||||
.map(|content| FunctionToolOutput::from_text(content, Some(true)))
|
||||
.map_err(|err| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"failed to serialize Reflections tool output: {err}"
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -72,6 +72,16 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::McpHandler;
|
||||
use crate::tools::handlers::McpResourceHandler;
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::ReflectionsGetContextRemainingHandler;
|
||||
use crate::tools::handlers::ReflectionsListHandler;
|
||||
use crate::tools::handlers::ReflectionsListSharedNotesHandler;
|
||||
use crate::tools::handlers::ReflectionsNewContextWindowHandler;
|
||||
use crate::tools::handlers::ReflectionsReadHandler;
|
||||
use crate::tools::handlers::ReflectionsReadSharedNoteHandler;
|
||||
use crate::tools::handlers::ReflectionsSearchHandler;
|
||||
use crate::tools::handlers::ReflectionsSearchSharedNotesHandler;
|
||||
use crate::tools::handlers::ReflectionsWriteNoteHandler;
|
||||
use crate::tools::handlers::ReflectionsWriteSharedNoteHandler;
|
||||
use crate::tools::handlers::RequestPermissionsHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
@@ -206,6 +216,41 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
ToolHandlerKind::Plan => {
|
||||
builder.register_handler(handler.name, plan_handler.clone());
|
||||
}
|
||||
ToolHandlerKind::ReflectionsGetContextRemaining => {
|
||||
builder.register_handler(
|
||||
handler.name,
|
||||
Arc::new(ReflectionsGetContextRemainingHandler),
|
||||
);
|
||||
}
|
||||
ToolHandlerKind::ReflectionsList => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsListHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsListSharedNotes => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsListSharedNotesHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsNewContextWindow => {
|
||||
builder
|
||||
.register_handler(handler.name, Arc::new(ReflectionsNewContextWindowHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsRead => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsReadHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsReadSharedNote => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsReadSharedNoteHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsSearch => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsSearchHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsSearchSharedNotes => {
|
||||
builder
|
||||
.register_handler(handler.name, Arc::new(ReflectionsSearchSharedNotesHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsWriteSharedNote => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsWriteSharedNoteHandler));
|
||||
}
|
||||
ToolHandlerKind::ReflectionsWriteNote => {
|
||||
builder.register_handler(handler.name, Arc::new(ReflectionsWriteNoteHandler));
|
||||
}
|
||||
ToolHandlerKind::RequestPermissions => {
|
||||
builder.register_handler(handler.name, request_permissions_handler.clone());
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_login::CodexAuth;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::built_in_model_providers;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_protocol::config_types::CompactionStrategyConfig;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
@@ -20,13 +21,19 @@ use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
|
||||
use core_test_support::responses::ev_local_shell_call;
|
||||
use core_test_support::responses::ev_message_item_added;
|
||||
use core_test_support::responses::ev_output_text_delta;
|
||||
use core_test_support::responses::ev_reasoning_item;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
@@ -410,6 +417,377 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reflections_manual_compact_writes_sidecar_without_summarizer_request() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let first_turn = sse(vec![
|
||||
ev_assistant_message("m1", FIRST_REPLY),
|
||||
ev_completed_with_tokens("r1", /*total_tokens*/ 80),
|
||||
]);
|
||||
let request_log = mount_sse_once(&server, first_turn).await;
|
||||
|
||||
let model_provider = non_openai_model_provider(&server);
|
||||
let test = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.model_provider = model_provider;
|
||||
config.compaction_strategy = CompactionStrategyConfig::Reflections;
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
.unwrap();
|
||||
let codex = test.codex.clone();
|
||||
let rollout_path = test.session_configured.rollout_path.expect("rollout path");
|
||||
let sidecar_path = rollout_path.with_extension("reflections");
|
||||
assert!(!sidecar_path.exists());
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "remember this detail".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
assert!(sidecar_path.is_dir());
|
||||
assert!(sidecar_path.join("notes").is_dir());
|
||||
assert!(sidecar_path.join("logs").is_dir());
|
||||
assert!(!sidecar_path.join("logs/cw00000").exists());
|
||||
|
||||
codex.submit(Op::Compact).await.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
assert_eq!(
|
||||
request_log.requests().len(),
|
||||
1,
|
||||
"Reflections /compact should not call the summarizer"
|
||||
);
|
||||
|
||||
codex.submit(Op::Shutdown).await.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let transcript_path = sidecar_path.join("logs/cw00000/transcript.md");
|
||||
let transcript = std::fs::read_to_string(&transcript_path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read Reflections transcript {}: {err}",
|
||||
transcript_path.display()
|
||||
)
|
||||
});
|
||||
assert!(sidecar_path.join("notes").is_dir());
|
||||
assert!(transcript.contains("schema: reflections.transcript.v1"));
|
||||
assert!(transcript.contains("remember this detail"));
|
||||
assert!(transcript.contains(FIRST_REPLY));
|
||||
assert!(transcript.contains("source_rollout:"));
|
||||
assert!(!transcript.contains("Reflections is enabled"));
|
||||
|
||||
let rollout = std::fs::read_to_string(&rollout_path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read rollout file {}: {err}",
|
||||
rollout_path.display()
|
||||
)
|
||||
});
|
||||
assert!(rollout.contains("Reflections is enabled"));
|
||||
assert!(rollout.contains("reflections_list"));
|
||||
assert!(rollout.contains("reflections_read"));
|
||||
assert!(rollout.contains("reflections_search"));
|
||||
assert!(rollout.contains("reflections_write_note"));
|
||||
assert!(!rollout.contains(&format!("{}/logs", sidecar_path.display())));
|
||||
assert!(!rollout.contains("logs/cw00000/transcript.md"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reflections_new_context_window_skips_sibling_tools_and_resumes_fresh() {
|
||||
skip_if_no_network!();
|
||||
|
||||
const AFTER_RESET_TEXT: &str = "SHOULD_NOT_RECORD_AFTER_RESET";
|
||||
const AFTER_RESET_NOTE: &str = "call-note-after-reset";
|
||||
const LIST_LOGS_CALL: &str = "call-list-logs";
|
||||
|
||||
let (after_reset_gate_tx, after_reset_gate_rx) = tokio::sync::oneshot::channel();
|
||||
let first_turn = sse(vec![
|
||||
ev_response_created("r1"),
|
||||
ev_function_call(
|
||||
"call-reflections-reset",
|
||||
REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME,
|
||||
"{}",
|
||||
),
|
||||
]);
|
||||
let after_reset_chunk = sse(vec![
|
||||
ev_message_item_added("msg-empty-after-reset", ""),
|
||||
ev_output_text_delta(AFTER_RESET_TEXT),
|
||||
ev_assistant_message("msg-after-reset", AFTER_RESET_TEXT),
|
||||
ev_function_call(
|
||||
AFTER_RESET_NOTE,
|
||||
"reflections_write_note",
|
||||
r#"{"note_id":"dangling","operation":"append","content":"DANGLING_NOTE"}"#,
|
||||
),
|
||||
ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"),
|
||||
ev_completed_with_tokens("r1", /*total_tokens*/ 100),
|
||||
]);
|
||||
let list_logs_turn = sse(vec![
|
||||
ev_response_created("r2"),
|
||||
ev_function_call(
|
||||
LIST_LOGS_CALL,
|
||||
"reflections_list",
|
||||
r#"{"collection":"logs","start":1,"stop":200}"#,
|
||||
),
|
||||
ev_completed_with_tokens("r2", /*total_tokens*/ 100),
|
||||
]);
|
||||
let final_turn = sse(vec![
|
||||
ev_response_created("r3"),
|
||||
ev_assistant_message("m2", "AFTER_REFLECTIONS_TOOL_RESET"),
|
||||
ev_completed_with_tokens("r3", /*total_tokens*/ 100),
|
||||
]);
|
||||
let (streaming_server, _completion_receivers) = start_streaming_sse_server(vec![
|
||||
vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: first_turn,
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(after_reset_gate_rx),
|
||||
body: after_reset_chunk,
|
||||
},
|
||||
],
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: list_logs_turn,
|
||||
}],
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: final_turn,
|
||||
}],
|
||||
])
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.compaction_strategy = CompactionStrategyConfig::Reflections;
|
||||
});
|
||||
let test = builder
|
||||
.build_with_streaming_server(&streaming_server)
|
||||
.await
|
||||
.unwrap();
|
||||
let codex = test.codex.clone();
|
||||
let rollout_path = test.session_configured.rollout_path.expect("rollout path");
|
||||
let sidecar_path = rollout_path.with_extension("reflections");
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "start work and reset with reflections".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let _ = after_reset_gate_tx.send(());
|
||||
codex.submit(Op::Shutdown).await.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let request_bodies: Vec<String> = streaming_server
|
||||
.requests()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|request| String::from_utf8_lossy(&request).to_string())
|
||||
.collect();
|
||||
streaming_server.shutdown().await;
|
||||
assert_eq!(request_bodies.len(), 3);
|
||||
assert!(
|
||||
body_contains_text(&request_bodies[1], "Your context window was reset"),
|
||||
"follow-up request should resume from the fresh Reflections handoff"
|
||||
);
|
||||
assert!(
|
||||
!request_bodies[1].contains(&format!("unsupported call: {DUMMY_FUNCTION_NAME}")),
|
||||
"sibling tool calls after the reset control tool should not be executed"
|
||||
);
|
||||
assert!(
|
||||
request_bodies[2].contains("cw00000"),
|
||||
"reflections_list(logs) should return the completed reset window"
|
||||
);
|
||||
assert!(
|
||||
!request_bodies[2].contains("cw00001"),
|
||||
"reflections_list(logs) should not expose a partial second window"
|
||||
);
|
||||
assert!(
|
||||
!request_bodies[2].contains(AFTER_RESET_TEXT)
|
||||
&& !request_bodies[2].contains(AFTER_RESET_NOTE)
|
||||
&& !request_bodies[2].contains("DANGLING_NOTE"),
|
||||
"reflections_list(logs) should not include events emitted after the reset tool"
|
||||
);
|
||||
|
||||
let rollout = std::fs::read_to_string(&rollout_path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read rollout file {}: {err}",
|
||||
rollout_path.display()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
rollout.contains(REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME),
|
||||
"reset tool call should be recorded before compaction"
|
||||
);
|
||||
assert!(
|
||||
!rollout.contains(DUMMY_FUNCTION_NAME)
|
||||
&& !rollout.contains(AFTER_RESET_TEXT)
|
||||
&& !rollout.contains("msg-empty-after-reset")
|
||||
&& !rollout.contains(AFTER_RESET_NOTE)
|
||||
&& !rollout.contains("DANGLING_NOTE"),
|
||||
"output after the reset control tool should not be recorded"
|
||||
);
|
||||
|
||||
let state_path = sidecar_path.join("state.json");
|
||||
let state = std::fs::read_to_string(&state_path)
|
||||
.unwrap_or_else(|err| panic!("failed to read {}: {err}", state_path.display()));
|
||||
assert!(state.contains("\"latest_window\": \"cw00000\""));
|
||||
assert!(!state.contains("cw00001"));
|
||||
|
||||
let transcript_path = sidecar_path.join("logs/cw00000/transcript.md");
|
||||
let transcript = std::fs::read_to_string(&transcript_path)
|
||||
.unwrap_or_else(|err| panic!("failed to read {}: {err}", transcript_path.display()));
|
||||
assert!(
|
||||
!transcript.contains(AFTER_RESET_TEXT)
|
||||
&& !transcript.contains(AFTER_RESET_NOTE)
|
||||
&& !transcript.contains("DANGLING_NOTE"),
|
||||
"transcript should stop at the reset control tool"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reflections_near_limit_reminder_is_recorded_once_and_dropped_after_compaction() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let first_turn = sse(vec![
|
||||
ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"),
|
||||
ev_completed_with_tokens("r1", /*total_tokens*/ 6_000),
|
||||
]);
|
||||
let reminder_turn = sse(vec![
|
||||
ev_assistant_message("m2", FINAL_REPLY),
|
||||
ev_completed_with_tokens("r2", /*total_tokens*/ 6_500),
|
||||
]);
|
||||
let post_compaction_turn = sse(vec![
|
||||
ev_assistant_message("m3", "AFTER_REFLECTIONS_COMPACT"),
|
||||
ev_completed_with_tokens("r3", /*total_tokens*/ 100),
|
||||
]);
|
||||
let request_log = mount_sse_sequence(
|
||||
&server,
|
||||
vec![first_turn, reminder_turn, post_compaction_turn],
|
||||
)
|
||||
.await;
|
||||
|
||||
let model_provider = non_openai_model_provider(&server);
|
||||
let test = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.model_provider = model_provider;
|
||||
config.compaction_strategy = CompactionStrategyConfig::Reflections;
|
||||
config.model_context_window = Some(20_000);
|
||||
config.model_auto_compact_token_limit = Some(10_000);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
.unwrap();
|
||||
let codex = test.codex.clone();
|
||||
let rollout_path = test.session_configured.rollout_path.expect("rollout path");
|
||||
let sidecar_path = rollout_path.with_extension("reflections");
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "do work near the context limit".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
codex.submit(Op::Compact).await.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "continue after reflections compact".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
codex.submit(Op::Shutdown).await.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
|
||||
|
||||
let reminder_prefix = "Your context window is nearly exhausted (";
|
||||
let reminder_suffix = "tokens remain before your context will be reset).";
|
||||
let request_bodies: Vec<String> = request_log
|
||||
.requests()
|
||||
.into_iter()
|
||||
.map(|request| request.body_json().to_string())
|
||||
.collect();
|
||||
assert_eq!(request_bodies.len(), 3);
|
||||
assert!(
|
||||
!body_contains_text(&request_bodies[0], reminder_prefix),
|
||||
"first request should not include the reminder before the threshold is crossed"
|
||||
);
|
||||
assert!(
|
||||
body_contains_text(&request_bodies[1], reminder_prefix)
|
||||
&& body_contains_text(&request_bodies[1], reminder_suffix),
|
||||
"second request should include the near-limit developer reminder"
|
||||
);
|
||||
assert!(
|
||||
body_contains_text(&request_bodies[1], "advised NOT to send a final answer"),
|
||||
"near-limit reminder should include the stronger note-writing guidance"
|
||||
);
|
||||
assert!(
|
||||
body_contains_text(&request_bodies[1], "reflections_write_note"),
|
||||
"near-limit reminder should point to the storage note tool"
|
||||
);
|
||||
assert!(
|
||||
request_bodies[1].contains(&format!("unsupported call: {DUMMY_FUNCTION_NAME}")),
|
||||
"tool output should still be sent with the reminder"
|
||||
);
|
||||
assert!(
|
||||
!body_contains_text(&request_bodies[2], reminder_prefix),
|
||||
"reminder should not survive into the fresh context after Reflections compaction"
|
||||
);
|
||||
|
||||
let rollout = std::fs::read_to_string(&rollout_path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read rollout file {}: {err}",
|
||||
rollout_path.display()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
rollout.contains(reminder_prefix),
|
||||
"near-limit reminder should be persisted in the rollout like other developer messages"
|
||||
);
|
||||
|
||||
let transcript_path = sidecar_path.join("logs/cw00000/transcript.md");
|
||||
let transcript = std::fs::read_to_string(&transcript_path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read Reflections transcript {}: {err}",
|
||||
transcript_path.display()
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
!transcript.contains(reminder_prefix),
|
||||
"near-limit reminder should not be copied into visible Reflections transcripts"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manual_compact_uses_custom_prompt() {
|
||||
skip_if_no_network!();
|
||||
|
||||
@@ -21,3 +21,16 @@ impl FeatureConfig for MultiAgentV2ConfigToml {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReflectionsConfigToml {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_hint_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_hint_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub storage_tools_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shared_notes_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use toml::Table;
|
||||
mod feature_configs;
|
||||
mod legacy;
|
||||
pub use feature_configs::MultiAgentV2ConfigToml;
|
||||
pub use feature_configs::ReflectionsConfigToml;
|
||||
use legacy::LegacyFeatureToggles;
|
||||
pub use legacy::legacy_feature_keys;
|
||||
|
||||
@@ -502,6 +503,8 @@ pub fn is_known_feature_key(key: &str) -> bool {
|
||||
pub struct FeaturesToml {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multi_agent_v2: Option<FeatureToml<MultiAgentV2ConfigToml>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reflections: Option<ReflectionsConfigToml>,
|
||||
/// Boolean feature toggles keyed by canonical or legacy feature name.
|
||||
#[serde(flatten)]
|
||||
entries: BTreeMap<String, bool>,
|
||||
|
||||
@@ -359,6 +359,31 @@ usage_hint_enabled = false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflections_feature_config_deserializes_table_without_feature_toggle() {
|
||||
let features_toml: FeaturesToml = toml::from_str(
|
||||
r#"
|
||||
[reflections]
|
||||
usage_hint_enabled = false
|
||||
usage_hint_text = "Custom recovery guidance."
|
||||
storage_tools_enabled = false
|
||||
shared_notes_enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("features table should deserialize");
|
||||
|
||||
assert_eq!(features_toml.entries(), BTreeMap::new());
|
||||
assert_eq!(
|
||||
features_toml.reflections,
|
||||
Some(crate::ReflectionsConfigToml {
|
||||
usage_hint_enabled: Some(false),
|
||||
usage_hint_text: Some("Custom recovery guidance.".to_string()),
|
||||
storage_tools_enabled: Some(false),
|
||||
shared_notes_enabled: Some(false),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstable_warning_event_only_mentions_enabled_under_development_features() {
|
||||
let mut configured_features = Table::new();
|
||||
|
||||
@@ -52,6 +52,17 @@ pub enum Verbosity {
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum CompactionStrategyConfig {
|
||||
#[default]
|
||||
Default,
|
||||
Reflections,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, JsonSchema, TS,
|
||||
)]
|
||||
|
||||
@@ -13,6 +13,7 @@ mod local_tool;
|
||||
mod mcp_resource_tool;
|
||||
mod mcp_tool;
|
||||
mod plan_tool;
|
||||
mod reflections_tool;
|
||||
mod request_user_input_tool;
|
||||
mod responses_api;
|
||||
mod tool_config;
|
||||
@@ -74,6 +75,26 @@ pub use mcp_resource_tool::create_read_mcp_resource_tool;
|
||||
pub use mcp_tool::mcp_call_tool_result_output_schema;
|
||||
pub use mcp_tool::parse_mcp_tool;
|
||||
pub use plan_tool::create_update_plan_tool;
|
||||
pub use reflections_tool::REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_LIST_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_READ_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
pub use reflections_tool::REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME;
|
||||
pub use reflections_tool::create_reflections_get_context_remaining_tool;
|
||||
pub use reflections_tool::create_reflections_list_shared_notes_tool;
|
||||
pub use reflections_tool::create_reflections_list_tool;
|
||||
pub use reflections_tool::create_reflections_new_context_window_tool;
|
||||
pub use reflections_tool::create_reflections_read_shared_note_tool;
|
||||
pub use reflections_tool::create_reflections_read_tool;
|
||||
pub use reflections_tool::create_reflections_search_shared_notes_tool;
|
||||
pub use reflections_tool::create_reflections_search_tool;
|
||||
pub use reflections_tool::create_reflections_write_note_tool;
|
||||
pub use reflections_tool::create_reflections_write_shared_note_tool;
|
||||
pub use request_user_input_tool::REQUEST_USER_INPUT_TOOL_NAME;
|
||||
pub use request_user_input_tool::create_request_user_input_tool;
|
||||
pub use request_user_input_tool::normalize_request_user_input_args;
|
||||
|
||||
359
codex-rs/tools/src/reflections_tool.rs
Normal file
359
codex-rs/tools/src/reflections_tool.rs
Normal file
@@ -0,0 +1,359 @@
|
||||
use crate::JsonSchema;
|
||||
use crate::ResponsesApiTool;
|
||||
use crate::ToolSpec;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME: &str = "reflections_new_context_window";
|
||||
pub const REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME: &str = "reflections_get_context_remaining";
|
||||
pub const REFLECTIONS_LIST_TOOL_NAME: &str = "reflections_list";
|
||||
pub const REFLECTIONS_READ_TOOL_NAME: &str = "reflections_read";
|
||||
pub const REFLECTIONS_SEARCH_TOOL_NAME: &str = "reflections_search";
|
||||
pub const REFLECTIONS_WRITE_NOTE_TOOL_NAME: &str = "reflections_write_note";
|
||||
pub const REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME: &str = "reflections_list_shared_notes";
|
||||
pub const REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME: &str = "reflections_read_shared_note";
|
||||
pub const REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME: &str = "reflections_search_shared_notes";
|
||||
pub const REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME: &str = "reflections_write_shared_note";
|
||||
|
||||
pub fn create_reflections_new_context_window_tool(
|
||||
usage_hint: Option<&str>,
|
||||
storage_tools_enabled: bool,
|
||||
) -> ToolSpec {
|
||||
let recovery_notes_location = if storage_tools_enabled {
|
||||
"with `reflections_write_note`"
|
||||
} else {
|
||||
"under the Reflections notes directory"
|
||||
};
|
||||
let mut description = format!(
|
||||
"Starts a fresh context window for the same task. Use this after you have saved concise recovery notes {recovery_notes_location} when the current context is large or the next steps should continue from durable logs. This is a control-flow tool: after the tool result is recorded, the current response stops and the next model request resumes from the Reflections handoff in a fresh context window."
|
||||
);
|
||||
if let Some(usage_hint) = usage_hint {
|
||||
description.push_str("\n\n");
|
||||
description.push_str(usage_hint);
|
||||
}
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME.to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: empty_parameters(),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_get_context_remaining_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME.to_string(),
|
||||
description: "Returns the estimated context window size, used tokens, and remaining tokens for the current thread."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: empty_parameters(),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_list_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_LIST_TOOL_NAME.to_string(),
|
||||
description: "Lists Reflections logs or notes using backend-neutral IDs. Use this to discover explicit log window IDs such as `cw00003` or durable note IDs before reading them.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"collection".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("logs"), json!("notes")],
|
||||
Some("Which Reflections collection to list.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start position. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop position. Defaults to 50 and returns at most 200 items.".to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["collection"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_read_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_READ_TOOL_NAME.to_string(),
|
||||
description: "Reads a Reflections log window or note by explicit ID. Log IDs are `cwNNNNN`; note IDs are simple slugs. The `latest` alias is not supported.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"kind".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("log"), json!("note")],
|
||||
Some("Whether to read a log window or note.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Explicit log ID such as `cw00003`, or note slug such as `handoff`."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start entry or line. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop entry or line. Defaults to 50 for logs and 1000 for notes."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["kind", "id"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_search_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_SEARCH_TOOL_NAME.to_string(),
|
||||
description: "Searches Reflections notes and/or explicit log windows. Search results include a one-call `read` locator for follow-up with `reflections_read`.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"scope".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("all"), json!("logs"), json!("notes")],
|
||||
Some("Where to search.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"query".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Case-insensitive literal text to search for.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start result position. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop result position. Defaults to 20 and returns at most 100 results."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"log_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional explicit log ID such as `cw00003`; only valid when searching logs."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["scope", "query"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_write_note_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_WRITE_NOTE_TOOL_NAME.to_string(),
|
||||
description: "Creates, appends to, or replaces a durable Reflections note. Note IDs are simple slugs, not file paths. A single note can contain at most 65,536 characters after the write.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"note_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Note slug matching ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ with no `..`."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"operation".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("create_only"), json!("append"), json!("replace")],
|
||||
Some("How to write the note.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"content".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"UTF-8 note content. The individual write and final note are each limited to 65,536 characters."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["note_id", "operation", "content"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_list_shared_notes_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME.to_string(),
|
||||
description: "Lists shared Reflections notes visible to agents in the same agent tree using backend-neutral note IDs. Use this to discover coordination notes before reading them.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start position. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop position. Defaults to 50 and returns at most 200 items.".to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
Vec::new(),
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_read_shared_note_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME.to_string(),
|
||||
description: "Reads a shared Reflections note by explicit note ID. Shared notes are for coordination across agents in the same agent tree. The `latest` alias is not supported.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"note_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Shared note slug matching ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ with no `..`."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start line. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop line. Defaults to 1000 and returns at most 1000 lines."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["note_id"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_search_shared_notes_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME.to_string(),
|
||||
description: "Searches shared Reflections notes visible to agents in the same agent tree. Search results include a one-call locator for `reflections_read_shared_note`.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"query".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Case-insensitive literal text to search for.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"start".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive start result position. Defaults to 1.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"1-based inclusive stop result position. Defaults to 20 and returns at most 100 results."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["query"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_reflections_write_shared_note_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME.to_string(),
|
||||
description: "Creates, appends to, or replaces a shared Reflections note visible to agents in the same agent tree. Shared notes are for coordination state that other agents should see. Note IDs are simple slugs, not file paths. A single note can contain at most 65,536 characters after the write.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"note_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Shared note slug matching ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ with no `..`."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"operation".to_string(),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("create_only"), json!("append"), json!("replace")],
|
||||
Some("How to write the shared note.".to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"content".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"UTF-8 note content. The individual write and final shared note are each limited to 65,536 characters."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["note_id", "operation", "content"],
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_parameters() -> JsonSchema {
|
||||
object(BTreeMap::new(), Vec::new())
|
||||
}
|
||||
|
||||
fn object(properties: BTreeMap<String, JsonSchema>, required: Vec<&str>) -> JsonSchema {
|
||||
JsonSchema::object(
|
||||
properties,
|
||||
Some(required.into_iter().map(str::to_string).collect()),
|
||||
Some(false.into()),
|
||||
)
|
||||
}
|
||||
@@ -104,6 +104,10 @@ pub struct ToolsConfig {
|
||||
pub can_request_original_image_detail: bool,
|
||||
pub collab_tools: bool,
|
||||
pub multi_agent_v2: bool,
|
||||
pub reflections: bool,
|
||||
pub reflections_usage_hint_text: Option<String>,
|
||||
pub reflections_storage_tools: bool,
|
||||
pub reflections_shared_notes: bool,
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub spawn_agent_usage_hint: bool,
|
||||
pub spawn_agent_usage_hint_text: Option<String>,
|
||||
@@ -225,6 +229,10 @@ impl ToolsConfig {
|
||||
can_request_original_image_detail: include_original_image_detail,
|
||||
collab_tools: include_collab_tools,
|
||||
multi_agent_v2: include_multi_agent_v2,
|
||||
reflections: false,
|
||||
reflections_usage_hint_text: None,
|
||||
reflections_storage_tools: true,
|
||||
reflections_shared_notes: true,
|
||||
hide_spawn_agent_metadata: false,
|
||||
spawn_agent_usage_hint: true,
|
||||
spawn_agent_usage_hint_text: None,
|
||||
@@ -254,6 +262,20 @@ impl ToolsConfig {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_reflections(
|
||||
mut self,
|
||||
reflections: bool,
|
||||
reflections_usage_hint_text: Option<String>,
|
||||
reflections_storage_tools: bool,
|
||||
reflections_shared_notes: bool,
|
||||
) -> Self {
|
||||
self.reflections = reflections;
|
||||
self.reflections_usage_hint_text = reflections_usage_hint_text;
|
||||
self.reflections_storage_tools = reflections_storage_tools;
|
||||
self.reflections_shared_notes = reflections_shared_notes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_hide_spawn_agent_metadata(mut self, hide_spawn_agent_metadata: bool) -> Self {
|
||||
self.hide_spawn_agent_metadata = hide_spawn_agent_metadata;
|
||||
self
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
use crate::CommandToolOptions;
|
||||
use crate::REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME;
|
||||
use crate::REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME;
|
||||
use crate::REFLECTIONS_LIST_TOOL_NAME;
|
||||
use crate::REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME;
|
||||
use crate::REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME;
|
||||
use crate::REFLECTIONS_READ_TOOL_NAME;
|
||||
use crate::REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME;
|
||||
use crate::REFLECTIONS_SEARCH_TOOL_NAME;
|
||||
use crate::REFLECTIONS_WRITE_NOTE_TOOL_NAME;
|
||||
use crate::REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME;
|
||||
use crate::REQUEST_USER_INPUT_TOOL_NAME;
|
||||
use crate::ShellToolOptions;
|
||||
use crate::SpawnAgentToolOptions;
|
||||
@@ -33,6 +43,16 @@ use crate::create_list_mcp_resource_templates_tool;
|
||||
use crate::create_list_mcp_resources_tool;
|
||||
use crate::create_local_shell_tool;
|
||||
use crate::create_read_mcp_resource_tool;
|
||||
use crate::create_reflections_get_context_remaining_tool;
|
||||
use crate::create_reflections_list_shared_notes_tool;
|
||||
use crate::create_reflections_list_tool;
|
||||
use crate::create_reflections_new_context_window_tool;
|
||||
use crate::create_reflections_read_shared_note_tool;
|
||||
use crate::create_reflections_read_tool;
|
||||
use crate::create_reflections_search_shared_notes_tool;
|
||||
use crate::create_reflections_search_tool;
|
||||
use crate::create_reflections_write_note_tool;
|
||||
use crate::create_reflections_write_shared_note_tool;
|
||||
use crate::create_report_agent_job_result_tool;
|
||||
use crate::create_request_permissions_tool;
|
||||
use crate::create_request_user_input_tool;
|
||||
@@ -247,6 +267,102 @@ pub fn build_tool_registry_plan(
|
||||
plan.register_handler("request_permissions", ToolHandlerKind::RequestPermissions);
|
||||
}
|
||||
|
||||
if config.reflections {
|
||||
plan.push_spec(
|
||||
create_reflections_new_context_window_tool(
|
||||
config.reflections_usage_hint_text.as_deref(),
|
||||
config.reflections_storage_tools,
|
||||
),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_get_context_remaining_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsNewContextWindow,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsGetContextRemaining,
|
||||
);
|
||||
|
||||
if config.reflections_storage_tools {
|
||||
plan.push_spec(
|
||||
create_reflections_list_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_read_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_search_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_write_note_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.register_handler(REFLECTIONS_LIST_TOOL_NAME, ToolHandlerKind::ReflectionsList);
|
||||
plan.register_handler(REFLECTIONS_READ_TOOL_NAME, ToolHandlerKind::ReflectionsRead);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_SEARCH_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsSearch,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsWriteNote,
|
||||
);
|
||||
|
||||
if config.reflections_shared_notes {
|
||||
plan.push_spec(
|
||||
create_reflections_list_shared_notes_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_read_shared_note_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_search_shared_notes_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.push_spec(
|
||||
create_reflections_write_shared_note_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsListSharedNotes,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsReadSharedNote,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsSearchSharedNotes,
|
||||
);
|
||||
plan.register_handler(
|
||||
REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME,
|
||||
ToolHandlerKind::ReflectionsWriteSharedNote,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.search_tool
|
||||
&& let Some(deferred_mcp_tools) = params.deferred_mcp_tools
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::ResponsesApiTool;
|
||||
use crate::ResponsesApiWebSearchFilters;
|
||||
use crate::ResponsesApiWebSearchUserLocation;
|
||||
use crate::ToolHandlerSpec;
|
||||
use crate::ToolName;
|
||||
use crate::ToolNamespace;
|
||||
use crate::ToolRegistryPlanDeferredTool;
|
||||
use crate::ToolsConfigParams;
|
||||
@@ -632,6 +633,249 @@ fn request_permissions_tool_is_independent_from_additional_permissions() {
|
||||
assert_lacks_tool_name(&tools, "request_permissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflections_tools_require_config_flag() {
|
||||
let model_info = model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.disable(Feature::Collab);
|
||||
let available_models = Vec::new();
|
||||
let default_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let (tools, _) = build_specs(
|
||||
&default_tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_NOTE_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME);
|
||||
|
||||
let reflections_tools_config = default_tools_config.clone().with_reflections(
|
||||
true,
|
||||
Some("Custom Reflections hint.".to_string()),
|
||||
/*reflections_storage_tools*/ true,
|
||||
/*reflections_shared_notes*/ false,
|
||||
);
|
||||
let (tools, handlers) = build_specs(
|
||||
&reflections_tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_contains_tool_names(
|
||||
&tools,
|
||||
&[
|
||||
REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME,
|
||||
REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME,
|
||||
REFLECTIONS_LIST_TOOL_NAME,
|
||||
REFLECTIONS_READ_TOOL_NAME,
|
||||
REFLECTIONS_SEARCH_TOOL_NAME,
|
||||
REFLECTIONS_WRITE_NOTE_TOOL_NAME,
|
||||
],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME);
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsNewContextWindow,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsGetContextRemaining,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_LIST_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsList,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_READ_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsRead,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_SEARCH_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsSearch,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_WRITE_NOTE_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsWriteNote,
|
||||
}));
|
||||
|
||||
let new_context_tool = find_tool(&tools, REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME);
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &new_context_tool.spec else {
|
||||
panic!("{REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME} should be a function tool");
|
||||
};
|
||||
assert!(description.contains("Custom Reflections hint."));
|
||||
|
||||
let path_based_reflections_tools_config = default_tools_config.with_reflections(
|
||||
true,
|
||||
Some("Custom Reflections hint.".to_string()),
|
||||
/*reflections_storage_tools*/ false,
|
||||
/*reflections_shared_notes*/ true,
|
||||
);
|
||||
let (tools, handlers) = build_specs(
|
||||
&path_based_reflections_tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_contains_tool_names(
|
||||
&tools,
|
||||
&[
|
||||
REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME,
|
||||
REFLECTIONS_GET_CONTEXT_REMAINING_TOOL_NAME,
|
||||
],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_NOTE_TOOL_NAME);
|
||||
assert!(!handlers.iter().any(|handler| matches!(
|
||||
handler.kind,
|
||||
ToolHandlerKind::ReflectionsList
|
||||
| ToolHandlerKind::ReflectionsRead
|
||||
| ToolHandlerKind::ReflectionsSearch
|
||||
| ToolHandlerKind::ReflectionsWriteNote
|
||||
)));
|
||||
let new_context_tool = find_tool(&tools, REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME);
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &new_context_tool.spec else {
|
||||
panic!("{REFLECTIONS_NEW_CONTEXT_WINDOW_TOOL_NAME} should be a function tool");
|
||||
};
|
||||
assert!(description.contains("under the Reflections notes directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflections_shared_note_tools_require_reflections_storage_and_availability() {
|
||||
let model_info = model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Collab);
|
||||
let available_models = Vec::new();
|
||||
let default_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
|
||||
let disabled_by_storage = default_tools_config.clone().with_reflections(
|
||||
true, None, /*reflections_storage_tools*/ false,
|
||||
/*reflections_shared_notes*/ true,
|
||||
);
|
||||
let (tools, _) = build_specs(
|
||||
&disabled_by_storage,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME);
|
||||
|
||||
let disabled_by_config = default_tools_config.clone().with_reflections(
|
||||
true, None, /*reflections_storage_tools*/ true,
|
||||
/*reflections_shared_notes*/ false,
|
||||
);
|
||||
let (tools, _) = build_specs(
|
||||
&disabled_by_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME);
|
||||
|
||||
let mut no_collab_features = Features::with_defaults();
|
||||
no_collab_features.disable(Feature::Collab);
|
||||
let no_collab_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &no_collab_features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.with_reflections(
|
||||
true, None, /*reflections_storage_tools*/ true, /*reflections_shared_notes*/ true,
|
||||
);
|
||||
let (tools, _) = build_specs(
|
||||
&no_collab_tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, "spawn_agent");
|
||||
assert_contains_tool_names(
|
||||
&tools,
|
||||
&[
|
||||
REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME,
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME,
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME,
|
||||
REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME,
|
||||
],
|
||||
);
|
||||
|
||||
let enabled = default_tools_config.with_reflections(
|
||||
true, None, /*reflections_storage_tools*/ true, /*reflections_shared_notes*/ true,
|
||||
);
|
||||
let (tools, handlers) = build_specs(
|
||||
&enabled,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_contains_tool_names(
|
||||
&tools,
|
||||
&[
|
||||
REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME,
|
||||
REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME,
|
||||
REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME,
|
||||
REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME,
|
||||
],
|
||||
);
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_LIST_SHARED_NOTES_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsListSharedNotes,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_READ_SHARED_NOTE_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsReadSharedNote,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_SEARCH_SHARED_NOTES_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsSearchSharedNotes,
|
||||
}));
|
||||
assert!(handlers.contains(&ToolHandlerSpec {
|
||||
name: ToolName::plain(REFLECTIONS_WRITE_SHARED_NOTE_TOOL_NAME),
|
||||
kind: ToolHandlerKind::ReflectionsWriteSharedNote,
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_requires_feature_flag() {
|
||||
let model_info = model_info();
|
||||
|
||||
@@ -26,6 +26,16 @@ pub enum ToolHandlerKind {
|
||||
Mcp,
|
||||
McpResource,
|
||||
Plan,
|
||||
ReflectionsGetContextRemaining,
|
||||
ReflectionsList,
|
||||
ReflectionsListSharedNotes,
|
||||
ReflectionsNewContextWindow,
|
||||
ReflectionsRead,
|
||||
ReflectionsReadSharedNote,
|
||||
ReflectionsSearch,
|
||||
ReflectionsSearchSharedNotes,
|
||||
ReflectionsWriteSharedNote,
|
||||
ReflectionsWriteNote,
|
||||
RequestPermissions,
|
||||
RequestUserInput,
|
||||
ResumeAgentV1,
|
||||
|
||||
Reference in New Issue
Block a user