Codex Handbook
ext/image-generation/src/tool.rs 462 lines
use std::collections::HashSet;use codex_api::ImageBackground;use codex_api::ImageEditRequest;use codex_api::ImageGenerationRequest;use codex_api::ImageQuality;use codex_api::ImageUrl;use codex_core::context::extension_image_generation_output_hint;use codex_core::image_generation_artifact_path;use codex_extension_api::ExtensionTurnItem;use codex_extension_api::FunctionCallError;use codex_extension_api::ToolCall;use codex_extension_api::ToolEnvironment;use codex_extension_api::ToolExecutor;use codex_extension_api::ToolName;use codex_extension_api::ToolOutput;use codex_extension_api::ToolPayload;use codex_extension_api::ToolSpec;use codex_extension_api::parse_tool_input_schema;use codex_protocol::items::ImageGenerationItem;use codex_protocol::models::ContentItem;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::ResponseInputItem;use codex_protocol::models::ResponseItem;use codex_tools::ResponsesApiNamespace;use codex_tools::ResponsesApiNamespaceTool;use codex_tools::ResponsesApiTool;use codex_tools::ToolExposure;use codex_tools::default_namespace_description;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_image::PromptImageMode;use codex_utils_image::load_for_prompt_bytes;use codex_utils_path_uri::PathUri;use schemars::JsonSchema;use schemars::r#gen::SchemaSettings;use serde::Deserialize;use serde_json::Map;use serde_json::Value;use crate::IMAGE_GEN_NAMESPACE;use crate::IMAGEGEN_TOOL_NAME;use crate::backend::CodexImagesBackend;const IMAGE_MODEL: &str = "gpt-image-2";const MAX_EDIT_IMAGES: usize = 5;const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md");#[derive(Clone)]pub(crate) struct ImageGenerationTool {    backend: CodexImagesBackend,    codex_home: AbsolutePathBuf,    thread_id: String,}impl ImageGenerationTool {    /// Creates an image-generation tool backed by an image API executor.    pub(crate) fn new(        backend: CodexImagesBackend,        codex_home: AbsolutePathBuf,        thread_id: String,    ) -> Self {        Self {            backend,            codex_home,            thread_id,        }    }}#[derive(Debug, Deserialize, JsonSchema)]#[serde(deny_unknown_fields)]struct ImagegenArgs {    prompt: String,    #[schemars(length(max = 5))]    referenced_image_paths: Option<Vec<AbsolutePathBuf>>,    #[schemars(range(min = 1, max = 5))]    num_last_images_to_include: Option<usize>,}impl ToolExecutor<ToolCall> for ImageGenerationTool {    /// Keeps the tool in the existing image-generation Responses namespace.    fn tool_name(&self) -> ToolName {        ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME)    }    /// Advertises the model contract: a rewritten prompt and optional edit references.    fn spec(&self) -> ToolSpec {        imagegen_tool_spec()    }    /// Exposes image generation directly and through the nested code-mode tool surface.    fn exposure(&self) -> ToolExposure {        ToolExposure::Direct    }    /// Executes the selected image operation and returns the completed image result.    fn handle(&self, call: ToolCall) -> codex_extension_api::ToolExecutorFuture<'_> {        Box::pin(self.handle_call(call))    }}impl ImageGenerationTool {    async fn handle_call(&self, call: ToolCall) -> Result<Box<dyn ToolOutput>, FunctionCallError> {        let args = parse_args(&call)?;        let request =            request_for_call_args(&args, call.conversation_history.items(), &call.environments)                .await?;        call.turn_item_emitter            .emit_started(ExtensionTurnItem::ImageGeneration(ImageGenerationItem {                id: call.call_id.clone(),                status: "in_progress".to_string(),                revised_prompt: None,                result: String::new(),                saved_path: None,            }))            .await;        let result = match request {            ImageRequest::Generate(request) => self.backend.generate(request).await,            ImageRequest::Edit(request) => self.backend.edit(request).await,        }        .map_err(|err| format!("image generation failed: {err}"))        .and_then(|response| {            response                .data                .into_iter()                .next()                .map(|data| data.b64_json)                .ok_or_else(|| "image generation returned no image data".to_string())        });        let result = match result {            Ok(result) => result,            Err(message) => {                call.turn_item_emitter                    .emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem {                        id: call.call_id.clone(),                        status: "failed".to_string(),                        revised_prompt: Some(args.prompt.clone()),                        result: String::new(),                        saved_path: None,                    }))                    .await;                return Err(FunctionCallError::RespondToModel(message));            }        };        call.turn_item_emitter            .emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem {                id: call.call_id.clone(),                status: "completed".to_string(),                revised_prompt: Some(args.prompt),                result: result.clone(),                saved_path: None,            }))            .await;        let output_path =            image_generation_artifact_path(&self.codex_home, &self.thread_id, &call.call_id);        let output_dir = output_path            .parent()            .unwrap_or_else(|| self.codex_home.clone());        let output_hint =            extension_image_generation_output_hint(output_dir.display(), output_path.display());        Ok(Box::new(GeneratedImageOutput {            result,            output_hint,        }))    }}#[derive(Debug, PartialEq)]enum ImageRequest {    Generate(ImageGenerationRequest),    Edit(ImageEditRequest),}async fn request_for_call_args(    args: &ImagegenArgs,    history: &[ResponseItem],    environments: &[ToolEnvironment],) -> Result<ImageRequest, FunctionCallError> {    let paths = args.referenced_image_paths.as_deref().unwrap_or_default();    if paths.len() > MAX_EDIT_IMAGES {        return Err(FunctionCallError::RespondToModel(format!(            "`referenced_image_paths` must contain at most {MAX_EDIT_IMAGES} paths"        )));    }    let images = match (paths.is_empty(), args.num_last_images_to_include) {        (true, None) => {            return Ok(ImageRequest::Generate(ImageGenerationRequest {                prompt: args.prompt.clone(),                background: Some(ImageBackground::Auto),                model: IMAGE_MODEL.to_string(),                n: None,                quality: Some(ImageQuality::Auto),                size: Some("auto".to_string()),            }));        }        (false, None) => {            let Some(environment) = environments.first() else {                return Err(FunctionCallError::RespondToModel(                    "referenced image paths are unavailable in this session".to_string(),                ));            };            let mut images = Vec::with_capacity(paths.len());            for path in paths {                images.push(image_url(path, environment).await?);            }            images        }        (true, Some(count)) => {            if !(1..=MAX_EDIT_IMAGES).contains(&count) {                return Err(FunctionCallError::RespondToModel(format!(                    "`num_last_images_to_include` must be between 1 and {MAX_EDIT_IMAGES}"                )));            }            // Pathless images have no stable reference, so this bounded window may include newer            // unrelated images. This remains best-effort until the harness provides stable refs.            let images = recent_images(history, count);            if images.len() != count {                return Err(FunctionCallError::RespondToModel(format!(                    "requested the last {count} conversation images, but only {} were available",                    images.len()                )));            }            images        }        (false, Some(_)) => {            return Err(FunctionCallError::RespondToModel(                "provide only one of `referenced_image_paths` or \                 `num_last_images_to_include`"                    .to_string(),            ));        }    };    Ok(ImageRequest::Edit(ImageEditRequest {        images,        prompt: args.prompt.clone(),        background: Some(ImageBackground::Auto),        model: IMAGE_MODEL.to_string(),        n: None,        quality: Some(ImageQuality::Auto),        size: Some("auto".to_string()),    }))}fn recent_images(history: &[ResponseItem], count: usize) -> Vec<ImageUrl> {    let mut function_call_ids = HashSet::new();    let mut custom_tool_call_ids = HashSet::new();    for item in history {        match item {            ResponseItem::FunctionCall { call_id, .. } => {                function_call_ids.insert(call_id.as_str());            }            ResponseItem::CustomToolCall { call_id, .. } => {                custom_tool_call_ids.insert(call_id.as_str());            }            ResponseItem::Message { .. }            | ResponseItem::AgentMessage { .. }            | ResponseItem::Reasoning { .. }            | ResponseItem::LocalShellCall { .. }            | ResponseItem::ToolSearchCall { .. }            | ResponseItem::FunctionCallOutput { .. }            | ResponseItem::CustomToolCallOutput { .. }            | ResponseItem::ToolSearchOutput { .. }            | ResponseItem::WebSearchCall { .. }            | ResponseItem::ImageGenerationCall { .. }            | ResponseItem::Compaction { .. }            | ResponseItem::CompactionTrigger { .. }            | ResponseItem::ContextCompaction { .. }            | ResponseItem::Other => {}        }    }    let mut images = Vec::with_capacity(count);    'history: for item in history.iter().rev() {        let mut image_urls = Vec::new();        match item {            ResponseItem::Message { content, .. } => {                image_urls.extend(content.iter().rev().filter_map(|item| match item {                    ContentItem::InputImage { image_url, .. } => Some(image_url.clone()),                    ContentItem::InputText { .. } | ContentItem::OutputText { .. } => None,                }));            }            ResponseItem::FunctionCallOutput {                call_id, output, ..            } if function_call_ids.contains(call_id.as_str()) => {                image_urls.extend(output_image_urls(output));            }            ResponseItem::CustomToolCallOutput {                call_id, output, ..            } if custom_tool_call_ids.contains(call_id.as_str()) => {                image_urls.extend(output_image_urls(output));            }            ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => {                image_urls.push(format!("data:image/png;base64,{result}"));            }            ResponseItem::Reasoning { .. }            | ResponseItem::AgentMessage { .. }            | ResponseItem::LocalShellCall { .. }            | ResponseItem::FunctionCall { .. }            | ResponseItem::ToolSearchCall { .. }            | ResponseItem::CustomToolCall { .. }            | ResponseItem::FunctionCallOutput { .. }            | ResponseItem::CustomToolCallOutput { .. }            | ResponseItem::ToolSearchOutput { .. }            | ResponseItem::WebSearchCall { .. }            | ResponseItem::ImageGenerationCall { .. }            | ResponseItem::Compaction { .. }            | ResponseItem::CompactionTrigger { .. }            | ResponseItem::ContextCompaction { .. }            | ResponseItem::Other => {}        }        for image_url in image_urls {            images.push(ImageUrl { image_url });            if images.len() == count {                break 'history;            }        }    }    images.reverse();    images}/// Extracts image URLs from a tool output in newest-first order.fn output_image_urls(output: &FunctionCallOutputPayload) -> impl Iterator<Item = String> + '_ {    output        .content_items()        .into_iter()        .flatten()        .rev()        .filter_map(|item| match item {            FunctionCallOutputContentItem::InputImage { image_url, .. } => Some(image_url.clone()),            FunctionCallOutputContentItem::InputText { .. }            | FunctionCallOutputContentItem::EncryptedContent { .. } => None,        })}async fn image_url(    path: &AbsolutePathBuf,    environment: &ToolEnvironment,) -> Result<ImageUrl, FunctionCallError> {    let path_uri = PathUri::from_abs_path(path);    let sandbox = environment.file_system_sandbox_context.clone();    let bytes = environment        .file_system        .read_file(&path_uri, Some(&sandbox))        .await        .map_err(|error| {            FunctionCallError::RespondToModel(format!(                "unable to read referenced image at `{}`: {error}",                path.display()            ))        })?;    let image = load_for_prompt_bytes(path.as_path(), bytes, PromptImageMode::Original).map_err(        |error| {            FunctionCallError::RespondToModel(format!(                "unable to process referenced image at `{}`: {error}",                path.display()            ))        },    )?;    Ok(ImageUrl {        image_url: image.into_data_url(),    })}/// Parses the strict model-facing arguments for an image-generation call.fn parse_args(call: &ToolCall) -> Result<ImagegenArgs, FunctionCallError> {    serde_json::from_str(call.function_arguments()?)        .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))}/// Builds the namespace function schema exposed to the model.fn imagegen_tool_spec() -> ToolSpec {    let mut schema_value = serde_json::to_value(        SchemaSettings::draft2019_09()            .with(|settings| settings.inline_subschemas = true)            .into_generator()            .into_root_schema_for::<ImagegenArgs>(),    )    .unwrap_or_else(|err| panic!("imagegen schema should serialize: {err}"));    let Value::Object(ref mut schema) = schema_value else {        unreachable!("imagegen root schema must be an object");    };    let mut input_schema = Map::new();    for key in ["properties", "required", "type", "additionalProperties"] {        if let Some(value) = schema.remove(key) {            input_schema.insert(key.to_string(), value);        }    }    ToolSpec::Namespace(ResponsesApiNamespace {        name: IMAGE_GEN_NAMESPACE.to_string(),        description: default_namespace_description(IMAGE_GEN_NAMESPACE),        tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {            name: IMAGEGEN_TOOL_NAME.to_string(),            description: IMAGEGEN_DESCRIPTION.to_string(),            strict: false,            parameters: parse_tool_input_schema(&Value::Object(input_schema))                .unwrap_or_else(|err| panic!("imagegen input schema should parse: {err}")),            output_schema: None,            defer_loading: None,        })],    })}struct GeneratedImageOutput {    result: String,    output_hint: Option<String>,}impl ToolOutput for GeneratedImageOutput {    /// Avoids copying image bytes into tool-call telemetry.    fn log_preview(&self) -> String {        "[generated image]".to_string()    }    /// Reports a completed images request as successful tool execution.    fn success_for_logging(&self) -> bool {        true    }    /// Returns the object consumed by the code-mode `generatedImage()` helper.    fn code_mode_result(&self, _payload: &ToolPayload) -> Value {        let mut result = Map::from_iter([(            "image_url".to_string(),            Value::String(format!("data:image/png;base64,{}", self.result)),        )]);        if let Some(output_hint) = &self.output_hint {            result.insert(                "output_hint".to_string(),                Value::String(output_hint.clone()),            );        }        Value::Object(result)    }    /// Returns generated bytes and persisted-artifact context for model follow-up.    fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {        let mut content = vec![FunctionCallOutputContentItem::InputImage {            image_url: format!("data:image/png;base64,{}", self.result),            detail: Some(DEFAULT_IMAGE_DETAIL),        }];        if let Some(output_hint) = &self.output_hint {            content.push(FunctionCallOutputContentItem::InputText {                text: output_hint.clone(),            });        }        ResponseInputItem::FunctionCallOutput {            call_id: call_id.to_string(),            output: FunctionCallOutputPayload {                body: FunctionCallOutputBody::ContentItems(content),                success: Some(true),            },        }    }}#[cfg(test)]#[path = "tests.rs"]mod tests;