feat: wire spreadsheet artifact (#13362)

This commit is contained in:
jif-oai 2026-03-03 15:27:37 +00:00 committed by GitHub
parent 24ba01b9da
commit 8159f05dfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1180 additions and 23 deletions

1
codex-rs/Cargo.lock generated
View file

@ -1802,6 +1802,7 @@ dependencies = [
"codex-apply-patch",
"codex-arg0",
"codex-artifact-presentation",
"codex-artifact-spreadsheet",
"codex-async-utils",
"codex-client",
"codex-config",

View file

@ -64,7 +64,7 @@ pub struct SpreadsheetChart {
}
#[derive(Debug, Clone, Default)]
pub struct SpreadsheetChartLookup<'a> {
pub struct SpreadsheetChartLookup {
pub id: Option<u32>,
pub index: Option<usize>,
}
@ -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<u32, SpreadsheetArtifactError> {
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,
}
});
}

View file

@ -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,

View file

@ -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::*;

View file

@ -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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
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<Vec<SpreadsheetSheetSummary>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chart_list: Option<Vec<crate::SpreadsheetChart>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table_list: Option<Vec<crate::SpreadsheetTableView>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conditional_format_list: Option<Vec<crate::SpreadsheetConditionalFormat>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pivot_table_list: Option<Vec<crate::SpreadsheetPivotTable>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sheet_ref: Option<SpreadsheetSheetRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cell_ref: Option<SpreadsheetCellRef>,
@ -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<String>,
}
#[derive(Debug, Deserialize)]
struct ChartLookupArgs {
sheet_name: Option<String>,
sheet_index: Option<u32>,
chart_id: Option<u32>,
chart_index: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct CreateChartArgs {
sheet_name: Option<String>,
sheet_index: Option<u32>,
chart_type: crate::SpreadsheetChartType,
source_sheet_name: Option<String>,
source_range: String,
options: Option<crate::SpreadsheetChartCreateOptions>,
}
#[derive(Debug, Deserialize)]
struct AddChartSeriesArgs {
sheet_name: Option<String>,
sheet_index: Option<u32>,
chart_id: Option<u32>,
chart_index: Option<u32>,
series: crate::SpreadsheetChartSeries,
}
#[derive(Debug, Deserialize)]
struct SetChartPropertiesArgs {
#[serde(flatten)]
lookup: ChartLookupArgs,
properties: crate::SpreadsheetChartProperties,
}
#[derive(Debug, Deserialize)]
struct TableLookupArgs {
sheet_name: Option<String>,
sheet_index: Option<u32>,
name: Option<String>,
display_name: Option<String>,
id: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct CreateTableArgs {
sheet_name: Option<String>,
sheet_index: Option<u32>,
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<u32>,
column_name: Option<String>,
new_name: String,
}
#[derive(Debug, Deserialize)]
struct SetTableColumnTotalsArgs {
#[serde(flatten)]
lookup: TableLookupArgs,
column_id: Option<u32>,
column_name: Option<String>,
totals_row_label: Option<String>,
totals_row_function: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddConditionalFormatArgs {
sheet_name: Option<String>,
sheet_index: Option<u32>,
format: crate::SpreadsheetConditionalFormat,
}
#[derive(Debug, Deserialize)]
struct DeleteConditionalFormatArgs {
sheet_name: Option<String>,
sheet_index: Option<u32>,
id: u32,
}
#[derive(Debug, Deserialize)]
struct PivotTableLookupArgs {
sheet_name: Option<String>,
sheet_index: Option<u32>,
name: Option<String>,
index: Option<u32>,
}
#[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(),

View file

@ -457,6 +457,10 @@ pub struct SpreadsheetSheetSummary {
pub default_column_width: Option<f64>,
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<CellAddress, SpreadsheetCell>,
#[serde(default)]
pub merged_ranges: Vec<CellRange>,
#[serde(default)]
pub charts: Vec<crate::SpreadsheetChart>,
#[serde(default)]
pub tables: Vec<crate::SpreadsheetTable>,
#[serde(default)]
pub conditional_formats: Vec<crate::SpreadsheetConditionalFormat>,
#[serde(default)]
pub pivot_tables: Vec<crate::SpreadsheetPivotTable>,
pub default_row_height: Option<f64>,
pub default_column_width: Option<f64>,
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| {

View file

@ -181,7 +181,12 @@ impl SpreadsheetSheet {
range: &CellRange,
options: SpreadsheetCreateTableOptions,
) -> Result<u32, SpreadsheetArtifactError> {
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<String>) -> Vec<String> {
let mut seen = BTreeMap::<String, u32>::new();
names.into_iter()
names
.into_iter()
.map(|name| {
let entry = seen.entry(name.clone()).or_insert(0);
*entry += 1;

View file

@ -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 }

View file

@ -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<PresentationArtifactResponse, PresentationArtifactError> {
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<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
let mut state = self.state.lock().await;
state.artifacts.spreadsheet.execute(request, cwd)
}
async fn record_initial_history(&self, conversation_history: InitialHistory) {

View file

@ -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<JoinHandle<CodexResult<RegularTask>>>,
pub(crate) active_mcp_tool_selection: Option<Vec<String>>,
pub(crate) active_connector_selection: HashSet<String>,
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(),
}
}

View file

@ -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;

View file

@ -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<ToolOutput, FunctionCallError> {
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",
}
}

View file

@ -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<String>,
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]