Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/agent/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ fn tool_index() -> Vec<Value> {
"plotium_properties_inspect"=>(vec!["properties.inspect"],"ToolResult.value: {property,canonical_label,tier,aggregate,readings,schema?,default_value?}; schema and default_value move out of readings when every reading shares them; ToolResult.targets lists only skips and failures","ids expand by property, or exact Series TargetRef[]"),
"plotium_plan"=>(EDIT_OPERATIONS.to_vec(),"{plan_id,project_context?,basis_revision?,preflight:ToolPlan|edit_preflight{ToolPlan without request and frozen_targets,summary:{compatible,skipped,failed},targets:non-compatible PlannedTarget[]}|creation_preflight{required_authority,created_target_count,targets,outputs,binding_plan{creation,binding_count,row_counts,bindings:issue_diagnostics[]}}|property_preflight{required_authority,estimated_changes,summary:{compatible,skipped,failed},targets:non-compatible PlannedTarget[]}|save_preflight|export_preflight,save_id?,export_id?,import?:{status,items},transform?:{status,output},update?:{status,output,before_table_revision,changed}}; native import or table_data_only import/transform; import omits ids/targets, transform requires one DatasetId in ids; prepared is not committed","rename: dataset/canvas/content IDs; properties: ids or exact Series targets; transform: one DatasetId; creation: explicit parent inside parameters, no top-level selector"),
"plotium_execute"=>(EDIT_OPERATIONS.to_vec(),"ToolResult for edits; plot.create adds summary and keeps exceptional targets, full bindings/layout and preflight-only defaults; properties.set/set_many/reset add summary {succeeded,skipped,failed,by_reason} and keep only non-succeeded targets and value.writes; figure.edit, plot.series.*, scientific plots, rename, themes and dataset.delete add summary {succeeded,skipped,failed} and keep only non-succeeded targets; {save:SaveReceipt} or {export:ExportReceipt} for external writes. Submitted plan is consumed even on failure","one-use plan_id"),
"plotium_apply"=>(crate::session::apply::APPLY_OPERATIONS.to_vec(),"Same as plotium_execute for the applied operation; nothing is retained to execute again","Same as plotium_plan"),
"plotium_document_inspect"=>(vec!["document.inspect"],"ToolResult.value: self {revision,node:{target,kind,name,visible,ancestors,child_count,...}}; children {revision,target,items,total,next_offset,truncated}","document, canvas, panel, content or exact Series TargetRef"),
"plotium_table_columns"=>(vec!["table.columns"],"ToolResult.value: {dataset_id,table_id,table_revision,snapshot_fingerprint,fields,columns,total_rows,diagnostics,inference,freshness,analyses,provenance?}","explicit DatasetId and table RevisionId"),
"plotium_table_read_rows"=>(vec!["table.read_rows"],"ToolResult.value: {dataset_id,table_id,table_revision,revision,column_ids,offset,requested_rows,returned_rows,total_rows,next_offset,end,rows:[{row_id,values,sources?}]} ; typed 64-bit integer values are decimal strings","explicit DatasetId, table RevisionId and ColumnId projection"),
Expand All @@ -204,7 +205,7 @@ fn tool_index() -> Vec<Value> {
};
let mut entry = json!({"name":tool.name,"operations":operations,
"result_contract":result,"targets":targets,
"required_permission":if tool.name=="plotium_execute"{"operation_specific: reversible_modify or per_request_desktop_save/export_authorization"}else if tool.name=="plotium_plan"{"operation_specific: read or reversible_modify"}else if matches!(tool.name.as_ref(),"plotium_preview_plan"|"plotium_tasks_cancel") {"reversible_modify"} else{"read"}});
"required_permission":if tool.name=="plotium_apply"{"reversible_modify"}else if tool.name=="plotium_execute"{"operation_specific: reversible_modify or per_request_desktop_save/export_authorization"}else if tool.name=="plotium_plan"{"operation_specific: read or reversible_modify"}else if matches!(tool.name.as_ref(),"plotium_preview_plan"|"plotium_tasks_cancel") {"reversible_modify"} else{"read"}});
// plan and execute accept every editable operation; the index lists them once.
if matches!(tool.name.as_ref(), "plotium_plan" | "plotium_execute") {
entry.as_object_mut().expect("local entry").remove("operations");
Expand Down
11 changes: 10 additions & 1 deletion crates/agent/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ impl ServerHandler for Handler {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("Plotium", env!("CARGO_PKG_VERSION")))
.with_instructions("Known operations can fetch their static detail directly; otherwise browse the plotium_capabilities_get index. Read plotium_get_state first. Supply its project_context as expected_project_context on project queries and planning. On project_changed, reread state and replan; never reuse targets or plans. For known exact targets, committed synchronous results provide project_context and after_revision for the next call. Refresh state after desktop edits or Undo. Never replay a tool call after a disconnection.")
.with_instructions("Known operations can fetch their static detail directly; otherwise browse the plotium_capabilities_get index. Read plotium_get_state first. Supply its project_context as expected_project_context on project queries and planning. On project_changed, reread state and replan; never reuse targets or plans. For known exact targets, committed synchronous results provide project_context and after_revision for the next call. Refresh state after desktop edits or Undo. For a reversible edit, plotium_apply plans and commits in one call. Never replay a tool call after a disconnection.")
}
async fn list_tools(
&self,
Expand Down Expand Up @@ -146,6 +146,15 @@ pub(crate) fn definitions() -> Vec<Tool> {
let schema = json!({"type": "object", "properties": properties, "required": required, "additionalProperties": false});
Tool::new(name, description, schema.as_object().expect("local schema is an object").clone())
}).collect();
let mut apply = tools
.iter()
.find(|t| t.name == "plotium_plan")
.expect("plan tool")
.input_schema
.as_ref()
.clone();
apply["properties"]["tool_id"] = json!({"enum": crate::session::apply::APPLY_OPERATIONS});
tools.push(Tool::new("plotium_apply", "Plan and commit one reversible edit in a single call, with plotium_plan inputs; returns the plotium_execute result. Saving, export, deletions, imports, table and scientific operations use plotium_plan then plotium_execute. After a disconnection read state: an unchanged revision means nothing was committed. Never replay.", apply));
tools.push(Tool::new("plotium_document_inspect", "Inspect bounded document structure and source bindings by full stable target. view self returns details; children pages direct children. Pagination is only valid with view children. Never uses selection.", document.as_object().expect("local schema is an object").clone()));
let mut diagnostic = plotium_core::automation::figure_diagnostic_schema();
diagnostic["properties"]["expected_revision"] = json!({"type":"integer","minimum":0});
Expand Down
4 changes: 4 additions & 0 deletions crates/agent/src/session.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Desktop-thread capability boundary. No project state crosses the bridge.
use std::collections::HashMap;
pub(crate) mod apply;
mod candidate;
mod compact;
mod creation_response;
Expand Down Expand Up @@ -232,6 +233,9 @@ impl Session {
mut arguments: Value,
blocked: bool,
) -> Result<Value, Failure> {
if name == "plotium_apply" {
return self.apply(app, arguments, blocked);
}
if name == "plotium_properties_list" {
return crate::property_catalog::respond(arguments);
}
Expand Down
70 changes: 70 additions & 0 deletions crates/agent/src/session/apply.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! One call that plans and commits a reversible edit. It reuses the plan and
//! execute paths unchanged, so preflight, permissions, revision checks, Undo
//! and audit records are exactly those of the two-step form.
use super::*;

/// Reversible edits that write no files and start no background work.
/// Deletions keep the two-step form so callers review them first.
pub(crate) const APPLY_OPERATIONS: [&str; 10] = [
TOOL_SET,
TOOL_SET_MANY,
TOOL_RESET,
FIGURE_EDIT,
PLOT_CREATE,
"plot.series.append",
"plot.series.bind",
"plot.series.materialize_default",
"resource.rename",
"figure.apply_theme",
];

const DELETIONS: [&str; 3] = ["content_delete", "panel_delete", "series_delete"];

impl Session {
pub(super) fn apply(
&mut self,
app: &mut PlotiumApp,
arguments: Value,
blocked: bool,
) -> Result<Value, Failure> {
let tool_id = arguments.get("tool_id").and_then(Value::as_str);
if !tool_id.is_some_and(|id| APPLY_OPERATIONS.contains(&id)) {
return Err(Failure::new(
"unsupported_operation",
"plotium_apply accepts reversible edits only; plan this operation with plotium_plan and submit it with plotium_execute",
)
.at_field("tool_id"));
}
let deletes = arguments
.pointer("/parameters/edits")
.and_then(Value::as_array)
.is_some_and(|edits| {
edits
.iter()
.any(|e| e["op"].as_str().is_some_and(|op| DELETIONS.contains(&op)))
});
if tool_id == Some(FIGURE_EDIT) && deletes {
return Err(Failure::new(
"unsupported_operation",
"Deletions are reviewed before they run; use plotium_plan, then plotium_execute",
)
.at_field("parameters.edits"));
}
if !self.allow_modify {
return Err(Failure::new(
"permission_denied",
"Switch to Read and modify before applying edits",
));
}
let plan = self.call_inner(app, "plotium_plan", arguments, blocked)?;
let id = plan["plan_id"]
.as_str()
.expect("plans have an id")
.to_owned();
self.call_inner(app, "plotium_execute", json!({ "plan_id": id }), blocked)
}
}

#[cfg(test)]
#[path = "apply_tests.rs"]
mod tests;
172 changes: 172 additions & 0 deletions crates/agent/src/session/apply_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
use super::*;
use plotium_core::{
data::*,
state::{Dataset, TableDataset},
};

fn fixture() -> (PlotiumApp, Session, Value) {
let mut app = PlotiumApp::new_with_settings(Default::default());
let columns = [
("x", vec![Some(0.), Some(1.), Some(2.)]),
("y", vec![Some(1.), Some(3.), Some(2.)]),
]
.into_iter()
.map(|(name, values)| {
(
ColumnSchema::new(name, LogicalType::Float64),
ColumnChunk::optional_f64(values),
)
})
.collect();
let table =
TableDataset::from_materialized(columns, vec![], None, vec![], "plotium.test.apply")
.unwrap();
app.doc.datasets.push(Dataset::Table(Box::new(table)));
let table = app.doc.datasets[0].as_table().unwrap();
let r = &table.typed_state.envelope.revision;
let c = &r.snapshot.schema.columns;
let parameters = json!({"parent":plotium_core::automation::document_root_target(),"name":"P",
"series":[{"kind":"line","source":{"dataset_id":table.resource_id,
"field_id":app.doc.datasets[0].default_field_id().unwrap(),"table_id":r.table_id,
"table_revision":r.id,"x_column_id":c[0].id,"y_column_id":c[1].id,"y_error_column_id":null}}]});
let mut session = Session::default();
session.set_allow_modify(true);
(app, session, parameters)
}

fn call(
session: &mut Session,
app: &mut PlotiumApp,
name: &str,
tool: &str,
parameters: Value,
) -> std::result::Result<Value, Failure> {
let request = args(app, tool, parameters);
session.call(app, name, request)
}

fn args(app: &PlotiumApp, tool: &str, parameters: Value) -> Value {
json!({"expected_project_context":app.session.project_context,
"expected_revision":app.doc.automation_revision,"tool_id":tool,"parameters":parameters})
}

#[test]
fn apply_commits_like_plan_then_execute() {
let (mut two_step, mut s2, create) = fixture();
let plan = call(
&mut s2,
&mut two_step,
"plotium_plan",
PLOT_CREATE,
create.clone(),
)
.unwrap();
let executed = s2
.call(
&mut two_step,
"plotium_execute",
json!({"plan_id":plan["plan_id"]}),
)
.unwrap();
let (mut one_step, mut s1, create) = fixture();
let applied = call(&mut s1, &mut one_step, "plotium_apply", PLOT_CREATE, create).unwrap();
assert!(s1.plans.is_empty());
for key in ["tool_id", "before_revision", "after_revision", "summary"] {
assert_eq!(applied[key], executed[key], "{key}");
}
assert_eq!(
applied["project_context"],
json!(one_step.session.project_context)
);
assert_eq!(
one_step.doc.automation_runs.len(),
two_step.doc.automation_runs.len()
);
assert_eq!(
one_step.session.undo_stack.len(),
two_step.session.undo_stack.len()
);
let target = applied["value"]["bindings"][0]["target"].clone();
let write = json!({"writes":[{"target":target,"key":"series.line.color","value":"#123456"}]});
let styled = call(
&mut s1,
&mut one_step,
"plotium_apply",
TOOL_SET_MANY,
write,
)
.unwrap();
assert_eq!(styled["summary"]["succeeded"], 1);
let steps = one_step.session.undo_stack.len();
one_step.undo();
assert_eq!(one_step.session.undo_stack.len(), steps - 1);
}

#[test]
fn apply_refuses_deletions_files_read_only_and_stale_requests_without_retaining_plans() {
let (mut app, mut session, create) = fixture();
let dataset = app.doc.datasets[0].resource_id().to_string();
for (tool, parameters) in [
(DATASET_DELETE, json!({})),
("project.save", json!({"path":"/tmp/never.plotium"})),
("data.import", json!({"paths":["/tmp/never.csv"]})),
] {
let mut request = args(&app, tool, parameters);
if tool == DATASET_DELETE {
request["ids"] = json!([dataset]);
}
let error = session
.call(&mut app, "plotium_apply", request)
.unwrap_err();
assert_eq!(error.code, "unsupported_operation", "{tool}");
}
let deletion = json!({"canvas":{"resource":{"id":uuid::Uuid::new_v4(),"kind":"plotium.canvas"}},
"edits":[{"op":"content_delete","target":{"resource":{"id":"c/1","kind":"plotium.canvas.object"}}}]});
let error = call(
&mut session,
&mut app,
"plotium_apply",
FIGURE_EDIT,
deletion,
)
.unwrap_err();
assert_eq!(error.code, "unsupported_operation");
let mut stale = args(&app, PLOT_CREATE, create.clone());
stale["expected_revision"] = json!(app.doc.automation_revision + 1);
let error = session.call(&mut app, "plotium_apply", stale).unwrap_err();
assert_eq!(error.code, "stale_revision");
let recovery = error.value()["error"]["recovery"].clone();
assert_eq!(recovery["committed"], false);
assert_eq!(recovery["submission"], "not_submitted");
session.set_allow_modify(false);
let error = call(&mut session, &mut app, "plotium_apply", PLOT_CREATE, create).unwrap_err();
assert_eq!(error.code, "permission_denied");
assert!(session.plans.is_empty());
assert!(app.doc.canvases.is_empty());
assert_eq!(app.doc.automation_revision, 0);
}

#[test]
fn interrupted_apply_reports_an_unknown_outcome() {
let request = json!({"tool_id":PLOT_CREATE,"parameters":{}});
for code in ["request_cancelled", "connection_stopped"] {
let recovery = Failure::new(code, "interrupted")
.for_request("plotium_apply", &request)
.value()["error"]["recovery"]
.clone();
assert_eq!(recovery["submission"], "unknown", "{code}");
assert_eq!(recovery["committed"], Value::Null, "{code}");
assert_eq!(recovery["completion"], "unknown", "{code}");
assert_eq!(recovery["replay_allowed"], false, "{code}");
let planned = Failure::new(code, "interrupted")
.for_request("plotium_plan", &request)
.value()["error"]["recovery"]
.clone();
assert_eq!(planned["committed"], false, "{code}");
}
let rejected = Failure::new("busy", "Desktop queue is full")
.for_request("plotium_apply", &request)
.value()["error"]["recovery"]
.clone();
assert_eq!(rejected["committed"], false);
}
12 changes: 8 additions & 4 deletions crates/agent/src/session/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ pub(super) fn for_error(code: &str) -> Value {

impl Failure {
pub(crate) fn for_request(mut self, name: &str, arguments: &Value) -> Self {
if name == "plotium_plan"
let planning = matches!(name, "plotium_plan" | "plotium_apply");
if planning
&& self.code == "invalid_parameters"
&& self.details.as_ref().is_some_and(|details| {
details["field"].is_null()
Expand All @@ -116,7 +117,7 @@ impl Failure {
let field = self.details.as_ref().and_then(|d| d["field"].as_str());
let query = if name == "plotium_capabilities_get" {
Some(json!({}))
} else if name == "plotium_plan"
} else if planning
&& !matches!(
field,
Some("expected_project_context" | "expected_revision")
Expand Down Expand Up @@ -163,8 +164,11 @@ impl Failure {
}
}
self.recovery = Some(value);
if name == "plotium_plan" {
// Preflight can retain candidates, but never submits or commits execution.
// A desktop error means nothing was committed: apply plans and executes
// atomically. A cancelled or disconnected apply may still have committed.
let unknown = name == "plotium_apply"
&& matches!(self.code, "request_cancelled" | "connection_stopped");
if planning && !unknown {
self.not_submitted()
} else {
self
Expand Down
2 changes: 1 addition & 1 deletion crates/agent/src/session/result_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use super::*;
pub(super) fn annotate(app: &PlotiumApp, name: &str, value: &mut Value) {
// Receipt revisions describe their own lifecycle, possibly in another project.
// A submitted task's after_revision is not evidence of a committed edit.
if name == "plotium_execute"
if matches!(name, "plotium_execute" | "plotium_apply")
&& value["tool_id"].is_string()
&& value["before_revision"].is_number()
&& value["after_revision"] == json!(app.doc.automation_revision)
Expand Down
2 changes: 1 addition & 1 deletion crates/agent/src/tool_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ impl ToolEffect {
| "plotium_export_status" => Self::Read,
"plotium_figure_diagnose" => Self::Diagnostic,
"plotium_plan" => Self::Prepare,
"plotium_execute" => Self::Execute,
"plotium_execute" | "plotium_apply" => Self::Execute,
"plotium_tasks_cancel" => Self::Cancel,
_ => unreachable!("every advertised tool needs an effect classification"),
}
Expand Down
4 changes: 2 additions & 2 deletions crates/agent/src/tool_effects_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ fn every_advertised_tool_has_explicit_conservative_annotations() {
"plotium_export_status",
];
let definitions = protocol::definitions();
assert_eq!(definitions.len(), reads.len() + 4);
assert_eq!(definitions.len(), reads.len() + 5);
for tool in definitions {
let (read, destructive, idempotent, external) = match tool.name.as_ref() {
"plotium_plan" => (false, false, false, true),
"plotium_execute" => (false, true, false, true),
"plotium_execute" | "plotium_apply" => (false, true, false, true),
"plotium_tasks_cancel" => (false, true, true, false),
"plotium_figure_diagnose" => (true, false, false, false),
name if reads.contains(&name) => (true, false, true, false),
Expand Down
Loading
Loading