From 1df040e62bdc1e96b69eaf53639b638050a3eeb1 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 3 Mar 2026 14:37:26 +0000 Subject: [PATCH] feat: add multi-actions to presentation tool (#13357) --- .../src/presentation_artifact/api.rs | 72 +++++++++++++++++ .../src/presentation_artifact/manager.rs | 42 ++++++++++ .../src/presentation_artifact/response.rs | 5 +- codex-rs/artifact-presentation/src/tests.rs | 79 +++++++++++++++++++ codex-rs/core/src/codex.rs | 6 +- .../tools/handlers/presentation_artifact.rs | 28 +++---- codex-rs/core/src/tools/spec.rs | 39 ++++++--- .../templates/tools/presentation_artifact.md | 46 ++++++----- 8 files changed, 262 insertions(+), 55 deletions(-) diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/api.rs b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs index 1b36392cd..5680bf97f 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/api.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs @@ -85,6 +85,27 @@ pub struct PresentationArtifactRequest { pub args: Value, } +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PresentationArtifactToolRequest { + pub artifact_id: Option, + pub actions: Vec, +} + +#[derive(Debug, Clone)] +pub struct PresentationArtifactExecutionRequest { + pub artifact_id: Option, + pub requests: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PresentationArtifactToolAction { + pub action: String, + #[serde(default)] + pub args: Value, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PathAccessKind { Read, @@ -99,6 +120,10 @@ pub struct PathAccessRequirement { } impl PresentationArtifactRequest { + pub fn is_mutating(&self) -> bool { + !is_read_only_action(&self.action) + } + pub fn required_path_accesses( &self, cwd: &Path, @@ -175,3 +200,50 @@ impl PresentationArtifactRequest { Ok(access) } } + +impl PresentationArtifactToolRequest { + pub fn is_mutating(&self) -> Result { + Ok(self.actions.iter().any(|request| !is_read_only_action(&request.action))) + } + + pub fn into_execution_request( + self, + ) -> Result { + if self.actions.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: "presentation_artifact".to_string(), + message: "`actions` must contain at least one item".to_string(), + }); + } + Ok(PresentationArtifactExecutionRequest { + artifact_id: self.artifact_id, + requests: self + .actions + .into_iter() + .map(|request| PresentationArtifactRequest { + artifact_id: None, + action: request.action, + args: request.args, + }) + .collect(), + }) + } + + pub fn required_path_accesses( + &self, + cwd: &Path, + ) -> Result, PresentationArtifactError> { + let mut accesses = Vec::new(); + for request in &self.actions { + accesses.extend( + PresentationArtifactRequest { + artifact_id: None, + action: request.action.clone(), + args: request.args.clone(), + } + .required_path_accesses(cwd)?, + ); + } + Ok(accesses) + } +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs index 67e190ee5..28682cb91 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs @@ -14,6 +14,47 @@ struct HistoryEntry { } impl PresentationArtifactManager { + pub fn execute_requests( + &mut self, + request: PresentationArtifactExecutionRequest, + cwd: &Path, + ) -> Result { + let PresentationArtifactExecutionRequest { + artifact_id, + requests, + } = request; + let request_count = requests.len(); + let mut current_artifact_id = artifact_id; + let mut executed_actions = Vec::with_capacity(request_count); + let mut exported_paths = Vec::new(); + let mut last_response = None; + + for mut request in requests { + if request.artifact_id.is_none() { + request.artifact_id = current_artifact_id.clone(); + } + let response = self.execute(request, cwd)?; + current_artifact_id = Some(response.artifact_id.clone()); + exported_paths.extend(response.exported_paths.iter().cloned()); + executed_actions.push(response.action.clone()); + last_response = Some(response); + } + + let mut response = last_response.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: "presentation_artifact".to_string(), + message: "request sequence must contain at least one action".to_string(), + })?; + if request_count > 1 { + let final_summary = response.summary.clone(); + response.action = "batch".to_string(); + response.summary = + format!("Executed {request_count} actions sequentially. {final_summary}"); + response.executed_actions = Some(executed_actions); + response.exported_paths = exported_paths; + } + Ok(response) + } + pub fn execute( &mut self, request: PresentationArtifactRequest, @@ -2163,6 +2204,7 @@ impl PresentationArtifactManager { removed.artifact_id, removed.slides.len() ), + executed_actions: None, exported_paths: Vec::new(), artifact_snapshot: None, slide_list: None, diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/response.rs b/codex-rs/artifact-presentation/src/presentation_artifact/response.rs index 303f0b569..241cce570 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/response.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/response.rs @@ -3,6 +3,8 @@ pub struct PresentationArtifactResponse { pub artifact_id: String, pub action: String, pub summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub executed_actions: Option>, #[serde(skip_serializing_if = "Vec::is_empty")] pub exported_paths: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -38,6 +40,7 @@ impl PresentationArtifactResponse { artifact_id, action, summary, + executed_actions: None, exported_paths: Vec::new(), artifact_snapshot: Some(artifact_snapshot), slide_list: None, @@ -63,6 +66,7 @@ fn response_for_document_state( artifact_id, action, summary, + executed_actions: None, exported_paths: Vec::new(), artifact_snapshot: document.map(snapshot_for_document), slide_list: None, @@ -132,4 +136,3 @@ pub struct ThemeSnapshot { pub major_font: Option, pub minor_font: Option, } - diff --git a/codex-rs/artifact-presentation/src/tests.rs b/codex-rs/artifact-presentation/src/tests.rs index e9d0a75b6..493e67a17 100644 --- a/codex-rs/artifact-presentation/src/tests.rs +++ b/codex-rs/artifact-presentation/src/tests.rs @@ -258,6 +258,85 @@ fn exported_images_are_real_pictures_with_media_parts() -> Result<(), Box Result<(), Box> { + let request: PresentationArtifactToolRequest = serde_json::from_value(serde_json::json!({ + "actions": [ + { + "action": "create", + "args": { "name": "Batch Deck" } + }, + { + "action": "export_pptx", + "args": { "path": "deck.pptx" } + } + ] + }))?; + + let execution = request.into_execution_request()?; + assert_eq!(execution.artifact_id, None); + assert_eq!(execution.requests.len(), 2); + assert_eq!(execution.requests[0].action, "create"); + assert_eq!(execution.requests[1].action, "export_pptx"); + Ok(()) +} + +#[test] +fn manager_can_execute_sequential_actions() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let response = manager.execute_requests( + PresentationArtifactExecutionRequest { + artifact_id: None, + requests: vec![ + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Batch Deck" }), + }, + PresentationArtifactRequest { + artifact_id: None, + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + PresentationArtifactRequest { + artifact_id: None, + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "hello", + "position": { "left": 40, "top": 40, "width": 200, "height": 80 } + }), + }, + ], + }, + temp_dir.path(), + )?; + + assert_eq!(response.action, "batch"); + assert_eq!( + response.executed_actions, + Some(vec![ + "create".to_string(), + "add_slide".to_string(), + "add_text_shape".to_string(), + ]) + ); + assert_eq!( + response + .artifact_snapshot + .as_ref() + .map(|snapshot| snapshot.slide_count), + Some(1) + ); + assert!( + response + .summary + .contains("Executed 3 actions sequentially.") + ); + Ok(()) +} + #[test] fn imported_pptx_surfaces_image_elements() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 23f658cf2..1e6c72c0c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -56,7 +56,7 @@ use async_channel::Sender; use chrono::Local; use chrono::Utc; use codex_artifact_presentation::PresentationArtifactError; -use codex_artifact_presentation::PresentationArtifactRequest; +use codex_artifact_presentation::PresentationArtifactExecutionRequest; use codex_artifact_presentation::PresentationArtifactResponse; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; @@ -1782,11 +1782,11 @@ impl Session { pub(crate) async fn execute_presentation_artifact( &self, - request: PresentationArtifactRequest, + request: PresentationArtifactExecutionRequest, cwd: &Path, ) -> Result { let mut state = self.state.lock().await; - state.presentation_artifacts.execute(request, cwd) + state.presentation_artifacts.execute_requests(request, cwd) } async fn record_initial_history(&self, conversation_history: InitialHistory) { diff --git a/codex-rs/core/src/tools/handlers/presentation_artifact.rs b/codex-rs/core/src/tools/handlers/presentation_artifact.rs index d89e5d7f1..23b80d691 100644 --- a/codex-rs/core/src/tools/handlers/presentation_artifact.rs +++ b/codex-rs/core/src/tools/handlers/presentation_artifact.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use codex_artifact_presentation::PathAccessKind; use codex_artifact_presentation::PathAccessRequirement; use codex_artifact_presentation::PresentationArtifactError; -use codex_artifact_presentation::PresentationArtifactRequest; +use codex_artifact_presentation::PresentationArtifactToolRequest; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use serde_json::to_string; @@ -37,23 +37,10 @@ impl ToolHandler for PresentationArtifactHandler { let ToolPayload::Function { arguments } = &invocation.payload else { return true; }; - let Ok(request) = parse_arguments::(arguments) else { + let Ok(request) = parse_arguments::(arguments) else { return true; }; - !matches!( - request.action.as_str(), - "get_summary" - | "list_slides" - | "list_layouts" - | "list_layout_placeholders" - | "list_slide_placeholders" - | "inspect" - | "resolve" - | "to_proto" - | "get_style" - | "describe_styles" - | "record_patch" - ) + request.is_mutating().unwrap_or(true) } async fn handle(&self, invocation: ToolInvocation) -> Result { @@ -80,7 +67,7 @@ impl ToolHandler for PresentationArtifactHandler { } }; - let request: PresentationArtifactRequest = parse_arguments(&arguments)?; + let request: PresentationArtifactToolRequest = parse_arguments(&arguments)?; for access in request .required_path_accesses(&turn.cwd) .map_err(presentation_error)? @@ -89,7 +76,12 @@ impl ToolHandler for PresentationArtifactHandler { } let response = session - .execute_presentation_artifact(request, &turn.cwd) + .execute_presentation_artifact( + request + .into_execution_request() + .map_err(presentation_error)?, + &turn.cwd, + ) .await .map_err(presentation_error)?; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 6b36cdca4..267be909f 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -567,6 +567,26 @@ fn create_view_image_tool() -> ToolSpec { } fn create_presentation_artifact_tool() -> ToolSpec { + let action_step_schema = JsonSchema::Object { + properties: BTreeMap::from([ + ( + "action".to_string(), + JsonSchema::String { + description: Some("Action name to run for this step.".to_string()), + }, + ), + ( + "args".to_string(), + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some(true.into()), + }, + ), + ]), + required: Some(vec!["action".to_string(), "args".to_string()]), + additional_properties: Some(false.into()), + }; let properties = BTreeMap::from([ ( "artifact_id".to_string(), @@ -577,17 +597,12 @@ fn create_presentation_artifact_tool() -> ToolSpec { }, ), ( - "action".to_string(), - JsonSchema::String { - description: Some("Action name to run against the artifact.".to_string()), - }, - ), - ( - "args".to_string(), - JsonSchema::Object { - properties: BTreeMap::new(), - required: None, - additional_properties: Some(true.into()), + "actions".to_string(), + JsonSchema::Array { + items: Box::new(action_step_schema), + description: Some( + "Array of `(action, args)` steps to execute sequentially.".to_string(), + ), }, ), ]); @@ -598,7 +613,7 @@ fn create_presentation_artifact_tool() -> ToolSpec { strict: false, parameters: JsonSchema::Object { properties, - required: Some(vec!["action".to_string(), "args".to_string()]), + required: Some(vec!["actions".to_string()]), additional_properties: Some(false.into()), }, }) diff --git a/codex-rs/core/templates/tools/presentation_artifact.md b/codex-rs/core/templates/tools/presentation_artifact.md index 9b3039072..5c63c2e04 100644 --- a/codex-rs/core/templates/tools/presentation_artifact.md +++ b/codex-rs/core/templates/tools/presentation_artifact.md @@ -4,6 +4,7 @@ Create and edit PowerPoint presentation artifacts inside the current thread. - Resume and fork do not restore live artifact state. Export files if you need a durable handoff. - Relative paths resolve from the current working directory. - Position and size values are in slide points. +- Every tool call uses a top-level `actions` array of sequential steps. Each call operates on a single top-level `artifact_id` when one is needed. If a call starts with `create` or `import_pptx`, later steps in the same call automatically reuse the returned artifact id. Supported actions: - `create` @@ -61,60 +62,63 @@ Supported actions: - `delete_artifact` Example create: -`{"action":"create","args":{"name":"Quarterly Update"}}` +`{"actions":[{"action":"create","args":{"name":"Quarterly Update"}}]}` Example create with custom slide size: -`{"action":"create","args":{"name":"Quarterly Update","slide_size":{"width":960,"height":540}}}` +`{"actions":[{"action":"create","args":{"name":"Quarterly Update","slide_size":{"width":960,"height":540}}}]}` Example edit: -`{"artifact_id":"presentation_x","action":"add_text_shape","args":{"slide_index":0,"text":"Revenue up 24%","position":{"left":48,"top":72,"width":260,"height":80}}}` +`{"artifact_id":"presentation_x","actions":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Revenue up 24%","position":{"left":48,"top":72,"width":260,"height":80}}}]}` + +Example sequential batch: +`{"actions":[{"action":"create","args":{"name":"Quarterly Update"}},{"action":"add_slide","args":{}},{"action":"add_text_shape","args":{"slide_index":0,"text":"Revenue up 24%","position":{"left":48,"top":72,"width":260,"height":80}}}]}` Table creation also accepts optional `column_widths` and `row_heights` arrays in points when you need explicit table sizing instead of even splits. Example export: -`{"artifact_id":"presentation_x","action":"export_pptx","args":{"path":"artifacts/q2-update.pptx"}}` +`{"artifact_id":"presentation_x","actions":[{"action":"export_pptx","args":{"path":"artifacts/q2-update.pptx"}}]}` Example layout flow: -`{"artifact_id":"presentation_x","action":"create_layout","args":{"name":"Title Slide"}}` +`{"artifact_id":"presentation_x","actions":[{"action":"create_layout","args":{"name":"Title Slide"}}]}` -`{"artifact_id":"presentation_x","action":"add_layout_placeholder","args":{"layout_id":"layout_1","name":"title","placeholder_type":"title","text":"Click to add title","position":{"left":48,"top":48,"width":624,"height":72}}}` +`{"artifact_id":"presentation_x","actions":[{"action":"add_layout_placeholder","args":{"layout_id":"layout_1","name":"title","placeholder_type":"title","text":"Click to add title","position":{"left":48,"top":48,"width":624,"height":72}}}]}` -`{"artifact_id":"presentation_x","action":"set_slide_layout","args":{"slide_index":0,"layout_id":"layout_1"}}` +`{"artifact_id":"presentation_x","actions":[{"action":"set_slide_layout","args":{"slide_index":0,"layout_id":"layout_1"}}]}` -`{"artifact_id":"presentation_x","action":"list_layout_placeholders","args":{"layout_id":"layout_1"}}` +`{"artifact_id":"presentation_x","actions":[{"action":"list_layout_placeholders","args":{"layout_id":"layout_1"}}]}` -`{"artifact_id":"presentation_x","action":"list_slide_placeholders","args":{"slide_index":0}}` +`{"artifact_id":"presentation_x","actions":[{"action":"list_slide_placeholders","args":{"slide_index":0}}]}` Layout references in `create_layout.parent_layout_id`, `add_layout_placeholder.layout_id`, `add_slide`, `insert_slide`, `set_slide_layout`, and `list_layout_placeholders` accept either a layout id or a layout name. Name matching prefers exact id, then exact name, then case-insensitive name. `insert_slide` accepts `index` or `after_slide_index`. If neither is provided, the new slide is inserted immediately after the active slide, or appended if no active slide is set yet. Example inspect: -`{"artifact_id":"presentation_x","action":"inspect","args":{"include":"deck,slide,textbox,shape,table,chart,image,notes,layoutList","exclude":"notes","search":"roadmap","max_chars":12000}}` +`{"artifact_id":"presentation_x","actions":[{"action":"inspect","args":{"include":"deck,slide,textbox,shape,table,chart,image,notes,layoutList","exclude":"notes","search":"roadmap","max_chars":12000}}]}` Example inspect target window: -`{"artifact_id":"presentation_x","action":"inspect","args":{"include":"textbox","target":{"id":"sh/element_3","before_lines":1,"after_lines":1}}}` +`{"artifact_id":"presentation_x","actions":[{"action":"inspect","args":{"include":"textbox","target":{"id":"sh/element_3","before_lines":1,"after_lines":1}}}]}` Example resolve: -`{"artifact_id":"presentation_x","action":"resolve","args":{"id":"sh/element_3"}}` +`{"artifact_id":"presentation_x","actions":[{"action":"resolve","args":{"id":"sh/element_3"}}]}` Example proto export: -`{"artifact_id":"presentation_x","action":"to_proto","args":{}}` +`{"artifact_id":"presentation_x","actions":[{"action":"to_proto","args":{}}]}` `to_proto` returns a full JSON snapshot of the current in-memory presentation document, including slide/layout records, anchors, notes, theme state, and typed element payloads. Example patch recording: -`{"artifact_id":"presentation_x","action":"record_patch","args":{"operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}` +`{"artifact_id":"presentation_x","actions":[{"action":"record_patch","args":{"operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}]}` Example patch application: -`{"artifact_id":"presentation_x","action":"apply_patch","args":{"patch":{"version":1,"artifactId":"presentation_x","operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}}` +`{"artifact_id":"presentation_x","actions":[{"action":"apply_patch","args":{"patch":{"version":1,"artifactId":"presentation_x","operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}}]}` Patch payloads are single-artifact and currently support existing in-memory editing actions like slide/element/layout/theme/text updates. Lifecycle, import/export, and nested history actions are intentionally excluded. Example undo/redo: -`{"artifact_id":"presentation_x","action":"undo","args":{}}` +`{"artifact_id":"presentation_x","actions":[{"action":"undo","args":{}}]}` -`{"artifact_id":"presentation_x","action":"redo","args":{}}` +`{"artifact_id":"presentation_x","actions":[{"action":"redo","args":{}}]}` Deck summaries, slide listings, `inspect`, and `resolve` now include active-slide metadata. Use `set_active_slide` to change it explicitly. @@ -123,10 +127,10 @@ Theme snapshots and `to_proto` both expose the deck theme hex color map via `hex Named text styles are supported through `add_style`, `get_style`, and `describe_styles`. Built-in styles include `title`, `heading1`, `body`, `list`, and `numberedList`. Example style creation: -`{"artifact_id":"presentation_x","action":"add_style","args":{"name":"callout","font_size":18,"color":"#336699","italic":true,"underline":true}}` +`{"artifact_id":"presentation_x","actions":[{"action":"add_style","args":{"name":"callout","font_size":18,"color":"#336699","italic":true,"underline":true}}]}` Example style lookup: -`{"artifact_id":"presentation_x","action":"get_style","args":{"name":"title"}}` +`{"artifact_id":"presentation_x","actions":[{"action":"get_style","args":{"name":"title"}}]}` Text styling payloads on `add_text_shape`, `add_shape.text_style`, `update_text.styling`, and `update_table_cell.styling` accept `style` and `underline` in addition to the existing whole-element fields. @@ -155,9 +159,9 @@ Shape strokes accept an optional `style` field such as `solid`, `dashed`, `dotte Connectors are supported via `add_connector`, with straight/elbow/curved types plus dash styles and arrow heads. Example preview: -`{"artifact_id":"presentation_x","action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.png"}}` +`{"artifact_id":"presentation_x","actions":[{"action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.png"}}]}` `export_preview` also accepts `format`, `scale`, and `quality` for rendered previews. `format` currently supports `png`, `jpeg`, and `svg`. Example JPEG preview: -`{"artifact_id":"presentation_x","action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.jpg","format":"jpeg","scale":0.75,"quality":85}}` +`{"artifact_id":"presentation_x","actions":[{"action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.jpg","format":"jpeg","scale":0.75,"quality":85}}]}`