diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 77dcbd75c..62d2ffae3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1802,6 +1802,7 @@ dependencies = [ "codex-apply-patch", "codex-arg0", "codex-artifact-presentation", + "codex-artifact-spreadsheet", "codex-async-utils", "codex-client", "codex-config", diff --git a/codex-rs/artifact-spreadsheet/src/chart.rs b/codex-rs/artifact-spreadsheet/src/chart.rs index 34f6dd7c9..83b02b6be 100644 --- a/codex-rs/artifact-spreadsheet/src/chart.rs +++ b/codex-rs/artifact-spreadsheet/src/chart.rs @@ -64,7 +64,7 @@ pub struct SpreadsheetChart { } #[derive(Debug, Clone, Default)] -pub struct SpreadsheetChartLookup<'a> { +pub struct SpreadsheetChartLookup { pub id: Option, pub index: Option, } @@ -101,7 +101,6 @@ impl SpreadsheetSheet { .transpose() .ok() .flatten() - .flatten() .is_some_and(|chart_range| chart_range.intersects(target)) }) }) @@ -112,7 +111,7 @@ impl SpreadsheetSheet { pub fn get_chart( &self, action: &str, - lookup: SpreadsheetChartLookup<'_>, + lookup: SpreadsheetChartLookup, ) -> Result<&SpreadsheetChart, SpreadsheetArtifactError> { if let Some(id) = lookup.id { return self @@ -221,7 +220,7 @@ impl SpreadsheetSheet { pub fn add_chart_series( &mut self, action: &str, - lookup: SpreadsheetChartLookup<'_>, + lookup: SpreadsheetChartLookup, mut series: SpreadsheetChartSeries, ) -> Result { validate_chart_series(action, &series)?; @@ -235,7 +234,7 @@ impl SpreadsheetSheet { pub fn delete_chart( &mut self, action: &str, - lookup: SpreadsheetChartLookup<'_>, + lookup: SpreadsheetChartLookup, ) -> Result<(), SpreadsheetArtifactError> { let index = if let Some(id) = lookup.id { self.charts @@ -267,7 +266,7 @@ impl SpreadsheetSheet { pub fn set_chart_properties( &mut self, action: &str, - lookup: SpreadsheetChartLookup<'_>, + lookup: SpreadsheetChartLookup, properties: SpreadsheetChartProperties, ) -> Result<(), SpreadsheetArtifactError> { let chart = self.get_chart_mut(action, lookup)?; @@ -307,7 +306,7 @@ impl SpreadsheetSheet { fn get_chart_mut( &mut self, action: &str, - lookup: SpreadsheetChartLookup<'_>, + lookup: SpreadsheetChartLookup, ) -> Result<&mut SpreadsheetChart, SpreadsheetArtifactError> { if let Some(id) = lookup.id { return self @@ -320,11 +319,12 @@ impl SpreadsheetSheet { }); } if let Some(index) = lookup.index { + let len = self.charts.len(); return self.charts.get_mut(index).ok_or_else(|| { SpreadsheetArtifactError::IndexOutOfRange { action: action.to_string(), index, - len: self.charts.len(), + len, } }); } diff --git a/codex-rs/artifact-spreadsheet/src/conditional.rs b/codex-rs/artifact-spreadsheet/src/conditional.rs index be6e9cd5b..96c0c8ebd 100644 --- a/codex-rs/artifact-spreadsheet/src/conditional.rs +++ b/codex-rs/artifact-spreadsheet/src/conditional.rs @@ -128,6 +128,18 @@ impl SpreadsheetConditionalFormatCollection { } impl SpreadsheetArtifact { + pub fn validate_conditional_formats( + &self, + action: &str, + sheet_name: &str, + ) -> Result<(), SpreadsheetArtifactError> { + let sheet = self.sheet_lookup(action, Some(sheet_name), None)?; + for format in &sheet.conditional_formats { + validate_conditional_format(self, format, action)?; + } + Ok(()) + } + pub fn add_conditional_format( &mut self, action: &str, diff --git a/codex-rs/artifact-spreadsheet/src/lib.rs b/codex-rs/artifact-spreadsheet/src/lib.rs index f39c9cd42..bd9f6ed8f 100644 --- a/codex-rs/artifact-spreadsheet/src/lib.rs +++ b/codex-rs/artifact-spreadsheet/src/lib.rs @@ -1,18 +1,26 @@ mod address; +mod chart; +mod conditional; mod error; mod formula; mod manager; mod model; +mod pivot; mod render; mod style; +mod table; mod xlsx; #[cfg(test)] mod tests; pub use address::*; +pub use chart::*; +pub use conditional::*; pub use error::*; pub use manager::*; pub use model::*; +pub use pivot::*; pub use render::*; pub use style::*; +pub use table::*; diff --git a/codex-rs/artifact-spreadsheet/src/manager.rs b/codex-rs/artifact-spreadsheet/src/manager.rs index 98a2a045a..392b685c4 100644 --- a/codex-rs/artifact-spreadsheet/src/manager.rs +++ b/codex-rs/artifact-spreadsheet/src/manager.rs @@ -113,6 +113,26 @@ impl SpreadsheetArtifactManager { "list_sheets" => self.list_sheets(request), "get_sheet" => self.get_sheet(request), "inspect" => self.inspect(request), + "list_charts" => self.list_charts(request), + "get_chart" => self.get_chart(request), + "create_chart" => self.create_chart(request), + "add_chart_series" => self.add_chart_series(request), + "set_chart_properties" => self.set_chart_properties(request), + "delete_chart" => self.delete_chart(request), + "list_tables" => self.list_tables(request), + "get_table" => self.get_table(request), + "create_table" => self.create_table(request), + "set_table_style" => self.set_table_style(request), + "clear_table_filters" => self.clear_table_filters(request), + "reapply_table_filters" => self.reapply_table_filters(request), + "rename_table_column" => self.rename_table_column(request), + "set_table_column_totals" => self.set_table_column_totals(request), + "delete_table" => self.delete_table(request), + "list_conditional_formats" => self.list_conditional_formats(request), + "add_conditional_format" => self.add_conditional_format(request), + "delete_conditional_format" => self.delete_conditional_format(request), + "list_pivot_tables" => self.list_pivot_tables(request), + "get_pivot_table" => self.get_pivot_table(request), "create_sheet" => self.create_sheet(request), "rename_sheet" => self.rename_sheet(request), "delete_sheet" => self.delete_sheet(request), @@ -493,6 +513,632 @@ impl SpreadsheetArtifactManager { Ok(response) } + fn list_charts( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = args.range.as_deref().map(CellRange::parse).transpose()?; + let charts = sheet.list_charts(range.as_ref())?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} charts on `{}`", charts.len(), sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.chart_list = Some(charts); + Ok(response) + } + + fn get_chart( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: ChartLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let chart = sheet + .get_chart(&request.action, chart_lookup_from_args(&args))? + .clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved chart `{}` from `{}`", chart.id, sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.chart_list = Some(vec![chart]); + Ok(response) + } + + fn create_chart( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateChartArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + if let Some(source_sheet_name) = args.source_sheet_name.as_deref() { + artifact.sheet_lookup(&request.action, Some(source_sheet_name), None)?; + } + let source_range = CellRange::parse(&args.source_range)?; + let chart = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let chart_id = sheet.create_chart( + &request.action, + args.chart_type, + args.source_sheet_name.or_else(|| Some(sheet.name.clone())), + &source_range, + args.options.unwrap_or_default(), + )?; + sheet + .get_chart( + &request.action, + crate::SpreadsheetChartLookup { + id: Some(chart_id), + index: None, + }, + )? + .clone() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created chart `{}`", chart.id), + snapshot_for_artifact(artifact), + ); + response.chart_list = Some(vec![chart]); + Ok(response) + } + + fn add_chart_series( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: AddChartSeriesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let chart = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let series_id = sheet.add_chart_series( + &request.action, + crate::SpreadsheetChartLookup { + id: args.chart_id, + index: args.chart_index.map(|value| value as usize), + }, + args.series, + )?; + let chart = sheet.get_chart( + &request.action, + crate::SpreadsheetChartLookup { + id: args.chart_id.or(Some(series_id).and(None)), + index: args.chart_index.map(|value| value as usize), + }, + ); + match chart { + Ok(chart) => chart.clone(), + Err(_) => sheet + .get_chart( + &request.action, + crate::SpreadsheetChartLookup { + id: None, + index: args.chart_index.map(|value| value as usize), + }, + )? + .clone(), + } + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Added chart series to chart `{}`", chart.id), + snapshot_for_artifact(artifact), + ); + response.chart_list = Some(vec![chart]); + Ok(response) + } + + fn set_chart_properties( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetChartPropertiesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let chart = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.lookup.sheet_name.as_deref(), + args.lookup.sheet_index.map(|value| value as usize), + )?; + let lookup = chart_lookup_from_args(&args.lookup); + sheet.set_chart_properties(&request.action, lookup.clone(), args.properties)?; + sheet.get_chart(&request.action, lookup)?.clone() + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Updated chart `{}`", chart.id), + snapshot_for_artifact(artifact), + ); + response.chart_list = Some(vec![chart]); + Ok(response) + } + + fn delete_chart( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: ChartLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_name = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let name = sheet.name.clone(); + sheet.delete_chart(&request.action, chart_lookup_from_args(&args))?; + name + }; + let action = request.action.clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Deleted chart from `{sheet_name}`"), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![ + artifact + .sheet_lookup(&action, Some(&sheet_name), None)? + .summary(), + ]); + Ok(response) + } + + fn list_tables( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = args.range.as_deref().map(CellRange::parse).transpose()?; + let tables = sheet.list_tables(range.as_ref())?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} tables on `{}`", tables.len(), sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.table_list = Some(tables); + Ok(response) + } + + fn get_table( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: TableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let table = sheet.get_table_view(&request.action, table_lookup_from_args(&args))?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved table `{}` from `{}`", table.name, sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn create_table( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateTableArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let range = CellRange::parse(&args.range)?; + let table = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let table_id = sheet.create_table(&request.action, &range, args.options)?; + sheet.get_table_view( + &request.action, + crate::SpreadsheetTableLookup { + name: None, + display_name: None, + id: Some(table_id), + }, + )? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created table `{}`", table.name), + snapshot_for_artifact(artifact), + ); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn set_table_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetTableStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let table = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.lookup.sheet_name.as_deref(), + args.lookup.sheet_index.map(|value| value as usize), + )?; + let lookup = table_lookup_from_args(&args.lookup); + sheet.set_table_style(&request.action, lookup.clone(), args.options)?; + sheet.get_table_view(&request.action, lookup)? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Updated table style for `{}`", table.name), + snapshot_for_artifact(artifact), + ); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn clear_table_filters( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: TableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let table = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let lookup = table_lookup_from_args(&args); + sheet.clear_table_filters(&request.action, lookup.clone())?; + sheet.get_table_view(&request.action, lookup)? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Cleared table filters for `{}`", table.name), + snapshot_for_artifact(artifact), + ); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn reapply_table_filters( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: TableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let table = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let lookup = table_lookup_from_args(&args); + sheet.reapply_table_filters(&request.action, lookup.clone())?; + sheet.get_table_view(&request.action, lookup)? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Reapplied table filters for `{}`", table.name), + snapshot_for_artifact(artifact), + ); + response.table_list = Some(vec![table]); + Ok(response) + } + + fn rename_table_column( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: RenameTableColumnArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let column = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.lookup.sheet_name.as_deref(), + args.lookup.sheet_index.map(|value| value as usize), + )?; + sheet.rename_table_column( + &request.action, + table_lookup_from_args(&args.lookup), + args.column_id, + args.column_name.as_deref(), + args.new_name, + )? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Renamed table column to `{}`", column.name), + snapshot_for_artifact(artifact), + ); + response.serialized_dict = Some(to_serialized_value(column)?); + Ok(response) + } + + fn set_table_column_totals( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SetTableColumnTotalsArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let column = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.lookup.sheet_name.as_deref(), + args.lookup.sheet_index.map(|value| value as usize), + )?; + sheet.set_table_column_totals( + &request.action, + table_lookup_from_args(&args.lookup), + args.column_id, + args.column_name.as_deref(), + args.totals_row_label, + args.totals_row_function, + )? + }; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Updated totals metadata for column `{}`", column.name), + snapshot_for_artifact(artifact), + ); + response.serialized_dict = Some(to_serialized_value(column)?); + Ok(response) + } + + fn delete_table( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: TableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_name = { + let sheet = artifact.sheet_lookup_mut( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let name = sheet.name.clone(); + sheet.delete_table(&request.action, table_lookup_from_args(&args))?; + name + }; + let action = request.action.clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Deleted table from `{sheet_name}`"), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![ + artifact + .sheet_lookup(&action, Some(&sheet_name), None)? + .summary(), + ]); + Ok(response) + } + + fn list_conditional_formats( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = args.range.as_deref().map(CellRange::parse).transpose()?; + let formats = sheet.list_conditional_formats(range.as_ref()); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Listed {} conditional formats on `{}`", + formats.len(), + sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.conditional_format_list = Some(formats); + Ok(response) + } + + fn add_conditional_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: AddConditionalFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_name = artifact + .sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )? + .name + .clone(); + let format_id = + artifact.add_conditional_format(&request.action, &sheet_name, args.format)?; + let format = artifact + .sheet_lookup(&request.action, Some(&sheet_name), None)? + .conditional_formats + .iter() + .find(|entry| entry.id == format_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: format!("created conditional format `{format_id}` was not available"), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created conditional format `{format_id}`"), + snapshot_for_artifact(artifact), + ); + response.conditional_format_list = Some(vec![format]); + Ok(response) + } + + fn delete_conditional_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: DeleteConditionalFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let sheet_name = artifact + .sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )? + .name + .clone(); + artifact.delete_conditional_format(&request.action, &sheet_name, args.id)?; + let action = request.action.clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + action.clone(), + format!("Deleted conditional format `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.sheet_list = Some(vec![ + artifact + .sheet_lookup(&action, Some(&sheet_name), None)? + .summary(), + ]); + Ok(response) + } + + fn list_pivot_tables( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: SheetLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let range = args.range.as_deref().map(CellRange::parse).transpose()?; + let pivots = sheet.list_pivot_tables(range.as_ref())?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} pivot tables on `{}`", pivots.len(), sheet.name), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.range_ref = range + .as_ref() + .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); + response.pivot_table_list = Some(pivots); + Ok(response) + } + + fn get_pivot_table( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: PivotTableLookupArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let sheet = artifact.sheet_lookup( + &request.action, + args.sheet_name.as_deref(), + args.sheet_index.map(|value| value as usize), + )?; + let pivot = sheet + .get_pivot_table(&request.action, pivot_lookup_from_args(&args))? + .clone(); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Retrieved pivot table `{}` from `{}`", + pivot.name, sheet.name + ), + snapshot_for_artifact(artifact), + ); + response.sheet_ref = Some(sheet_reference(sheet)); + response.pivot_table_list = Some(vec![pivot]); + Ok(response) + } + fn rename_sheet( &mut self, request: SpreadsheetArtifactRequest, @@ -776,6 +1422,7 @@ impl SpreadsheetArtifactManager { sheet.cleanup_and_validate_sheet()?; sheet.summary() }; + artifact.validate_conditional_formats(&request.action, &sheet_summary.name)?; let mut response = SpreadsheetArtifactResponse::new( artifact_id, request.action, @@ -2006,6 +2653,14 @@ pub struct SpreadsheetArtifactResponse { #[serde(skip_serializing_if = "Option::is_none")] pub sheet_list: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub chart_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub table_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub conditional_format_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pivot_table_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub sheet_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cell_ref: Option, @@ -2056,6 +2711,10 @@ impl SpreadsheetArtifactResponse { artifact_snapshot: Some(artifact_snapshot), workbook_summary: None, sheet_list: None, + chart_list: None, + table_list: None, + conditional_format_list: None, + pivot_table_list: None, sheet_ref: None, cell_ref: None, range_ref: None, @@ -2097,6 +2756,10 @@ pub struct SpreadsheetSheetSnapshot { pub filled_columns: usize, pub minimum_range_filled: String, pub merged_range_count: usize, + pub chart_count: usize, + pub table_count: usize, + pub conditional_format_count: usize, + pub pivot_table_count: usize, } #[derive(Debug, Deserialize)] @@ -2137,6 +2800,105 @@ struct SheetLookupArgs { range: Option, } +#[derive(Debug, Deserialize)] +struct ChartLookupArgs { + sheet_name: Option, + sheet_index: Option, + chart_id: Option, + chart_index: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateChartArgs { + sheet_name: Option, + sheet_index: Option, + chart_type: crate::SpreadsheetChartType, + source_sheet_name: Option, + source_range: String, + options: Option, +} + +#[derive(Debug, Deserialize)] +struct AddChartSeriesArgs { + sheet_name: Option, + sheet_index: Option, + chart_id: Option, + chart_index: Option, + series: crate::SpreadsheetChartSeries, +} + +#[derive(Debug, Deserialize)] +struct SetChartPropertiesArgs { + #[serde(flatten)] + lookup: ChartLookupArgs, + properties: crate::SpreadsheetChartProperties, +} + +#[derive(Debug, Deserialize)] +struct TableLookupArgs { + sheet_name: Option, + sheet_index: Option, + name: Option, + display_name: Option, + id: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateTableArgs { + sheet_name: Option, + sheet_index: Option, + range: String, + options: crate::SpreadsheetCreateTableOptions, +} + +#[derive(Debug, Deserialize)] +struct SetTableStyleArgs { + #[serde(flatten)] + lookup: TableLookupArgs, + options: crate::SpreadsheetTableStyleOptions, +} + +#[derive(Debug, Deserialize)] +struct RenameTableColumnArgs { + #[serde(flatten)] + lookup: TableLookupArgs, + column_id: Option, + column_name: Option, + new_name: String, +} + +#[derive(Debug, Deserialize)] +struct SetTableColumnTotalsArgs { + #[serde(flatten)] + lookup: TableLookupArgs, + column_id: Option, + column_name: Option, + totals_row_label: Option, + totals_row_function: Option, +} + +#[derive(Debug, Deserialize)] +struct AddConditionalFormatArgs { + sheet_name: Option, + sheet_index: Option, + format: crate::SpreadsheetConditionalFormat, +} + +#[derive(Debug, Deserialize)] +struct DeleteConditionalFormatArgs { + sheet_name: Option, + sheet_index: Option, + id: u32, +} + +#[derive(Debug, Deserialize)] +struct PivotTableLookupArgs { + sheet_name: Option, + sheet_index: Option, + name: Option, + index: Option, +} + #[derive(Debug, Deserialize)] struct CreateSheetArgs { name: String, @@ -2444,11 +3206,37 @@ fn snapshot_for_artifact(artifact: &SpreadsheetArtifact) -> SpreadsheetArtifactS filled_columns: sheet.filled_columns(), minimum_range_filled: sheet.minimum_range_filled(), merged_range_count: sheet.merged_ranges.len(), + chart_count: sheet.charts.len(), + table_count: sheet.tables.len(), + conditional_format_count: sheet.conditional_formats.len(), + pivot_table_count: sheet.pivot_tables.len(), }) .collect(), } } +fn chart_lookup_from_args(args: &ChartLookupArgs) -> crate::SpreadsheetChartLookup { + crate::SpreadsheetChartLookup { + id: args.chart_id, + index: args.chart_index.map(|value| value as usize), + } +} + +fn table_lookup_from_args(args: &TableLookupArgs) -> crate::SpreadsheetTableLookup<'_> { + crate::SpreadsheetTableLookup { + name: args.name.as_deref(), + display_name: args.display_name.as_deref(), + id: args.id, + } +} + +fn pivot_lookup_from_args(args: &PivotTableLookupArgs) -> crate::SpreadsheetPivotTableLookup<'_> { + crate::SpreadsheetPivotTableLookup { + name: args.name.as_deref(), + index: args.index.map(|value| value as usize), + } +} + fn sheet_reference(sheet: &crate::SpreadsheetSheet) -> SpreadsheetSheetRef { SpreadsheetSheetRef { sheet_name: sheet.name.clone(), diff --git a/codex-rs/artifact-spreadsheet/src/model.rs b/codex-rs/artifact-spreadsheet/src/model.rs index 228943a56..747eedbce 100644 --- a/codex-rs/artifact-spreadsheet/src/model.rs +++ b/codex-rs/artifact-spreadsheet/src/model.rs @@ -457,6 +457,10 @@ pub struct SpreadsheetSheetSummary { pub default_column_width: Option, pub show_grid_lines: bool, pub merged_range_count: usize, + pub chart_count: usize, + pub table_count: usize, + pub conditional_format_count: usize, + pub pivot_table_count: usize, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -504,6 +508,14 @@ pub struct SpreadsheetSheet { pub cells: BTreeMap, #[serde(default)] pub merged_ranges: Vec, + #[serde(default)] + pub charts: Vec, + #[serde(default)] + pub tables: Vec, + #[serde(default)] + pub conditional_formats: Vec, + #[serde(default)] + pub pivot_tables: Vec, pub default_row_height: Option, pub default_column_width: Option, pub show_grid_lines: bool, @@ -569,6 +581,10 @@ impl SpreadsheetSheet { name, cells: BTreeMap::new(), merged_ranges: Vec::new(), + charts: Vec::new(), + tables: Vec::new(), + conditional_formats: Vec::new(), + pivot_tables: Vec::new(), default_row_height: None, default_column_width: None, show_grid_lines: true, @@ -679,6 +695,10 @@ impl SpreadsheetSheet { default_column_width: self.default_column_width, show_grid_lines: self.show_grid_lines, merged_range_count: self.merged_ranges.len(), + chart_count: self.charts.len(), + table_count: self.tables.len(), + conditional_format_count: self.conditional_formats.len(), + pivot_table_count: self.pivot_tables.len(), } } @@ -1303,6 +1323,9 @@ impl SpreadsheetSheet { } } } + self.validate_tables("cleanup_and_validate_sheet")?; + self.validate_charts("cleanup_and_validate_sheet")?; + self.validate_pivot_tables("cleanup_and_validate_sheet")?; Ok(()) } @@ -1718,7 +1741,24 @@ impl SpreadsheetArtifact { } match selected.as_str() { - "xlsx" => write_xlsx(self, path), + "xlsx" => { + for sheet in &self.sheets { + if !sheet.charts.is_empty() + || !sheet.tables.is_empty() + || !sheet.conditional_formats.is_empty() + || !sheet.pivot_tables.is_empty() + { + return Err(SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: format!( + "xlsx export does not yet support charts, tables, conditional formats, or pivot tables on sheet `{}`; use json or bin export instead", + sheet.name + ), + }); + } + } + write_xlsx(self, path) + } "json" => { let json = self.to_json()?; std::fs::write(path, json).map_err(|error| { diff --git a/codex-rs/artifact-spreadsheet/src/table.rs b/codex-rs/artifact-spreadsheet/src/table.rs index b714a20c2..be04c188e 100644 --- a/codex-rs/artifact-spreadsheet/src/table.rs +++ b/codex-rs/artifact-spreadsheet/src/table.rs @@ -181,7 +181,12 @@ impl SpreadsheetSheet { range: &CellRange, options: SpreadsheetCreateTableOptions, ) -> Result { - validate_table_geometry(action, range, options.header_row_count, options.totals_row_count)?; + validate_table_geometry( + action, + range, + options.header_row_count, + options.totals_row_count, + )?; for table in &self.tables { let table_range = table.range()?; if table_range.intersects(range) { @@ -368,7 +373,12 @@ impl SpreadsheetSheet { let mut seen_display_names = BTreeSet::new(); for table in &self.tables { let range = table.range()?; - validate_table_geometry(action, &range, table.header_row_count, table.totals_row_count)?; + validate_table_geometry( + action, + &range, + table.header_row_count, + table.totals_row_count, + )?; if !seen_names.insert(table.name.clone()) { return Err(SpreadsheetArtifactError::InvalidArgs { action: action.to_string(), @@ -565,7 +575,8 @@ fn build_table_columns( fn unique_table_column_names(names: Vec) -> Vec { let mut seen = BTreeMap::::new(); - names.into_iter() + names + .into_iter() .map(|name| { let entry = seen.entry(name.clone()).or_insert(0); *entry += 1; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 9eb88c66c..c3a37bb98 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -43,6 +43,7 @@ codex-keyring-store = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-artifact-presentation = { workspace = true } +codex-artifact-spreadsheet = { workspace = true } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } codex-state = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1e6c72c0c..7b540aa4d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -58,6 +58,9 @@ use chrono::Utc; use codex_artifact_presentation::PresentationArtifactError; use codex_artifact_presentation::PresentationArtifactExecutionRequest; use codex_artifact_presentation::PresentationArtifactResponse; +use codex_artifact_spreadsheet::SpreadsheetArtifactError; +use codex_artifact_spreadsheet::SpreadsheetArtifactRequest; +use codex_artifact_spreadsheet::SpreadsheetArtifactResponse; use codex_hooks::HookEvent; use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; @@ -1786,7 +1789,16 @@ impl Session { cwd: &Path, ) -> Result { let mut state = self.state.lock().await; - state.presentation_artifacts.execute_requests(request, cwd) + state.artifacts.presentation.execute_requests(request, cwd) + } + + pub(crate) async fn execute_spreadsheet_artifact( + &self, + request: SpreadsheetArtifactRequest, + cwd: &Path, + ) -> Result { + let mut state = self.state.lock().await; + state.artifacts.spreadsheet.execute(request, cwd) } async fn record_initial_history(&self, conversation_history: InitialHistory) { diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 822af421b..babfb044d 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,6 +1,7 @@ //! Session-wide mutable state. use codex_artifact_presentation::PresentationArtifactManager; +use codex_artifact_spreadsheet::SpreadsheetArtifactManager; use codex_protocol::models::ResponseItem; use std::collections::HashMap; use std::collections::HashSet; @@ -17,6 +18,12 @@ use crate::tasks::RegularTask; use crate::truncate::TruncationPolicy; use codex_protocol::protocol::TurnContextItem; +#[derive(Default)] +pub(crate) struct SessionArtifacts { + pub(crate) presentation: PresentationArtifactManager, + pub(crate) spreadsheet: SpreadsheetArtifactManager, +} + /// Persistent, session-scoped state previously stored directly on `Session`. pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, @@ -33,7 +40,7 @@ pub(crate) struct SessionState { pub(crate) startup_regular_task: Option>>, pub(crate) active_mcp_tool_selection: Option>, pub(crate) active_connector_selection: HashSet, - pub(crate) presentation_artifacts: PresentationArtifactManager, + pub(crate) artifacts: SessionArtifacts, } impl SessionState { @@ -51,7 +58,7 @@ impl SessionState { startup_regular_task: None, active_mcp_tool_selection: None, active_connector_selection: HashSet::new(), - presentation_artifacts: PresentationArtifactManager::default(), + artifacts: SessionArtifacts::default(), } } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 49127df10..908f3d18d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -13,6 +13,7 @@ mod read_file; mod request_user_input; mod search_tool_bm25; mod shell; +mod spreadsheet_artifact; mod test_sync; pub(crate) mod unified_exec; mod view_image; @@ -48,6 +49,7 @@ pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME; pub use search_tool_bm25::SearchToolBm25Handler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; +pub use spreadsheet_artifact::SpreadsheetArtifactHandler; pub use test_sync::TestSyncHandler; pub use unified_exec::UnifiedExecHandler; pub use view_image::ViewImageHandler; diff --git a/codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs b/codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs new file mode 100644 index 000000000..be4eb7fc5 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs @@ -0,0 +1,235 @@ +use async_trait::async_trait; +use codex_artifact_spreadsheet::PathAccessKind; +use codex_artifact_spreadsheet::PathAccessRequirement; +use codex_artifact_spreadsheet::SpreadsheetArtifactError; +use codex_artifact_spreadsheet::SpreadsheetArtifactRequest; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ReviewDecision; +use serde_json::to_string; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::features::Feature; +use crate::function_tool::FunctionCallError; +use crate::path_utils::normalize_for_path_comparison; +use crate::path_utils::resolve_symlink_write_paths; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use crate::tools::sandboxing::with_cached_approval; + +pub struct SpreadsheetArtifactHandler; + +#[async_trait] +impl ToolHandler for SpreadsheetArtifactHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + true + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + + if !session.enabled(Feature::Artifact) { + return Err(FunctionCallError::RespondToModel( + "spreadsheet_artifact is disabled by feature flag".to_string(), + )); + } + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "spreadsheet_artifact handler received unsupported payload".to_string(), + )); + } + }; + + let request: SpreadsheetArtifactRequest = parse_arguments(&arguments)?; + for access in request + .required_path_accesses(&turn.cwd) + .map_err(spreadsheet_error)? + { + authorize_path_access(session.as_ref(), turn.as_ref(), &call_id, &access).await?; + } + + let response = session + .execute_spreadsheet_artifact(request, &turn.cwd) + .await + .map_err(spreadsheet_error)?; + + Ok(ToolOutput::Function { + body: FunctionCallOutputBody::Text(to_string(&response).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "failed to serialize spreadsheet_artifact response: {error}" + )) + })?), + success: Some(true), + }) + } +} + +fn spreadsheet_error(error: SpreadsheetArtifactError) -> FunctionCallError { + FunctionCallError::RespondToModel(error.to_string()) +} + +async fn authorize_path_access( + session: &Session, + turn: &TurnContext, + call_id: &str, + access: &PathAccessRequirement, +) -> Result<(), FunctionCallError> { + let effective_path = match access.kind { + PathAccessKind::Read => effective_read_path(&access.path), + PathAccessKind::Write => effective_write_path(&access.path), + }; + let allowed = match access.kind { + PathAccessKind::Read => path_is_readable(turn, &effective_path), + PathAccessKind::Write => path_is_writable(turn, &effective_path), + }; + if allowed { + return Ok(()); + } + + let approval_policy = turn.approval_policy.value(); + if !matches!( + approval_policy, + AskForApproval::OnRequest | AskForApproval::UnlessTrusted + ) { + return Err(FunctionCallError::RespondToModel(format!( + "{} path `{}` is outside the current sandbox policy", + access_kind_label(access.kind), + access.path.display() + ))); + } + + let key = format!( + "spreadsheet_artifact:{:?}:{}", + access.kind, + effective_path.display() + ); + let path = access.path.clone(); + let action = access.action.clone(); + let decision = + with_cached_approval(&session.services, "spreadsheet_artifact", vec![key], || { + let path = path.clone(); + let action = action.clone(); + async move { + session + .request_command_approval( + turn, + call_id.to_string(), + None, + vec![ + "spreadsheet_artifact".to_string(), + action, + path.display().to_string(), + ], + turn.cwd.clone(), + Some(format!( + "Allow spreadsheet_artifact to {} `{}`?", + access_kind_verb(access.kind), + path.display() + )), + None, + None, + None, + None, + ) + .await + } + }) + .await; + + if matches!( + decision, + ReviewDecision::Approved + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedExecpolicyAmendment { .. } + ) { + return Ok(()); + } + + Err(FunctionCallError::RespondToModel(format!( + "{} path `{}` was not approved", + access_kind_label(access.kind), + access.path.display() + ))) +} + +fn path_is_readable(turn: &TurnContext, path: &Path) -> bool { + if turn.sandbox_policy.has_full_disk_read_access() { + return true; + } + + turn.sandbox_policy + .get_readable_roots_with_cwd(&turn.cwd) + .iter() + .any(|root| path.starts_with(root.as_path())) +} + +fn path_is_writable(turn: &TurnContext, path: &Path) -> bool { + if turn.sandbox_policy.has_full_disk_write_access() { + return true; + } + + turn.sandbox_policy + .get_writable_roots_with_cwd(&turn.cwd) + .iter() + .any(|root| root.is_path_writable(path)) +} + +fn effective_read_path(path: &Path) -> PathBuf { + normalize_for_path_comparison(path).unwrap_or_else(|_| normalize_without_fs(path)) +} + +fn effective_write_path(path: &Path) -> PathBuf { + let write_path = resolve_symlink_write_paths(path) + .map(|paths| paths.write_path) + .unwrap_or_else(|_| path.to_path_buf()); + normalize_for_path_comparison(&write_path).unwrap_or_else(|_| normalize_without_fs(&write_path)) +} + +fn normalize_without_fs(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::ParentDir => { + normalized.pop(); + } + Component::CurDir => {} + other => normalized.push(other.as_os_str()), + } + } + normalized +} + +fn access_kind_label(kind: PathAccessKind) -> &'static str { + match kind { + PathAccessKind::Read => "read", + PathAccessKind::Write => "write", + } +} + +fn access_kind_verb(kind: PathAccessKind) -> &'static str { + match kind { + PathAccessKind::Read => "read from", + PathAccessKind::Write => "write to", + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 267be909f..0e5367075 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -35,8 +35,6 @@ use std::collections::HashMap; const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_description.md"); -const PRESENTATION_ARTIFACT_DESCRIPTION_TEMPLATE: &str = - include_str!("../../templates/tools/presentation_artifact.md"); #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum ShellCommandBackendConfig { @@ -57,7 +55,7 @@ pub(crate) struct ToolsConfig { pub js_repl_enabled: bool, pub js_repl_tools_only: bool, pub collab_tools: bool, - pub presentation_artifact: bool, + pub artifact_tools: bool, pub default_mode_request_user_input: bool, pub experimental_supported_tools: Vec, pub agent_jobs_tools: bool, @@ -87,7 +85,7 @@ impl ToolsConfig { let include_default_mode_request_user_input = features.enabled(Feature::DefaultModeRequestUserInput); let include_search_tool = features.enabled(Feature::Apps); - let include_presentation_artifact = features.enabled(Feature::Artifact); + let include_artifact_tools = features.enabled(Feature::Artifact); let include_agent_jobs = include_collab_tools && features.enabled(Feature::Sqlite); let request_permission_enabled = features.enabled(Feature::RequestPermissions); let shell_command_backend = @@ -143,7 +141,7 @@ impl ToolsConfig { js_repl_enabled: include_js_repl, js_repl_tools_only: include_js_repl_tools_only, collab_tools: include_collab_tools, - presentation_artifact: include_presentation_artifact, + artifact_tools: include_artifact_tools, default_mode_request_user_input: include_default_mode_request_user_input, experimental_supported_tools: model_info.experimental_supported_tools.clone(), agent_jobs_tools: include_agent_jobs, @@ -609,7 +607,7 @@ fn create_presentation_artifact_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "presentation_artifact".to_string(), - description: PRESENTATION_ARTIFACT_DESCRIPTION_TEMPLATE.to_string(), + description: "Create or edit a presentation artifact for the current thread.".to_string(), strict: false, parameters: JsonSchema::Object { properties, @@ -619,6 +617,44 @@ fn create_presentation_artifact_tool() -> ToolSpec { }) } +fn create_spreadsheet_artifact_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "artifact_id".to_string(), + JsonSchema::String { + description: Some( + "Artifact id returned by an earlier spreadsheet_artifact call.".to_string(), + ), + }, + ), + ( + "action".to_string(), + JsonSchema::String { + description: Some("Action name to run for this request.".to_string()), + }, + ), + ( + "args".to_string(), + JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some(true.into()), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "spreadsheet_artifact".to_string(), + description: "Create or edit a spreadsheet artifact for the current thread.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["action".to_string(), "args".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} + fn create_collab_input_items_schema() -> JsonSchema { let properties = BTreeMap::from([ ( @@ -1722,6 +1758,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::SearchToolBm25Handler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; + use crate::tools::handlers::SpreadsheetArtifactHandler; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::UnifiedExecHandler; use crate::tools::handlers::ViewImageHandler; @@ -1745,6 +1782,7 @@ pub(crate) fn build_specs( let js_repl_handler = Arc::new(JsReplHandler); let js_repl_reset_handler = Arc::new(JsReplResetHandler); let presentation_artifact_handler = Arc::new(PresentationArtifactHandler); + let spreadsheet_artifact_handler = Arc::new(SpreadsheetArtifactHandler); let request_permission_enabled = config.request_permission_enabled; match &config.shell_type { @@ -1882,9 +1920,11 @@ pub(crate) fn build_specs( builder.push_spec_with_parallel_support(create_view_image_tool(), true); builder.register_handler("view_image", view_image_handler); - if config.presentation_artifact { + if config.artifact_tools { builder.push_spec(create_presentation_artifact_tool()); + builder.push_spec(create_spreadsheet_artifact_tool()); builder.register_handler("presentation_artifact", presentation_artifact_handler); + builder.register_handler("spreadsheet_artifact", spreadsheet_artifact_handler); } if config.collab_tools { @@ -2201,7 +2241,7 @@ mod tests { session_source: SessionSource::Cli, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert_contains_tool_names(&tools, &["presentation_artifact"]); + assert_contains_tool_names(&tools, &["presentation_artifact", "spreadsheet_artifact"]); } #[test]