Codex Handbook
core/src/tools/handlers/view_image.rs 428 lines
use codex_features::Feature;use codex_protocol::items::ImageViewItem;use codex_protocol::items::TurnItem;use codex_protocol::models::DEFAULT_IMAGE_DETAIL;use codex_protocol::models::FunctionCallOutputBody;use codex_protocol::models::FunctionCallOutputContentItem;use codex_protocol::models::FunctionCallOutputPayload;use codex_protocol::models::ImageDetail;use codex_protocol::models::ResponseInputItem;use codex_protocol::openai_models::InputModality;use codex_utils_image::PromptImageMode;use codex_utils_image::data_url_from_bytes;use codex_utils_image::load_for_prompt_bytes;use serde::Deserialize;use crate::function_tool::FunctionCallError;use crate::original_image_detail::can_request_original_image_detail;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::parse_arguments;use crate::tools::handlers::resolve_tool_environment;use crate::tools::handlers::view_image_spec::ViewImageToolOptions;use crate::tools::handlers::view_image_spec::create_view_image_tool;use crate::tools::registry::CoreToolRuntime;use crate::tools::registry::ToolExecutor;use codex_tools::ToolName;use codex_tools::ToolSpec;use codex_utils_path_uri::PathUri;pub struct ViewImageHandler {    options: ViewImageToolOptions,}impl Default for ViewImageHandler {    fn default() -> Self {        Self {            options: ViewImageToolOptions {                can_request_original_image_detail: false,                include_environment_id: false,            },        }    }}impl ViewImageHandler {    pub(crate) fn new(options: ViewImageToolOptions) -> Self {        Self { options }    }}const VIEW_IMAGE_UNSUPPORTED_MESSAGE: &str =    "view_image is not allowed because you do not support image inputs";#[derive(Deserialize)]struct ViewImageArgs {    path: String,    #[serde(default)]    environment_id: Option<String>,    detail: Option<String>,}#[derive(Clone, Copy, Eq, PartialEq)]enum ViewImageDetail {    High,    Original,}impl ToolExecutor<ToolInvocation> for ViewImageHandler {    fn tool_name(&self) -> ToolName {        ToolName::plain("view_image")    }    fn spec(&self) -> ToolSpec {        create_view_image_tool(self.options)    }    fn supports_parallel_tool_calls(&self) -> bool {        true    }    fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {        Box::pin(self.handle_call(invocation))    }}impl ViewImageHandler {    async fn handle_call(        &self,        invocation: ToolInvocation,    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {        if !invocation            .turn            .model_info            .input_modalities            .contains(&InputModality::Image)        {            return Err(FunctionCallError::RespondToModel(                VIEW_IMAGE_UNSUPPORTED_MESSAGE.to_string(),            ));        }        let ToolInvocation {            session,            turn,            payload,            call_id,            ..        } = invocation;        let arguments = match payload {            ToolPayload::Function { arguments } => arguments,            _ => {                return Err(FunctionCallError::RespondToModel(                    "view_image handler received unsupported payload".to_string(),                ));            }        };        let ViewImageArgs {            path,            environment_id,            detail,        } = parse_arguments(&arguments)?;        // `high` is the explicit spelling of the default resized path.        // Other string values remain invalid rather than being silently reinterpreted.        let detail = match detail.as_deref() {            None => None,            Some("high") => Some(ViewImageDetail::High),            Some("original") => Some(ViewImageDetail::Original),            Some(detail) => {                return Err(FunctionCallError::RespondToModel(format!(                    "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `{detail}`"                )));            }        };        let Some(turn_environment) =            resolve_tool_environment(turn.as_ref(), environment_id.as_deref())?        else {            return Err(FunctionCallError::RespondToModel(                "view_image is unavailable in this session".to_string(),            ));        };        // TODO(anp): Resolve tool paths using the selected environment's native path convention        // so view_image can support relative paths in foreign environments.        let cwd = turn_environment.cwd().to_abs_path().map_err(|err| {            FunctionCallError::RespondToModel(format!(                "environment cwd `{}` is not native to the Codex host: {err}",                turn_environment.cwd()            ))        })?;        let abs_path = cwd.join(path);        let sandbox = turn.file_system_sandbox_context(            /*additional_permissions*/ None,            turn_environment.cwd(),        );        let fs = turn_environment.environment.get_filesystem();        let path_uri = PathUri::from_abs_path(&abs_path);        let metadata = fs            .get_metadata(&path_uri, Some(&sandbox))            .await            .map_err(|error| {                FunctionCallError::RespondToModel(format!(                    "unable to locate image at `{}`: {error}",                    abs_path.display()                ))            })?;        if !metadata.is_file {            return Err(FunctionCallError::RespondToModel(format!(                "image path `{}` is not a file",                abs_path.display()            )));        }        let file_bytes = fs            .read_file(&path_uri, Some(&sandbox))            .await            .map_err(|error| {                FunctionCallError::RespondToModel(format!(                    "unable to read image at `{}`: {error}",                    abs_path.display()                ))            })?;        let event_path = abs_path.clone();        let can_request_original_detail = can_request_original_image_detail(&turn.model_info);        let use_original_detail =            can_request_original_detail && matches!(detail, Some(ViewImageDetail::Original));        let image_detail = if use_original_detail {            ImageDetail::Original        } else {            DEFAULT_IMAGE_DETAIL        };        let image_url = if turn.features.enabled(Feature::ResizeAllImages) {            // The history insertion path owns image decoding and resizing when this is enabled.            data_url_from_bytes("application/octet-stream", &file_bytes)        } else {            let image_mode = if use_original_detail {                PromptImageMode::Original            } else {                PromptImageMode::ResizeToFit            };            load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode)                .map_err(|error| {                    FunctionCallError::RespondToModel(format!(                        "unable to process image at `{}`: {error}",                        abs_path.display()                    ))                })?                .into_data_url()        };        let item = TurnItem::ImageView(ImageViewItem {            id: call_id,            path: event_path,        });        session.emit_turn_item_started(turn.as_ref(), &item).await;        session.emit_turn_item_completed(turn.as_ref(), item).await;        Ok(boxed_tool_output(ViewImageOutput {            image_url,            image_detail,        }))    }}impl CoreToolRuntime for ViewImageHandler {}pub struct ViewImageOutput {    image_url: String,    image_detail: ImageDetail,}impl ToolOutput for ViewImageOutput {    fn log_preview(&self) -> String {        format!("<image data URL omitted: {} bytes>", self.image_url.len())    }    fn success_for_logging(&self) -> bool {        true    }    fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {        let body =            FunctionCallOutputBody::ContentItems(vec![FunctionCallOutputContentItem::InputImage {                image_url: self.image_url.clone(),                detail: Some(self.image_detail),            }]);        let output = FunctionCallOutputPayload {            body,            success: Some(true),        };        ResponseInputItem::FunctionCallOutput {            call_id: call_id.to_string(),            output,        }    }    fn code_mode_result(&self, _payload: &ToolPayload) -> serde_json::Value {        serde_json::json!({            "image_url": self.image_url,            "detail": self.image_detail        })    }}#[cfg(test)]mod tests {    use super::*;    use crate::session::tests::make_session_and_context;    use crate::session::turn_context::TurnEnvironment;    use crate::tools::context::ToolCallSource;    use crate::tools::context::ToolInvocation;    use crate::turn_diff_tracker::TurnDiffTracker;    use codex_protocol::models::PermissionProfile;    use codex_utils_absolute_path::AbsolutePathBuf;    use codex_utils_path_uri::PathUri;    use core_test_support::TempDirExt;    use pretty_assertions::assert_eq;    use serde_json::json;    use std::sync::Arc;    use tokio::sync::Mutex;    fn replace_primary_environment_cwd(turn: &mut crate::TurnContext, cwd: AbsolutePathBuf) {        let current = turn            .environments            .turn_environments            .first()            .cloned()            .expect("default local turn environment");        turn.environments.turn_environments[0] = TurnEnvironment::new(            current.environment_id,            current.environment,            PathUri::from_abs_path(&cwd),            current.shell,        );    }    #[test]    fn log_preview_omits_image_data() {        let output = ViewImageOutput {            image_url: "data:image/png;base64,AAA".to_string(),            image_detail: DEFAULT_IMAGE_DETAIL,        };        assert_eq!(output.log_preview(), "<image data URL omitted: 25 bytes>");    }    #[test]    fn code_mode_result_returns_image_url_object() {        let output = ViewImageOutput {            image_url: "data:image/png;base64,AAA".to_string(),            image_detail: DEFAULT_IMAGE_DETAIL,        };        let result = output.code_mode_result(&ToolPayload::Function {            arguments: "{}".to_string(),        });        assert_eq!(            result,            json!({                "image_url": "data:image/png;base64,AAA",                "detail": "high",            })        );    }    #[tokio::test]    async fn handle_passes_sandbox_context_for_local_filesystem_reads() {        let (session, mut turn) = make_session_and_context().await;        let image_dir = tempfile::tempdir().expect("create image temp dir");        let image_cwd = image_dir.abs();        replace_primary_environment_cwd(&mut turn, image_cwd.clone());        let image_path = image_cwd.join("image.png");        std::fs::write(image_path.as_path(), b"not a real image").expect("write test image");        turn.permission_profile = PermissionProfile::read_only();        let result = ViewImageHandler::default()            .handle(ToolInvocation {                session: Arc::new(session),                turn: Arc::new(turn),                cancellation_token: tokio_util::sync::CancellationToken::new(),                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),                call_id: "call-view-image".to_string(),                tool_name: codex_tools::ToolName::plain("view_image"),                source: ToolCallSource::Direct,                payload: ToolPayload::Function {                    arguments: json!({ "path": "image.png" }).to_string(),                },            })            .await;        let Err(FunctionCallError::RespondToModel(message)) = result else {            panic!("expected sandboxed filesystem error");        };        assert!(            message.contains("sandboxed filesystem operations require configured runtime paths"),            "{message}"        );    }    #[tokio::test]    async fn handle_rejects_unsupported_detail() {        let (session, turn) = make_session_and_context().await;        let result = ViewImageHandler::default()            .handle(ToolInvocation {                session: Arc::new(session),                turn: Arc::new(turn),                cancellation_token: tokio_util::sync::CancellationToken::new(),                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),                call_id: "call-view-image".to_string(),                tool_name: codex_tools::ToolName::plain("view_image"),                source: ToolCallSource::Direct,                payload: ToolPayload::Function {                    arguments: json!({ "path": "image.png", "detail": "low" }).to_string(),                },            })            .await;        let Err(FunctionCallError::RespondToModel(message)) = result else {            panic!("expected unsupported detail error");        };        assert_eq!(            message,            "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `low`"        );    }    #[tokio::test(flavor = "multi_thread")]    async fn handle_accepts_explicit_high_detail() {        let (session, mut turn) = make_session_and_context().await;        let image_dir = tempfile::tempdir().expect("create image temp dir");        let image_cwd = image_dir.abs();        replace_primary_environment_cwd(&mut turn, image_cwd.clone());        let image_path = image_cwd.join("image.png");        std::fs::write(image_path.as_path(), b"not a real image").expect("write test image");        turn.permission_profile = PermissionProfile::Disabled;        let result = ViewImageHandler::default()            .handle(ToolInvocation {                session: Arc::new(session),                turn: Arc::new(turn),                cancellation_token: tokio_util::sync::CancellationToken::new(),                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),                call_id: "call-view-image".to_string(),                tool_name: codex_tools::ToolName::plain("view_image"),                source: ToolCallSource::Direct,                payload: ToolPayload::Function {                    arguments: json!({ "path": "image.png", "detail": "high" }).to_string(),                },            })            .await;        let Err(FunctionCallError::RespondToModel(message)) = result else {            panic!("expected image processing error");        };        assert!(message.contains("unable to process image"), "{message}");    }}