mirror of
https://github.com/openai/codex.git
synced 2026-05-29 23:40:29 +00:00
feat: wire spreadsheet artifact (#13362)
This commit is contained in:
@@ -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;
|
||||
|
||||
235
codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs
Normal file
235
codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user