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
37 changes: 27 additions & 10 deletions crates/agent/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ use plotium_core::automation::{
};
use serde_json::{Value, json};

pub(crate) const EDIT_OPERATIONS: [&str; 28] = [
pub(crate) const EDIT_OPERATIONS: [&str; 30] = [
"analysis.diffusion.plot",
"plot.field.create",
"analysis.dosy.build",
"analysis.regions.extract",
"analysis.diffusion.fit",
"nmr.volume.edit",
"nmr.peaks3d",
"protein.edit",
"protein.assignments.edit",
"processing.edit",
"processing.apply_scheme",
"diffusion.set_input",
Expand All @@ -37,7 +39,17 @@ pub(crate) const EDIT_OPERATIONS: [&str; 28] = [

/// One line per editable operation, so a caller can map a task to an operation
/// before fetching its schema.
const PURPOSES: [(&str, &str); 32] = [
const PURPOSES: [(&str, &str); 36] = [
("protein.edit", "Import and bind protein sequences"),
(
"protein.assignments.edit",
"Edit and confirm 3D assignments",
),
("protein.inspect", "Read with plotium_protein_inspect"),
(
"protein.assignments.export",
"Read with plotium_protein_assignments_export",
),
("nmr.peaks3d", "Add, edit, delete or locate manual 3D peaks"),
("nmr.peaks3d.list", "Read with plotium_peaks3d_inspect"),
(
Expand Down Expand Up @@ -172,7 +184,9 @@ const KEYED: [&str; 5] = [
"limits",
];
/// Operations that name their targets inside parameters and omit ids/targets.
pub(crate) const NO_SELECTOR: [&str; 11] = [
pub(crate) const NO_SELECTOR: [&str; 13] = [
"protein.edit",
"protein.assignments.edit",
"data.import",
"project.save",
"figure.export",
Expand All @@ -196,6 +210,8 @@ fn tool_index() -> Vec<Value> {
"plotium_tasks_cancel"=>(vec!["tasks.cancel"],"{task:TaskReceipt}; task_not_found never authorizes replay","TaskId; callable while busy or after project change; requires reversible_modify"),
"plotium_analysis_results_inspect"=>(vec!["analysis.results.inspect"],"ToolResult.value: paginated native DOSY/ILT columns, validity, counts and provenance","one result field ID"),
"plotium_diffusion_inspect"=>(vec!["diffusion.inspect"],"ToolResult.value: {resolution_source,increment_count,input:DiffusionInputReport,field_requirement,executable,acquisition_evidence}","one dataset or field ID"),
"plotium_protein_inspect"=>(vec!["protein.inspect"],"Protein sequences, bindings and paginated assignments","document"),
"plotium_protein_assignments_export"=>(vec!["protein.assignments.export"],"One molecule's JSON assignment report","document"),
"plotium_peaks3d_inspect"=>(vec!["nmr.peaks3d.list"],"ToolResult.value: {dataset,dataset_name,source,processing,axes,peaks,position,intensity}","one 3D dataset ID"),
"plotium_processing_inspect"=>(vec!["processing.inspect"],"ToolResult.value: {dataset,axes:[{axis,pipeline|settings,pending_apply}]}","one dataset ID"),
"plotium_get_state"=>(vec!["state.get"],"{project_context,revision,availability,project:{dataset_count,canvas_count},selection:{items,total,next_offset,canvas:{items:[{target,kind,name,visible}],total,part}},hand_edits:{items:[{target,key,label,value}],total,next_offset},permissions,capability_fingerprint,save_state}; latest state per call, not a frozen selection snapshot","application; project, selection and hand_edits are null while busy"),
Expand Down Expand Up @@ -246,7 +262,8 @@ fn operation_constraints() -> serde_json::Map<String, Value> {
let scientific = id.starts_with("processing.")
|| id.starts_with("diffusion.")
|| id.starts_with("analysis.")
|| id.starts_with("nmr.");
|| id.starts_with("nmr.")
|| id.starts_with("protein.");
constraints.insert(id.into(), json!({"schema":schema,"preflight_permission":if scientific {"reversible_modify"} else {"read"},"execute_permission":"reversible_modify"}));
}
constraints["dataset.delete"]["result_contract"] = json!(
Expand All @@ -263,7 +280,7 @@ fn sections() -> serde_json::Map<String, Value> {
let constraints = operation_constraints();
let details = crate::operation_details::build(&constraints["figure.edit"]);
let scientific = plotium_core::automation::ToolRegistry::built_in().descriptors()
.filter(|d|d.id.starts_with("processing.")||d.id.starts_with("diffusion.")||d.id.starts_with("analysis.")||d.id.starts_with("nmr."))
.filter(|d|d.id.starts_with("processing.")||d.id.starts_with("diffusion.")||d.id.starts_with("analysis.")||d.id.starts_with("nmr.")||d.id.starts_with("protein."))
.map(|d| {
let mut entry = json!({"descriptor":d,"required_permission":if d.effect==plotium_core::automation::EffectLevel::ReadOnly{"read"}else{"reversible_modify"}});
if EDIT_OPERATIONS.contains(&d.id.as_str()) {
Expand Down Expand Up @@ -349,12 +366,12 @@ fn build_index(sections: &serde_json::Map<String, Value>) -> Value {
}).collect::<serde_json::Map<_,_>>();
json!({"tools":tools,"editable_operations":operations,"scientific_operations":scientific,
"sections":listing,
"properties":"Property definitions and ids are listed by plotium_properties_list.",
"property_batch":{"operation":"properties.set_many","writes_min":1,"writes_max":200,"atomic":true,"undo_steps":1,"selectors":"exact target/key/value per write; omit top-level ids/targets","target_source":"plot.create ToolResult.value.bindings[].target and value.creation.plot; preserve full resource and component identity","unsupported_property":"error.details.write_index and key identify the rejected write"},
"recovery_contract":{"location":"error.recovery or receipt.recovery","unknown":"null plan_consumed/committed and unknown submission/completion are not evidence of non-execution","retryable":"false: follow next_action, never replay the failed request","authorization":"recovery actions and annotations grant no permissions; file requests require fresh exact-path desktop consent"},
"resource_capabilities":"Core resource capabilities describe data/rendering semantics, not operations exposed by this connection. Only the operations above can be called here.",
"properties":"See plotium_properties_list.",
"property_batch":{"operation":"properties.set_many","writes_min":1,"writes_max":200,"atomic":true,"undo_steps":1,"selectors":"exact target/key/value; omit ids/targets","target_source":"plot.create value.bindings[].target or value.creation.plot","unsupported_property":"see error.details.write_index and key"},
"recovery_contract":{"location":"error.recovery or receipt.recovery","unknown":"null plan_consumed/committed or unknown submission/completion do not rule out execution","retryable":"false: follow next_action; never replay","authorization":"no permission granted; file requests need exact-path desktop consent"},
"resource_capabilities":"Core resource capabilities describe data and rendering. Callable operations are listed above.",
"document_root":document_root_target(),
"revision_contract":"Committed synchronous execute results include project_context and after_revision. Reuse them for known exact targets in that context; refresh state after Undo, desktop changes, uncertain outcomes or when current permissions/selection are needed. Submitted tasks are not committed; only matching succeeded terminal receipts with end_revision permit reuse. Revision-bound reads and plans reject stale revisions; execute rechecks permission and revision. Queries do not append project run records or change dirty state, edit generation or revision."})
"revision_contract":"Reuse project_context and after_revision from a committed result, or end_revision from a matching succeeded task. Refresh after Undo, desktop changes, uncertain outcomes, or to check permission or selection. Reads and plans reject stale revisions; execute rechecks revision and permission. Queries leave the document and audit unchanged."})
}

/// Built-in themes are fixed in the binary, so they belong to the static
Expand Down
3 changes: 2 additions & 1 deletion crates/agent/src/capabilities_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ fn fingerprint_covers_nested_contracts_and_ignores_object_order() {
#[test]
fn index_is_compact_and_names_every_detail_section() {
let index = respond(json!({})).unwrap();
assert!(serde_json::to_vec(&index).unwrap().len() <= 12 * 1024);
let bytes = serde_json::to_vec(&index).unwrap().len();
assert!(bytes <= 12 * 1024, "capability index is {bytes} bytes");
let caps = &index["connection_capabilities"];
for key in [
"property_definitions",
Expand Down
132 changes: 132 additions & 0 deletions crates/agent/src/peaks3d_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,135 @@ fn mcp_peak_edits_and_readonly_export_match_desktop_core() {
direct.peak_table_3d(owner).unwrap()
);
}

#[test]
fn mcp_protein_uses_document_target_permissions_and_revision() {
let mut app = PlotiumApp::new();
let mut session = Session::default();
let context = app.session.project_context;
let request = json!({
"tool_id":"protein.edit",
"parameters":{"operation":"import","source":"synthetic.fasta","fasta":">A\nAG\n>B\nG"},
"expected_revision":0,"expected_project_context":context
});
assert!(
session
.call(&mut app, "plotium_plan", request.clone())
.is_err()
);
session.set_allow_modify(true);
let plan = session
.call(&mut app, "plotium_plan", request.clone())
.unwrap();
session
.call(
&mut app,
"plotium_execute",
json!({"plan_id":plan["plan_id"]}),
)
.unwrap();
assert!(session.call(&mut app, "plotium_plan", request).is_err());
assert_eq!(app.doc.protein.molecules.len(), 1);
let id = app.doc.protein.molecules[0].id;
let residue = app.doc.protein.molecules[0].chains[0].residues[0].id;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../io/tests/fixtures/nmr/bruker-3d-qf");
let (dataset, _) = plotium_core::workflow::dataset_from_acquisition(
plotium_io::load_path(fixture).unwrap().acquisition,
)
.unwrap();
let d = dataset.resource_id();
app.doc.datasets.push(dataset);
let v = plotium_core::protein::volume(&app.doc, d).unwrap();
let ppm = std::array::from_fn(|axis| v.processed.axis_coordinate(axis, 1).unwrap());
let peak = app.add_peak_3d(d, ppm).unwrap();
let edit = |session: &mut Session, app: &mut PlotiumApp, tool: &str, parameters: Value| {
let request = json!({"tool_id":tool,"parameters":parameters,
"expected_revision":app.doc.automation_revision,"expected_project_context":context});
let plan = session.call(app, "plotium_plan", request).unwrap();
session
.call(app, "plotium_execute", json!({"plan_id":plan["plan_id"]}))
.unwrap()
};
edit(
&mut session,
&mut app,
"protein.edit",
json!({"operation":"bind","dataset":d,"molecule":id}),
);
let added = edit(
&mut session,
&mut app,
"protein.assignments.edit",
json!({"dataset":d,"peak":peak,"edit":{"operation":"add","targets":[{"residue":residue,"members":["H"]},null,null]}}),
);
let candidate = &added["value"]["assignment"]["candidates"][0]["id"];
edit(
&mut session,
&mut app,
"protein.assignments.edit",
json!({"dataset":d,"peak":peak,"edit":{"operation":"confirm","candidate":candidate}}),
);
assert_eq!(
json!(app.doc.protein.assignment(d, peak).unwrap().confirmed),
*candidate
);
session.set_allow_modify(false);
for (name, parameters) in [
("plotium_protein_inspect", json!({"molecule":id})),
("plotium_protein_assignments_export", json!({"molecule":id})),
] {
let query = json!({"parameters":parameters,"expected_revision":app.doc.automation_revision,"expected_project_context":context});
let result = session.call(&mut app, name, query).unwrap();
if name.ends_with("export") {
assert_eq!(result["value"], app.export_protein(id, false).unwrap());
} else {
assert_eq!(result["value"]["molecules"][0]["id"], json!(id));
}
}
}

#[test]
fn mcp_protein_inspection_can_page_a_sequence_larger_than_the_response_budget() {
use plotium_core::protein::Edit;
let mut app = PlotiumApp::new();
app.edit_protein(Edit::Import {
source: "large.fasta".into(),
fasta: format!(">large\n{}G", "A".repeat(6_000)),
name: None,
chains: None,
})
.unwrap();
let m = &app.doc.protein.molecules[0];
assert!(
serde_json::to_vec(m).unwrap().len() > plotium_core::automation::DOCUMENT_RESPONSE_BYTES
);
let molecule = m.id;
let chain = m.chains[0].id;
let last = m.chains[0].residues.last().unwrap().id;
let mut session = Session::default();
let mut read = |parameters: Value| {
let query = json!({"parameters":parameters,"expected_revision":app.doc.automation_revision,
"expected_project_context":app.session.project_context});
session
.call(&mut app, "plotium_protein_inspect", query)
.unwrap()["value"]
.clone()
};
let summary = read(json!({}));
assert_eq!(summary["molecules"][0]["id"], json!(molecule));
assert_eq!(summary["molecules"][0]["chain_count"], 1);
assert!(summary["molecules"][0].get("chains").is_none());
let chains = read(json!({"view":"chains","molecule":molecule}));
assert_eq!(chains["chains"][0]["id"], json!(chain));
let first = read(json!({"view":"residues","molecule":molecule,"chain":chain,"limit":1}));
assert_eq!(first["next_offset"], 1);
assert_eq!(first["residues"][0]["number"], 1);
let last_page =
read(json!({"view":"residues","molecule":molecule,"chain":chain,"offset":6000,"limit":1}));
assert_eq!(
last_page["residues"][0],
json!({"id":last,"code":"G","number":6001})
);
assert!(last_page["next_offset"].is_null());
}
21 changes: 21 additions & 0 deletions crates/agent/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,27 @@ pub(crate) fn definitions() -> Vec<Tool> {
schema.as_object().expect("local scientific schema").clone(),
));
}
for (name, id) in [
("plotium_protein_inspect", "protein.inspect"),
(
"plotium_protein_assignments_export",
"protein.assignments.export",
),
] {
let descriptor = registry.get(id).expect("registered protein operation");
tools.push(Tool::new(
name,
descriptor.description.clone(),
json!({
"type":"object","additionalProperties":false,
"required":["expected_revision","parameters"],
"properties":{"expected_revision":revision,"parameters":descriptor.parameter_schema}
})
.as_object()
.expect("local schema")
.clone(),
));
}
for name in [
"plotium_tasks_get",
"plotium_tasks_list",
Expand Down
2 changes: 2 additions & 0 deletions crates/agent/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,8 @@ impl Session {
name,
"plotium_diffusion_inspect"
| "plotium_processing_inspect"
| "plotium_protein_inspect"
| "plotium_protein_assignments_export"
| "plotium_peaks3d_inspect"
| "plotium_analysis_results_inspect"
) {
Expand Down
50 changes: 49 additions & 1 deletion crates/agent/src/session/science.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ pub(super) fn is_scientific_edit(tool: &str) -> bool {
PROCESSING_EDIT
| "nmr.volume.edit"
| "nmr.peaks3d"
| "protein.edit"
| "protein.assignments.edit"
| DIFFUSION_SET_INPUT
| DOSY_BUILD
| REGIONS_EXTRACT
Expand Down Expand Up @@ -79,7 +81,15 @@ impl Session {
"Execute a pending plan before preparing another.",
));
}
let targets = selector(args.ids, args.targets)?;
let targets =
if args.tool_id.starts_with("protein.") && args.ids.is_none() && args.targets.is_none()
{
TargetSelector::Explicit {
ids: vec![document_root_target().resource.id],
}
} else {
selector(args.ids, args.targets)?
};
if !matches!(&targets,TargetSelector::Explicit {ids} if !ids.is_empty()) {
return Err(Failure::new(
"invalid_parameters",
Expand Down Expand Up @@ -115,6 +125,44 @@ impl Session {
name: &str,
args: Value,
) -> Result<Value, Failure> {
if matches!(
name,
"plotium_protein_inspect" | "plotium_protein_assignments_export"
) {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ProteinRead {
expected_revision: DocumentRevision,
parameters: Value,
}
let query: ProteinRead = decode(args)?;
let id = if name == "plotium_protein_inspect" {
"protein.inspect"
} else {
"protein.assignments.export"
};
let plan = plan_tool(
app,
request(
id,
query.parameters,
vec![document_root_target().resource.id],
query.expected_revision,
),
)?;
let result = json!(execute_tool(app, plan, ExecutionAuthority::Read)?);
if serde_json::to_vec(&result)
.map_err(|e| Failure::new("execution_failed", e.to_string()))?
.len()
> DOCUMENT_RESPONSE_BYTES
{
return Err(Failure::new(
"response_too_large",
"Protein report exceeds the response budget; use CLI export or paginate inspection.",
));
}
return Ok(result);
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Query {
Expand Down
2 changes: 2 additions & 0 deletions crates/agent/src/tool_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ impl ToolEffect {
| "plotium_analysis_results_inspect"
| "plotium_diffusion_inspect"
| "plotium_processing_inspect"
| "plotium_protein_inspect"
| "plotium_protein_assignments_export"
| "plotium_peaks3d_inspect"
| "plotium_tasks_get"
| "plotium_tasks_list"
Expand Down
2 changes: 2 additions & 0 deletions crates/agent/src/tool_effects_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ fn every_advertised_tool_has_explicit_conservative_annotations() {
"plotium_analysis_results_inspect",
"plotium_diffusion_inspect",
"plotium_processing_inspect",
"plotium_protein_inspect",
"plotium_protein_assignments_export",
"plotium_peaks3d_inspect",
"plotium_tasks_get",
"plotium_tasks_list",
Expand Down
2 changes: 2 additions & 0 deletions crates/app/src/ui/command_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ fn execute_inner(
| CommandId::Peak3DLocate
| CommandId::Peak3DPrevious
| CommandId::Peak3DNext => super::peaks3d::command(app, ctx, id),
CommandId::ImportProtein => super::tools::protein::import(app, ctx),
CommandId::ProteinAssignments => super::tools::protein::open(app),
CommandId::ApplyNmrVolumePhase => super::nmr_volume::apply_phase(app, ctx),
CommandId::PreviousNmrPlane | CommandId::NextNmrPlane => {
super::nmr_volume::step(app, id == CommandId::NextNmrPlane);
Expand Down
Loading
Loading