Add opt-in nonfatal handling for clock read failures (#45825)

## Why

Clock provider failures can abort a turn while preparing time context or running clock tools. Allow turns to continue with an explicit indication that the current time is unavailable.

## What changed

- Add `features.nonfatal_clock_read_errors`, disabled by default, to report clock failures to the model without failing the turn.
- Emit a generic `failed to read current time` notice for context reads and tool errors, without exposing provider error details. Deduplicate context notices per turn and compaction window, and remove inherited notices from forked subagent context.
- Omit unavailable environment dates and explicitly clear previously visible dates with `<current_date status="unavailable" />`.
- Return external sleep clock failures to the model when the feature is enabled, preserving sleep item completion notifications.

## Testing

Add coverage for continued inference after clock failures, notice deduplication across compaction, subagent notice filtering, environment date removal and recovery, and sleep failures during initial and polling reads.

GitOrigin-RevId: a39c3723c06a6f786d8ad59d667e8b9f626c973e
This commit is contained in:
Vivian Fang
2026-09-16 01:16:35 +00:00
committed by copyberry
parent fac58c1153
commit 5bf132cd52
14 changed files with 367 additions and 48 deletions
@@ -31,6 +31,7 @@ use core_test_support::assert_regex_match;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_completed_with_tokens;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::ev_response_created;
@@ -53,6 +54,8 @@ const SECOND_REMINDER: &str =
"<current_time_reminder>It is 2026-06-17 17:35:15 UTC.</current_time_reminder>";
const THIRD_REMINDER: &str =
"<current_time_reminder>It is 2026-06-17 17:36:15 UTC.</current_time_reminder>";
const CLOCK_UNAVAILABLE: &str =
"<current_time_unavailable>failed to read current time</current_time_unavailable>";
const FIRST_TIME_UNIX_SECONDS: i64 = 1_781_717_655;
#[derive(Clone, Copy)]
@@ -648,6 +651,86 @@ async fn time_provider_failure_stops_before_inference() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn opted_in_clock_failures_reach_the_model_without_aborting() -> Result<()> {
skip_if_no_network!(Ok(()));
const CALL_ID: &str = "failed-current-time";
let server = start_mock_server().await;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(CALL_ID, "clock", "curr_time", "{}"),
ev_completed_with_tokens("resp-1", /*total_tokens*/ 80),
]),
sse(vec![
ev_response_created("resp-2"),
ev_function_call_with_namespace("clock-before-compact", "clock", "curr_time", "{}"),
ev_completed_with_tokens("resp-2", /*total_tokens*/ 500),
]),
sse(vec![
ev_response_created("resp-compact"),
ev_assistant_message("msg-compact", "compact summary"),
ev_completed("resp-compact"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_completed_with_tokens("resp-3", /*total_tokens*/ 80),
]),
],
)
.await;
let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone();
model_provider.name = "OpenAI-compatible test provider".to_string();
model_provider.base_url = Some(format!("{}/v1", server.uri()));
model_provider.supports_websockets = false;
let test = test_codex()
.with_config(move |config| {
config.model_provider = model_provider;
config.model_auto_compact_token_limit = Some(200);
enable_current_time_reminder(config, /*interval*/ 0, CurrentTimeSource::External);
config.include_environment_context = true;
config
.features
.enable(Feature::NonfatalClockReadErrors)
.unwrap();
})
.with_external_time_provider(Arc::new(FailingTimeProvider))
.build_with_auto_env(&server)
.await?;
test.submit_turn("continue in a new context window").await?;
let requests = responses.requests();
assert_eq!(requests.len(), 4);
assert_eq!(
requests[1].function_call_output_text(CALL_ID),
Some("failed to read current time".to_string()),
);
for index in [0, 1, 3] {
let request = &requests[index];
let developer_messages = request.message_input_texts("developer");
assert_eq!(
developer_messages
.iter()
.map(|text| text.matches(CLOCK_UNAVAILABLE).count())
.sum::<usize>(),
1,
"request {index} developer messages: {developer_messages:?}",
);
assert!(current_time_reminders(request).is_empty());
assert!(
!request
.message_input_texts("user")
.iter()
.any(|text| text.contains("<current_date>"))
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn current_time_tool_returns_the_latest_time() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1623,8 +1623,14 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co
.features
.enable(Feature::CurrentTimeReminder)
.expect("test config should allow feature update");
config
.features
.enable(Feature::NonfatalClockReadErrors)
.expect("test config should allow feature update");
config.include_environment_context = false;
config.current_time_reminder = Some(CurrentTimeReminderConfig {
reminder_interval_seconds: 0,
clock_source: codex_features::CurrentTimeSource::External,
..CurrentTimeReminderConfig::default()
});
}
@@ -1652,6 +1658,31 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co
if matches!(selection, FullHistoryV2ModelSelection::WorldStateIdentity) {
builder = builder.with_history_mode(ThreadHistoryMode::Paginated);
}
if matches!(selection, FullHistoryV2ModelSelection::CurrentTimeReminders) {
#[derive(Default)]
struct FailFirstClockRead(std::sync::atomic::AtomicBool);
impl codex_core::TimeProvider for FailFirstClockRead {
fn current_time(&self, _thread_id: ThreadId) -> codex_core::TimeFuture<'_> {
let already_read = self.0.swap(true, std::sync::atomic::Ordering::Relaxed);
Box::pin(async move {
anyhow::ensure!(already_read, "parent clock unavailable");
Ok(chrono::Utc::now())
})
}
fn sleep(
&self,
_thread_id: ThreadId,
_duration: Duration,
) -> codex_core::SleepFuture<'_> {
Box::pin(async { Ok(()) })
}
}
builder =
builder.with_external_time_provider(std::sync::Arc::new(FailFirstClockRead::default()));
}
let test = builder.build(&server).await?;
if matches!(selection, FullHistoryV2ModelSelection::WorldStateIdentity) {
test.codex.submit(Op::Compact).await?;
@@ -1806,15 +1837,23 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co
);
}
if matches!(selection, FullHistoryV2ModelSelection::CurrentTimeReminders) {
let reminder_count = |request: &ResponsesRequest| {
let notice_count = |request: &ResponsesRequest, marker: &str| {
request
.message_input_texts("developer")
.into_iter()
.filter(|text| text.starts_with("<current_time_reminder>"))
.filter(|text| text.starts_with(marker))
.count()
};
assert_eq!(reminder_count(&parent_request), 2);
assert_eq!(reminder_count(&child_request), 1);
assert_eq!(
notice_count(&parent_request, "<current_time_unavailable>"),
1
);
assert_eq!(notice_count(&parent_request, "<current_time_reminder>"), 1);
assert_eq!(
notice_count(&child_request, "<current_time_unavailable>"),
0
);
assert_eq!(notice_count(&child_request, "<current_time_reminder>"), 1);
}
let child_body = child_request.body_json();
if matches!(selection, FullHistoryV2ModelSelection::WorldStateIdentity) {