From b5dfb8bdbdce85f274120c16d0926ee3be8d7b91 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Sun, 5 Jul 2026 09:54:54 +0100 Subject: [PATCH] feat(protocol): add declared-phase faculty (declare_phase tool) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for Auto mode's bijective climber. The model declares its cognitive phase each turn (slow = context loading, fast = execution) via a new declare_phase tool, emitting EventMsg::PhaseDeclared. Why: the climber guard must distinguish a legitimate slow-phase turn (produces no plan step on purpose) from a ralph loop. A phase transition is a structural climb signal, not an inference — Structure Determines Action. - protocol: Phase enum (Slow/Fast), DeclarePhaseArgs, EventMsg::PhaseDeclared - core: declare_phase tool spec + PhaseHandler (parses args, emits event), registered alongside update_plan - rollout/rollout-trace/mcp-server: cover PhaseDeclared in EventMsg matches The faculty is registered but inert until Auto mode + the climber guard consume it (next slice). declare_phase emits PhaseDeclared into the event stream; the guard reads it from there. Tests: spec well-formed + args round-trip. codex-core builds clean. Co-Authored-By: Claude --- codex-rs/core/src/session/turn.rs | 1 + codex-rs/core/src/tools/handlers/mod.rs | 3 + codex-rs/core/src/tools/handlers/phase.rs | 96 +++++++++++++++++++ .../core/src/tools/handlers/phase_spec.rs | 78 +++++++++++++++ codex-rs/core/src/tools/spec_plan.rs | 6 ++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/phase_tool.rs | 36 +++++++ codex-rs/protocol/src/protocol.rs | 5 + codex-rs/rollout-trace/src/protocol_event.rs | 2 + codex-rs/rollout/src/policy.rs | 1 + 11 files changed, 230 insertions(+) create mode 100644 codex-rs/core/src/tools/handlers/phase.rs create mode 100644 codex-rs/core/src/tools/handlers/phase_spec.rs create mode 100644 codex-rs/protocol/src/phase_tool.rs diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 9d4e7d1e8288..4099025918ec 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1410,6 +1410,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::TurnDiff(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::PlanUpdate(_) + | EventMsg::PhaseDeclared(_) | EventMsg::TurnAborted(_) | EventMsg::ShutdownComplete | EventMsg::EnteredReviewMode(_) diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index e7322156fdc3..b00e52818e85 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -15,6 +15,8 @@ pub(crate) mod multi_agents_spec; pub(crate) mod multi_agents_v2; mod plan; pub(crate) mod plan_spec; +mod phase; +pub(crate) mod phase_spec; mod request_permissions; mod request_plugin_install; pub(crate) mod request_plugin_install_spec; @@ -66,6 +68,7 @@ pub use mcp_resource::ListMcpResourceTemplatesHandler; pub use mcp_resource::ListMcpResourcesHandler; pub use mcp_resource::ReadMcpResourceHandler; pub use plan::PlanHandler; +pub use phase::PhaseHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_plugin_install::RequestPluginInstallHandler; pub use request_user_input::RequestUserInputHandler; diff --git a/codex-rs/core/src/tools/handlers/phase.rs b/codex-rs/core/src/tools/handlers/phase.rs new file mode 100644 index 000000000000..237c1f7884a5 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/phase.rs @@ -0,0 +1,96 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::handlers::phase_spec::create_declare_phase_tool; +use crate::tools::registry::CoreToolRuntime; +use crate::tools::registry::ToolExecutor; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::phase_tool::DeclarePhaseArgs; +use codex_protocol::protocol::EventMsg; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use serde_json::Value as JsonValue; + +pub struct PhaseHandler; + +pub struct PhaseToolOutput { + phase: String, +} + +const PHASE_DECLARED_MESSAGE: &str = "Phase declared"; + +impl ToolOutput for PhaseToolOutput { + fn log_preview(&self) -> String { + format!("{PHASE_DECLARED_MESSAGE}: {}", self.phase) + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { + let mut output = FunctionCallOutputPayload::from_text(PHASE_DECLARED_MESSAGE.to_string()); + output.success = Some(true); + + ResponseInputItem::FunctionCallOutput { + call_id: call_id.to_string(), + output, + } + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + JsonValue::Object(serde_json::Map::new()) + } +} + +#[async_trait::async_trait] +impl ToolExecutor for PhaseHandler { + fn tool_name(&self) -> ToolName { + ToolName::plain("declare_phase") + } + + fn spec(&self) -> ToolSpec { + create_declare_phase_tool() + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + let ToolInvocation { + session, + turn, + call_id: _, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "declare_phase handler received unsupported payload".to_string(), + )); + } + }; + + let args = parse_declare_phase_arguments(&arguments)?; + let phase = format!("{:?}", args.phase).to_lowercase(); + session + .send_event(turn.as_ref(), EventMsg::PhaseDeclared(args)) + .await; + + Ok(boxed_tool_output(PhaseToolOutput { phase })) + } +} + +impl CoreToolRuntime for PhaseHandler {} + +fn parse_declare_phase_arguments(arguments: &str) -> Result { + serde_json::from_str::(arguments).map_err(|e| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e}")) + }) +} diff --git a/codex-rs/core/src/tools/handlers/phase_spec.rs b/codex-rs/core/src/tools/handlers/phase_spec.rs new file mode 100644 index 000000000000..ad95d004de37 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/phase_spec.rs @@ -0,0 +1,78 @@ +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolSpec; +use serde_json::json; +use std::collections::BTreeMap; + +/// The `declare_phase` tool spec. +/// +/// This is the structural handle the Auto-mode climber reads: each turn the +/// agent declares which cognitive phase it is in (`slow` = loading context, +/// `fast` = executing the plan). A phase transition counts as a valid climb +/// step, so a slow-phase turn that completes no plan step is not penalized as a +/// ralph loop. +pub fn create_declare_phase_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "phase".to_string(), + JsonSchema::string_enum( + vec![json!("slow"), json!("fast")], + Some( + "The cognitive phase this turn is in: slow (loading context) or fast (executing)." + .to_string(), + ), + ), + ), + ( + "intent".to_string(), + JsonSchema::string(Some( + "Optional one-line intent for this phase move (e.g. 'loading auth refactor context')." + .to_string(), + )), + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "declare_phase".to_string(), + description: "Declare the cognitive phase of the current turn (slow = context loading, fast = execution). Call this at the start of each turn so the Auto-mode climber can distinguish genuine slow-phase loading from a stuck loop." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + Some(vec!["phase".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::phase_tool::{DeclarePhaseArgs, Phase}; + + #[test] + fn declare_phase_spec_is_well_formed() { + let spec = create_declare_phase_tool(); + let codex_tools::ToolSpec::Function(f) = spec else { + panic!("expected function spec"); + }; + assert_eq!(f.name, "declare_phase"); + // The serialized schema must advertise `phase` as required. + let schema_json = serde_json::to_string(&f.parameters).unwrap(); + assert!(schema_json.contains("\"phase\""), "schema: {schema_json}"); + } + + #[test] + fn declare_phase_args_parse_round_trip() { + let args: DeclarePhaseArgs = + serde_json::from_str(r#"{"phase":"slow","intent":"loading auth context"}"#).unwrap(); + assert_eq!(args.phase, Phase::Slow); + assert_eq!(args.intent.as_deref(), Some("loading auth context")); + + let fast: DeclarePhaseArgs = serde_json::from_str(r#"{"phase":"fast"}"#).unwrap(); + assert_eq!(fast.phase, Phase::Fast); + assert!(fast.intent.is_none()); + } +} diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index d8e808ee4ce5..786252ee6f98 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -13,6 +13,7 @@ use crate::tools::handlers::ListAvailablePluginsToInstallHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; use crate::tools::handlers::McpHandler; +use crate::tools::handlers::PhaseHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadMcpResourceHandler; use crate::tools::handlers::RequestPermissionsHandler; @@ -653,6 +654,11 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut planned_tools.add(PlanHandler); + // The declared-phase faculty: the model emits its cognitive phase each + // turn so the Auto-mode climber can distinguish slow-phase loading from a + // stuck loop. + planned_tools.add(PhaseHandler); + if turn_context.config.experimental_request_user_input_enabled { planned_tools.add(RequestUserInputHandler { available_modes: request_user_input_available_modes(features), diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 21793cc696cb..be3423ed2682 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -349,6 +349,7 @@ async fn run_codex_tool_session_inner( | EventMsg::WebSearchBegin(_) | EventMsg::WebSearchEnd(_) | EventMsg::PlanUpdate(_) + | EventMsg::PhaseDeclared(_) | EventMsg::TurnAborted(_) | EventMsg::UserMessage(_) | EventMsg::ShutdownComplete diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 63053159c6e4..044ae5344655 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -24,6 +24,7 @@ pub mod openai_models; pub mod parse_command; pub mod permissions; pub mod plan_tool; +pub mod phase_tool; pub mod protocol; pub mod request_permissions; pub mod request_user_input; diff --git a/codex-rs/protocol/src/phase_tool.rs b/codex-rs/protocol/src/phase_tool.rs new file mode 100644 index 000000000000..9a5a98159384 --- /dev/null +++ b/codex-rs/protocol/src/phase_tool.rs @@ -0,0 +1,36 @@ +//! Types for the `declare_phase` tool. +//! +//! `declare_phase` is the structural handle the Auto-mode climber reads to +//! distinguish a legitimate slow-phase turn (context loading — produces no plan +//! step on purpose) from a ralph loop (wheel-spin). The model declares its +//! cognitive phase each turn; the climber guard treats a phase transition as a +//! valid climb step. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// The two cognitive phases of the chess-master loop. +/// +/// `Slow` is the loading phase: context gathering, reads, reasoning. It looks +/// inert but is genuine progress. `Fast` is the explosive execution phase: +/// plan steps fall in rapid succession. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + Slow, + Fast, +} + +/// Arguments for the `declare_phase` tool. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct DeclarePhaseArgs { + /// The cognitive phase the agent is entering with this turn. + pub phase: Phase, + /// Optional one-line intent: what this phase move is for (e.g. "loading + /// context around the auth refactor" or "executing the plan"). + #[serde(default)] + pub intent: Option, +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0572f9612846..1ef478a2bbcd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -47,6 +47,7 @@ use crate::models::WebSearchAction; use crate::num_format::format_with_separators; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; +use crate::phase_tool::DeclarePhaseArgs; use crate::plan_tool::UpdatePlanArgs; use crate::request_permissions::RequestPermissionsEvent; use crate::request_permissions::RequestPermissionsResponse; @@ -1307,6 +1308,10 @@ pub enum EventMsg { PlanUpdate(UpdatePlanArgs), + /// The agent declared a cognitive phase transition (Auto-mode climber + /// signal). Emitted by the `declare_phase` tool handler. + PhaseDeclared(DeclarePhaseArgs), + TurnAborted(TurnAbortedEvent), /// Notification that the agent is shutting down. diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index a3e1f184ecdf..fc9c748638b8 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -263,6 +263,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::TurnDiff(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::PlanUpdate(_) + | EventMsg::PhaseDeclared(_) | EventMsg::EnteredReviewMode(_) | EventMsg::ExitedReviewMode(_) | EventMsg::RawResponseItem(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 17da9e62038e..304a639c61f3 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -140,6 +140,7 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::McpStartupComplete(_) | EventMsg::WebSearchBegin(_) | EventMsg::PlanUpdate(_) + | EventMsg::PhaseDeclared(_) | EventMsg::ShutdownComplete | EventMsg::DeprecationNotice(_) | EventMsg::ItemStarted(_)