Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/core/src/session/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1410,6 +1410,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::TurnDiff(_)
| EventMsg::RealtimeConversationListVoicesResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::PhaseDeclared(_)
| EventMsg::TurnAborted(_)
| EventMsg::ShutdownComplete
| EventMsg::EnteredReviewMode(_)
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/core/src/tools/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
96 changes: 96 additions & 0 deletions codex-rs/core/src/tools/handlers/phase.rs
Original file line number Diff line number Diff line change
@@ -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<ToolInvocation> 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<Box<dyn crate::tools::context::ToolOutput>, 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<DeclarePhaseArgs, FunctionCallError> {
serde_json::from_str::<DeclarePhaseArgs>(arguments).map_err(|e| {
FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e}"))
})
}
78 changes: 78 additions & 0 deletions codex-rs/core/src/tools/handlers/phase_spec.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
6 changes: 6 additions & 0 deletions codex-rs/core/src/tools/spec_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions codex-rs/mcp-server/src/codex_tool_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions codex-rs/protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions codex-rs/protocol/src/phase_tool.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}
5 changes: 5 additions & 0 deletions codex-rs/protocol/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/rollout-trace/src/protocol_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option<ToolRuntimeTr
| EventMsg::TurnDiff(_)
| EventMsg::RealtimeConversationListVoicesResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::PhaseDeclared(_)
| EventMsg::TurnAborted(_)
| EventMsg::ShutdownComplete
| EventMsg::EnteredReviewMode(_)
Expand Down Expand Up @@ -337,6 +338,7 @@ pub(crate) fn wrapped_protocol_event_type(event: &EventMsg) -> Option<&'static s
| EventMsg::TurnDiff(_)
| EventMsg::RealtimeConversationListVoicesResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::PhaseDeclared(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_)
| EventMsg::RawResponseItem(_)
Expand Down
1 change: 1 addition & 0 deletions codex-rs/rollout/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(_)
Expand Down
Loading