Files
solidcraft-upstream/crates/ui-egui/src/agent_chat/gate.rs
T

334 lines
16 KiB
Rust

//! What the chat's agent may do to the app. Every request its tools send (whatever the backend)
//! passes here before it runs: a short list of methods (run commands, read the design, move the
//! camera, render), with commands sorted into those that run at once, those that wait for the
//! user's Allow or Deny in the panel (anything that reads or writes files or replaces the design),
//! and those that never run for the agent (crash reports, which hold other designs and paths).
//! Plain data and rules, tested without a window.
use serde_json::Value;
/// Changes the agent may make in one turn (requests that run commands; reads don't count). The
/// user can continue with another message.
pub const DEFAULT_TURN_LIMIT: u32 = 24;
/// Requests of any kind in one turn, so an agent stuck reading the design in a loop stops too.
pub const MAX_REQUESTS_PER_TURN: u32 = 400;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
/// Runs now. `change`: it runs a command that may change the design (counted per turn).
Run { change: bool },
/// Waits for Allow or Deny; the text says what it would do.
Ask(String),
/// Never for the agent; the reason goes back to it.
Refuse(String),
}
/// Methods the bridged MCP server uses; nothing else (clicks, preferences, quitting, the chat
/// itself) is the agent's.
const METHODS: &[&str] = &["engine.commands", "document.inspect", "engine.execute", "engine.batch", "ui.view", "ui.render", "ui.screenshot"];
/// Parameter names that hold a file or folder and nothing else.
const FILE_KEYS: &[&str] = &["file", "files", "dir", "folder", "out", "output", "filename", "target_path"];
/// Parameter names that are a file in some commands and geometry in others (`path`: a sweep's or
/// pattern's curve ids, an occurrence's ids; `source`: a feature or body). They name a file only
/// when the value reads like one, or the command is by its nature about files.
const AMBIGUOUS_KEYS: &[&str] = &["path", "paths", "source"];
/// File extensions that make a bare word a file name.
const FILE_EXTENSIONS: &[&str] = &[
"solidcraft",
"json",
"csv",
"step",
"stp",
"iges",
"igs",
"stl",
"3mf",
"obj",
"dxf",
"svg",
"png",
"jpg",
"jpeg",
"urdf",
"txt",
"py",
"toml",
];
/// Commands that only read (not counted as changes).
fn reads_only(id: &str) -> bool {
id.starts_with("inspect.")
|| id.starts_with("model.")
|| id.ends_with(".inspect")
|| id.ends_with(".list")
|| matches!(id, "edit.checkpoint" | "render.status" | "render.backends" | "render.settings.get" | "file.slicers")
}
/// Commands that read or write a file by their nature: any string they are given in a path
/// parameter is a file, whatever it looks like.
fn is_file_verb(id: &str) -> bool {
id.starts_with("library.") || ["export", "import", "save", "open", "insert", "load", "write", "recover"].iter().any(|v| id.contains(v))
}
/// Whether `s` reads like a file or folder name: a path separator, `~`, a leading dot, or a known
/// extension. Curve ids (`c1`), edge names (`edge-3`) and the like do not.
fn looks_like_file(s: &str) -> bool {
if s.is_empty() || s.len() > 4096 {
return false;
}
if s.contains('/') || s.contains('\\') || s.starts_with('~') || s.starts_with('.') {
return true;
}
s.rsplit_once('.').is_some_and(|(stem, ext)| !stem.is_empty() && FILE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
}
/// The first file or folder a request's parameters name, for the question and the decision.
/// `id`: the command (empty for methods that are not commands).
fn names_a_file(id: &str, params: &Value) -> Option<String> {
let o = params.as_object()?;
let shown = |s: &str| s.chars().take(200).collect::<String>();
let by_nature = is_file_verb(id);
let first = |v: &Value, any: bool| -> Option<String> {
match v {
Value::String(s) if any && !s.is_empty() => Some(shown(s)),
Value::String(s) if looks_like_file(s) => Some(shown(s)),
Value::Array(a) => {
let names: Vec<&str> = a.iter().filter_map(Value::as_str).filter(|s| (any && !s.is_empty()) || looks_like_file(s)).take(3).collect();
(!names.is_empty()).then(|| names.join(", "))
}
_ => None,
}
};
FILE_KEYS
.iter()
.find_map(|k| o.get(*k).and_then(|v| first(v, true)))
.or_else(|| AMBIGUOUS_KEYS.iter().find_map(|k| o.get(*k).and_then(|v| first(v, by_nature))))
}
/// One command as the agent would run it. `undo_left`: steps this turn may still undo without
/// reaching changes made before it.
pub fn command(id: &str, params: &Value, undo_left: usize) -> Verdict {
let id = id.trim();
if id.starts_with("diagnostics.") || id.contains("crash") {
return Verdict::Refuse(format!("`{id}` is not available in the agent chat: crash reports stay with the user"));
}
if id.starts_with("app.") || id.starts_with("prefs.") {
return Verdict::Refuse(format!("`{id}` is not available in the agent chat"));
}
// Files, and commands that replace or reload the whole design.
let file_command = id.starts_with("file.") || id.starts_with("doc.") || matches!(id, "render.export" | "render.start");
if let Some(p) = names_a_file(id, params) {
return Verdict::Ask(format!("{id}: {p}"));
}
if file_command && !reads_only(id) {
return Verdict::Ask(id.to_string());
}
// Undo past this turn's first change takes back the user's own work.
if id == "edit.undo" && undo_left == 0 {
return Verdict::Ask(format!("{id} (a step from before this message)"));
}
Verdict::Run { change: !reads_only(id) }
}
/// One request from the agent's tools: `method` and its `params` as the control channel has them.
pub fn request(method: &str, params: &Value, undo_left: usize) -> Verdict {
if !METHODS.contains(&method) {
return Verdict::Refuse(format!("`{method}` is not available in the agent chat"));
}
match method {
"engine.execute" => {
let id = params.get("command").and_then(Value::as_str).unwrap_or("");
command(id, params.get("params").unwrap_or(&Value::Null), undo_left)
}
"engine.batch" => {
let Some(steps) = params.get("commands").and_then(Value::as_array) else { return Verdict::Run { change: false } };
let mut asks = Vec::new();
let mut change = false;
// A batch can undo as many steps as it has: count down as it goes.
let mut undo_left = undo_left;
for s in steps.iter().take(10_000) {
let id = s.get("command").or_else(|| s.get("id")).and_then(Value::as_str).unwrap_or("");
match command(id, s.get("params").unwrap_or(&Value::Null), undo_left) {
Verdict::Refuse(e) => return Verdict::Refuse(e),
Verdict::Ask(a) => asks.push(a),
Verdict::Run { change: c } => {
change |= c;
if id == "edit.undo" {
undo_left = undo_left.saturating_sub(1);
} else if c {
undo_left += 1;
}
}
}
}
if asks.is_empty() {
Verdict::Run { change }
} else {
Verdict::Ask(format!("batch: {}", asks.into_iter().take(6).collect::<Vec<_>>().join("; ")))
}
}
// A picture saved to a file names it.
"ui.screenshot" | "ui.render" => match names_a_file("ui.render.export", params) {
Some(_) if is_scratch(params) => Verdict::Run { change: false },
Some(p) => Verdict::Ask(format!("{method}: {p}")),
None => Verdict::Run { change: false },
},
_ => Verdict::Run { change: false },
}
}
/// What "Always allow" remembers for a request: the command it runs (`batch` for a batch).
pub fn tool_key(method: &str, params: &Value) -> String {
match method {
"engine.execute" => params.get("command").and_then(Value::as_str).unwrap_or("").chars().take(120).collect(),
"engine.batch" => "batch".into(),
m => m.into(),
}
}
/// A request as an approval card says it: the command, or a batch's commands.
pub fn describe(method: &str, params: &Value) -> String {
match method {
"engine.batch" => {
let steps = params.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
let ids: Vec<&str> = steps.iter().filter_map(|s| s.get("command").or_else(|| s.get("id")).and_then(Value::as_str)).take(6).collect();
let more = if steps.len() > ids.len() { format!(" (+{})", steps.len() - ids.len()) } else { String::new() };
format!("batch: {}{more}", ids.join(", "))
}
_ => tool_key(method, params),
}
}
/// The MCP server saves the pictures it hands back in its own scratch folder, inside its
/// `allow_paths`; those need no question.
fn is_scratch(params: &Value) -> bool {
let Some(path) = params.get("path").and_then(Value::as_str) else { return false };
let path = std::path::Path::new(path);
if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return false;
}
let ok = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("png"));
ok && params.get("allow_paths").and_then(Value::as_array).is_some_and(|roots| {
roots
.iter()
.filter_map(Value::as_str)
.map(std::path::Path::new)
.any(|r| r.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("solidcraft-mcp-")) && path.starts_with(r))
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn exec(id: &str, params: Value) -> Verdict {
request("engine.execute", &json!({"command": id, "params": params}), 5)
}
#[test]
fn modelling_runs_and_counts_reads_dont() {
assert_eq!(exec("solid.box", json!({"x": 10})), Verdict::Run { change: true });
assert_eq!(exec("sketch.inspect", json!({"sketch": 1})), Verdict::Run { change: false });
assert_eq!(exec("inspect.measure", json!({})), Verdict::Run { change: false });
assert_eq!(request("document.inspect", &json!({}), 0), Verdict::Run { change: false });
}
#[test]
fn files_and_replacing_the_design_ask_first() {
assert!(matches!(exec("file.save", json!({"path": "/tmp/a.solidcraft"})), Verdict::Ask(a) if a.contains("/tmp/a.solidcraft")));
assert!(matches!(exec("file.new", json!({})), Verdict::Ask(_)));
assert!(matches!(exec("doc.open", json!({"path": "x.step"})), Verdict::Ask(_)));
assert!(matches!(exec("file.export", json!({"path": "x.stl"})), Verdict::Ask(_)));
// Any command given a file, whatever its area.
assert!(matches!(exec("sketch.import", json!({"file": "a.dxf"})), Verdict::Ask(_)));
let batch = json!({"commands": [{"command": "solid.box", "params": {}}, {"command": "file.save", "params": {"path": "/etc/x"}}]});
assert!(matches!(request("engine.batch", &batch, 0), Verdict::Ask(a) if a.contains("/etc/x")));
}
/// `path`, `source` and friends are also geometry (curve ids, edges, occurrence ids): those
/// are not files and must not stop a sweep or a pattern with a question.
#[test]
fn geometry_named_path_is_not_a_file() {
assert_eq!(exec("solid.sweep", json!({"profile": "S1", "path_sketch": "S2", "path": ["c1", "c2"]})), Verdict::Run { change: true });
assert_eq!(
exec("solid.pattern.path", json!({"features": ["F1"], "path_sketch": "S2", "path": ["c1"], "count": 4})),
Verdict::Run { change: true }
);
assert_eq!(exec("construct.plane.along_path", json!({"path": "edge-3", "t": 0.5})), Verdict::Run { change: true });
assert_eq!(exec("component.move", json!({"component": 2, "path": [4, 7], "translate": [1, 0, 0]})), Verdict::Run { change: true });
assert_eq!(exec("occurrence.move", json!({"occurrence": 3, "path": [1, 2]})), Verdict::Run { change: true });
let batch = json!({"commands": [
{"command": "solid.box", "params": {}},
{"command": "solid.sweep", "params": {"path": ["c1", "c2"], "path_sketch": "S"}},
]});
assert_eq!(request("engine.batch", &batch, 0), Verdict::Run { change: true });
}
/// What does name a file still asks, with or without a separator or an extension, and in
/// the commands that export, import or save by their nature.
#[test]
fn real_file_names_still_ask() {
for (id, p) in [
("sketch.export_dxf", json!({"path": "/tmp/s.dxf"})),
("sketch.export_dxf", json!({"path": "s"})),
("parameters.export", json!({"path": "params"})),
("parameters.import", json!({"path": "p.csv"})),
("motion.export", json!({"study": "a", "path": "out.csv"})),
("canvas.insert", json!({"file": "pic"})),
("library.apply", json!({"path": "lib"})),
("solid.box", json!({"filename": "x"})),
("solid.box", json!({"path": "../x.step"})),
("solid.box", json!({"paths": ["a/b.step"]})),
("solid.box", json!({"dir": "out"})),
] {
assert!(matches!(exec(id, p.clone()), Verdict::Ask(_)), "{id} {p}");
}
}
/// Every registered `file.*` and `doc.*` command asks, so a new one cannot slip through by
/// name (the read-only ones, `reads_only`, are the listed exceptions).
#[test]
fn every_registered_file_and_doc_command_asks() {
let mut n = 0;
for spec in solidcraft_engine::command_specs() {
if (spec.id.starts_with("file.") || spec.id.starts_with("doc.")) && !reads_only(spec.id) {
n += 1;
assert!(matches!(exec(spec.id, json!({})), Verdict::Ask(_)), "{}", spec.id);
}
}
assert!(n >= 8, "found {n}");
}
#[test]
fn crash_reports_and_app_methods_are_never_the_agents() {
assert!(matches!(exec("diagnostics.crash_reports", json!({})), Verdict::Refuse(_)));
assert!(matches!(exec("diagnostics.crash_report", json!({"id": "x"})), Verdict::Refuse(_)));
let batch = json!({"commands": [{"command": "solid.box"}, {"command": "diagnostics.crash_reports"}]});
assert!(matches!(request("engine.batch", &batch, 0), Verdict::Refuse(_)));
for m in ["app.quit", "ui.click", "ui.prefs", "ui.agent_chat", "engine.script", "ui.key", "ui.file"] {
assert!(matches!(request(m, &json!({}), 0), Verdict::Refuse(_)), "{m}");
}
}
#[test]
fn undo_asks_only_past_the_turns_own_changes() {
assert_eq!(request("engine.execute", &json!({"command": "edit.undo"}), 1), Verdict::Run { change: true });
assert!(matches!(request("engine.execute", &json!({"command": "edit.undo"}), 0), Verdict::Ask(_)));
let batch = json!({"commands": [{"command": "solid.box"}, {"command": "edit.undo"}, {"command": "edit.undo"}]});
assert!(matches!(request("engine.batch", &batch, 0), Verdict::Ask(_)));
}
#[test]
fn the_mcp_servers_own_pictures_need_no_question() {
let scratch = json!({"path": "/tmp/solidcraft-mcp-41-a3/shot-1.png", "allow_paths": ["/tmp/solidcraft-mcp-41-a3"]});
assert_eq!(request("ui.screenshot", &scratch, 0), Verdict::Run { change: false });
let elsewhere = json!({"path": "/home/u/Desktop/shot.png", "allow_paths": ["/tmp/solidcraft-mcp-41-a3", "/home/u/Desktop"]});
assert!(matches!(request("ui.screenshot", &elsewhere, 0), Verdict::Ask(_)));
assert_eq!(request("ui.render", &json!({"width": 400}), 0), Verdict::Run { change: false });
}
}