mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
MCP: wait out long requests, honest batch outcomes, batch tool aliases, small inspect_design
- Long requests: the bridged backend no longer passes on the app's timeout text (which pointed at control.pending, unreachable over MCP). On a "still running" reply it keeps the connection and polls control.pending for up to 240 s (progress on stderr) and returns the real result; past that, an error starting "still running:" names the request and points at the new read-only `pending` tool. Later calls wait for it first (60 s) and are not sent behind it; a result waited out by a later call is kept for `pending`; a lost connection is reported as such. A busy reply waits and resends once. The engine.batch read timeout now outlasts the app's. - Timeout/busy wording: shared solidcraft_engine::control_pending tells read-only requests (ui.inspect, document.inspect, inspection commands) from changes; the app's timeout text no longer says a read "will still apply", and the busy text says the refused request was not run. - batch: rolled_back is what happened (checkpoint restored), not the input flag; replies add applied (steps whose changes are in the design) and not_run; a step still running is waited for before the rollback, and if it still runs it is marked still_running and counted as applied. - batch steps may name tools: list_commands (engine.commands, filtered), set_parameter (parameters.change, value or expression), execute, measure, inspect_design, undo, redo, export, save, open, new_design, crash_reports; screenshot/body_topology/batch/pending fail the batch before anything is sent, naming what to use. - inspect_design: sketch summaries by default (plane and counts of points, curves, constraints, profiles; document.inspect now reports them), full contents only for named sketches (or sketches: true); bodies filter; unknown names list the ones there are. Tool results are compact JSON. - Unknown tool arguments are refused with the tool's arguments listed; the screenshot, inspect_design and body_topology descriptions list theirs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 7fcfc3bd53b3322341d84d9aefaecb3877cd3234)
This commit is contained in:
@@ -10,6 +10,7 @@ use std::time::Duration;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
use solidcraft_engine::control_auth;
|
||||
use solidcraft_engine::control_pending;
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Connections served at once, so a flood must not be able to spawn threads without end.
|
||||
@@ -175,9 +176,34 @@ const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::fro
|
||||
struct Pending {
|
||||
id: Value,
|
||||
method: String,
|
||||
/// The request in messages ("`engine.execute` (solid.fillet)").
|
||||
what: String,
|
||||
reply: Receiver<Value>,
|
||||
}
|
||||
|
||||
/// The reply text for a request that outlasts its wait. A request that only reads changes
|
||||
/// nothing when it finishes; any other still applies, as it was not cancelled.
|
||||
fn timeout_message(what: &str, reads_only: bool) -> String {
|
||||
if reads_only {
|
||||
format!("timeout: {what} is still running in the app (it only reads: nothing changes); ask `control.pending` for its answer")
|
||||
} else {
|
||||
format!(
|
||||
"timeout: {what} is still running in the app and will still apply when it finishes (it was not cancelled: don't send it \
|
||||
again); ask `control.pending` for its result"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply text for a request refused because an earlier one on the connection still runs.
|
||||
fn busy_message(p: &Pending, method: &str) -> String {
|
||||
let method: String = method.chars().take(128).collect();
|
||||
format!(
|
||||
"busy: request {} ({}) is still running, so `{method}` was not run; ask `control.pending` for that request's result, then \
|
||||
send this one again",
|
||||
p.id, p.what
|
||||
)
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS);
|
||||
}
|
||||
@@ -234,7 +260,7 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
}
|
||||
} else if let Some(p) = pending.as_ref().filter(|_| late.is_none()) {
|
||||
// Nothing more is queued behind it, to run unseen when it ends.
|
||||
json!({"ok": false, "busy": true, "error": format!("busy: request {} (`{}`) is still running; ask `control.pending` for its result", p.id, p.method)})
|
||||
json!({"ok": false, "busy": true, "error": busy_message(p, &method)})
|
||||
} else {
|
||||
if late.is_some() {
|
||||
// Finished, and the client moved on without asking: it is dropped.
|
||||
@@ -242,6 +268,8 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
}
|
||||
// A batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
|
||||
let what = control_pending::describe(&method, ¶ms);
|
||||
let reads = control_pending::reads_only(&method, ¶ms);
|
||||
let (req, rrx) = ControlRequest::new(method.clone(), params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
@@ -250,8 +278,8 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
match rrx.recv_timeout(wait) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")});
|
||||
pending = Some(Pending { id: id.clone(), method, reply: rrx });
|
||||
let r = json!({"ok": false, "running": true, "error": timeout_message(&what, reads)});
|
||||
pending = Some(Pending { id: id.clone(), method, what, reply: rrx });
|
||||
r
|
||||
}
|
||||
}
|
||||
@@ -349,6 +377,18 @@ mod tests {
|
||||
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
|
||||
}
|
||||
|
||||
/// A timed-out request that only reads is not said to "still apply"; a change is, by name.
|
||||
#[test]
|
||||
fn timeout_text_tells_reads_from_changes() {
|
||||
let says = |method: &str, p: Value| timeout_message(&control_pending::describe(method, &p), control_pending::reads_only(method, &p));
|
||||
let read = says("ui.inspect", json!({}));
|
||||
assert!(read.starts_with("timeout") && read.contains("only reads") && !read.contains("will still apply"), "{read}");
|
||||
let read = says("engine.execute", json!({"command": "sketch.inspect"}));
|
||||
assert!(read.contains("only reads") && read.contains("sketch.inspect"), "{read}");
|
||||
let change = says("engine.execute", json!({"command": "solid.fillet"}));
|
||||
assert!(change.starts_with("timeout") && change.contains("will still apply") && change.contains("solid.fillet"), "{change}");
|
||||
}
|
||||
|
||||
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
|
||||
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
|
||||
/// need not be repeated on that connection.
|
||||
|
||||
@@ -256,7 +256,11 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sketches: Vec<Value> = st
|
||||
.sketches
|
||||
.iter()
|
||||
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)}))
|
||||
.map(|ss| {
|
||||
json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "plane": ss.plane,
|
||||
"points": ss.sketch.points.len(), "curves": ss.sketch.curves.len(), "constraints": ss.sketch.constraints.len(),
|
||||
"profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "metadata": s.doc.metadata, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md,
|
||||
//! #1618): the app's control server and the bridged MCP server both say whether the request
|
||||
//! still changes something when it finishes, or only reads.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Control methods that only read: a timed-out one changes nothing when it finishes.
|
||||
const READ_METHODS: &[&str] = &[
|
||||
"control.pending",
|
||||
"document.inspect",
|
||||
"engine.commands",
|
||||
"ui.at",
|
||||
"ui.handles",
|
||||
"ui.inspect",
|
||||
"ui.render",
|
||||
"ui.screenshot",
|
||||
"ui.selection",
|
||||
"ui.sketchToScreen",
|
||||
"ui.triad",
|
||||
"ui.worldToScreen",
|
||||
];
|
||||
|
||||
/// Commands that only read the design (inspection, lists, measurements).
|
||||
pub fn command_reads_only(id: &str) -> bool {
|
||||
let id = crate::find_command(id).map_or(id, |c| c.id);
|
||||
id.starts_with("inspect.")
|
||||
|| id.starts_with("model.")
|
||||
|| id.ends_with(".inspect")
|
||||
|| id.ends_with(".list")
|
||||
|| matches!(id, "document.inspect" | "engine.commands" | "render.status" | "render.backends" | "render.settings.get" | "file.slicers")
|
||||
}
|
||||
|
||||
/// Whether the request `method` with `params` only reads (the design, the window or the
|
||||
/// command list): renders and screenshots count, as they change nothing in the design.
|
||||
pub fn reads_only(method: &str, params: &Value) -> bool {
|
||||
match method {
|
||||
"engine.execute" | "command" => params.get("command").or_else(|| params.get("id")).and_then(Value::as_str).is_some_and(command_reads_only),
|
||||
m => READ_METHODS.contains(&m),
|
||||
}
|
||||
}
|
||||
|
||||
/// The request in a message: "`engine.execute` (solid.fillet)", or just "`ui.inspect`".
|
||||
pub fn describe(method: &str, params: &Value) -> String {
|
||||
let command = matches!(method, "engine.execute" | "command")
|
||||
.then(|| params.get("command").or_else(|| params.get("id")).and_then(Value::as_str))
|
||||
.flatten()
|
||||
.filter(|c| c.len() <= 128);
|
||||
match command {
|
||||
Some(c) => format!("`{method}` ({c})"),
|
||||
None => format!("`{}`", method.chars().take(128).collect::<String>()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn reads_are_told_from_changes() {
|
||||
for (m, p) in [
|
||||
("ui.inspect", json!({})),
|
||||
("document.inspect", json!({"measure": true})),
|
||||
("engine.execute", json!({"command": "sketch.inspect"})),
|
||||
("engine.execute", json!({"command": "inspect.measure"})),
|
||||
("engine.execute", json!({"command": "model.edges", "params": {"body": "Body1"}})),
|
||||
] {
|
||||
assert!(reads_only(m, &p), "{m} {p}");
|
||||
}
|
||||
for (m, p) in [
|
||||
("engine.execute", json!({"command": "solid.fillet"})),
|
||||
("engine.execute", json!({})),
|
||||
("engine.batch", json!({"commands": []})),
|
||||
("ui.click", json!({"x": 1, "y": 2})),
|
||||
("app.quit", json!({})),
|
||||
] {
|
||||
assert!(!reads_only(m, &p), "{m} {p}");
|
||||
}
|
||||
assert_eq!(describe("engine.execute", &json!({"command": "solid.fillet"})), "`engine.execute` (solid.fillet)");
|
||||
assert_eq!(describe("ui.inspect", &json!({})), "`ui.inspect`");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
pub mod capture;
|
||||
pub mod cmd;
|
||||
pub mod control_auth;
|
||||
pub mod control_pending;
|
||||
pub mod crash;
|
||||
pub mod frames;
|
||||
pub mod fuzz;
|
||||
|
||||
+251
-16
@@ -2,15 +2,18 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::control_pending;
|
||||
use solidcraft_engine::render::StandardView;
|
||||
use solidcraft_engine::view::Guides;
|
||||
|
||||
/// Something that answers control-channel methods: `engine.execute`, `engine.commands`,
|
||||
/// `document.inspect`, `ui.view`, `ui.render`, `ui.screenshot`.
|
||||
/// `document.inspect`, `ui.view`, `ui.render`, `ui.screenshot`, and `control.pending` (the
|
||||
/// result of a request that was still running when its call returned: `{pending: what | null,
|
||||
/// done?, reply?, lost?, running_s?}`).
|
||||
pub trait Backend {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
||||
/// True when a real window is attached (`ui.screenshot` can capture it).
|
||||
@@ -34,8 +37,78 @@ const MAX_REPLY: u64 = 64 << 20;
|
||||
/// How long a reply may take: kernel operations (booleans, fillets) on big parts can take a while.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How long the app may take to run an `engine.batch` (its control server waits as long).
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_secs(3600);
|
||||
/// How long the app may take to run an `engine.batch`: a little longer than its control server
|
||||
/// waits (3600 s), so its "still running" reply arrives first.
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_secs(3720);
|
||||
|
||||
/// How long a call goes on waiting, asking `control.pending`, after the app said its request is
|
||||
/// still running (the app says so after 60 s; #1618), before it says so itself.
|
||||
const PENDING_WAIT: Duration = Duration::from_secs(240);
|
||||
|
||||
/// How long a later call, or the `pending` tool, waits for a request still running from an
|
||||
/// earlier call.
|
||||
const SETTLE_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How often `control.pending` is asked while waiting.
|
||||
const POLL_EVERY: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How often a long wait is reported on stderr.
|
||||
const PROGRESS_EVERY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How a tool error starts when a request is still running in the app after the wait: the
|
||||
/// request was not cancelled and its reply waits on the connection; the `pending` tool gets it.
|
||||
/// `batch` uses it to tell "still running" from "failed".
|
||||
pub(crate) const STILL_RUNNING: &str = "still running:";
|
||||
|
||||
/// A request the app said is still running (#1618). Its reply will come on this connection,
|
||||
/// through `control.pending`, so the connection is kept for it.
|
||||
struct Outstanding {
|
||||
what: String,
|
||||
reads_only: bool,
|
||||
since: Instant,
|
||||
}
|
||||
|
||||
impl Outstanding {
|
||||
fn secs(&self) -> u64 {
|
||||
self.since.elapsed().as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a request still running when its call returned, once a later call waited it
|
||||
/// out: kept for the `pending` tool.
|
||||
enum Settled {
|
||||
Finished { what: String, reply: Value },
|
||||
Lost { what: String },
|
||||
}
|
||||
|
||||
/// The end of a wait for an outstanding request.
|
||||
enum Waited {
|
||||
/// It finished: its own reply.
|
||||
Done(Value),
|
||||
/// The app holds nothing (any more) for this connection.
|
||||
Nothing,
|
||||
/// Still running when the wait ran out.
|
||||
Running,
|
||||
}
|
||||
|
||||
/// How long to wait for a request still running (tests shorten them).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Waits {
|
||||
pub pending: Duration,
|
||||
pub settle: Duration,
|
||||
pub poll: Duration,
|
||||
}
|
||||
|
||||
const WAITS: Waits = Waits { pending: PENDING_WAIT, settle: SETTLE_WAIT, poll: POLL_EVERY };
|
||||
|
||||
/// A control reply as a call's result.
|
||||
fn outcome(v: &Value) -> Result<Value, String> {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A running SolidCraft app reached through its loopback control port
|
||||
/// (`solidcraft --control PORT`).
|
||||
@@ -47,6 +120,11 @@ pub struct Remote {
|
||||
/// `--token`, when given. Otherwise each request looks the token up again, since every
|
||||
/// launch of the app makes a new one (docs/control-protocol.md).
|
||||
explicit_token: Option<String>,
|
||||
/// A request still running in the app; its reply waits on `conn`.
|
||||
outstanding: Option<Outstanding>,
|
||||
/// An outstanding request's end, once a later call waited for it (for `pending`).
|
||||
settled: Option<Settled>,
|
||||
waits: Waits,
|
||||
}
|
||||
|
||||
/// How a tool error starts when no app listens at the bridged address. The agent reads it, and
|
||||
@@ -72,7 +150,21 @@ impl Remote {
|
||||
pub fn new(addr: &str, token: Option<&str>) -> Self {
|
||||
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
|
||||
let explicit_token = token.map(str::to_string);
|
||||
Self { addr: addr.to_string(), port, conn: None, next_id: 1, explicit_token }
|
||||
Self { addr: addr.to_string(), port, conn: None, next_id: 1, explicit_token, outstanding: None, settled: None, waits: WAITS }
|
||||
}
|
||||
|
||||
/// Shorter waits for requests still running (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_waits(&mut self, waits: Waits) {
|
||||
self.waits = waits;
|
||||
}
|
||||
|
||||
/// Close the connection. A request still running loses its reply with it.
|
||||
fn drop_conn(&mut self) {
|
||||
self.conn = None;
|
||||
if let Some(o) = self.outstanding.take() {
|
||||
self.settled = Some(Settled::Lost { what: o.what });
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect now, if the app is there (`new` does not); the error says why not.
|
||||
@@ -83,7 +175,7 @@ impl Remote {
|
||||
/// Failing to reach the app at all is `NotConnected`, so [`Remote::explain`] can tell it from
|
||||
/// a connection lost during a call.
|
||||
fn reconnect(&mut self) -> std::io::Result<()> {
|
||||
self.conn = None;
|
||||
self.drop_conn();
|
||||
let not_connected = |e: std::io::Error| std::io::Error::new(std::io::ErrorKind::NotConnected, e);
|
||||
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
|
||||
for sa in self.addr.to_socket_addrs().map_err(not_connected)? {
|
||||
@@ -125,7 +217,7 @@ impl Remote {
|
||||
// One write: a request that loses its newline still reaches the app as a request.
|
||||
let sent = writer.write_all(format!("{line}\n").as_bytes()).and_then(|()| writer.flush());
|
||||
if let Err(e) = sent {
|
||||
self.conn = None;
|
||||
self.drop_conn();
|
||||
return Err(e);
|
||||
}
|
||||
let mut reply = String::new();
|
||||
@@ -133,12 +225,12 @@ impl Remote {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
// A timed-out read leaves a late reply on the stream: start afresh next time.
|
||||
self.conn = None;
|
||||
self.drop_conn();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if n == 0 || !reply.ends_with('\n') {
|
||||
self.conn = None;
|
||||
self.drop_conn();
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"));
|
||||
}
|
||||
Ok(reply)
|
||||
@@ -162,8 +254,9 @@ impl Remote {
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
impl Remote {
|
||||
/// The request line for `method`: a fresh id, the agent's folders and the app's token.
|
||||
fn request_line(&mut self, method: &str, params: Value) -> String {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let mut params = params;
|
||||
@@ -176,7 +269,117 @@ impl Backend for Remote {
|
||||
if let Some(t) = solidcraft_engine::control_auth::client_token(self.port, self.explicit_token.as_deref()) {
|
||||
msg["token"] = json!(t);
|
||||
}
|
||||
let line = msg.to_string();
|
||||
msg.to_string()
|
||||
}
|
||||
|
||||
/// Ask the app, on this connection only, about the request it still runs for it.
|
||||
fn ask_pending(&mut self) -> Result<Value, String> {
|
||||
if self.conn.is_none() {
|
||||
self.drop_conn();
|
||||
return Err("the connection to the app was lost".into());
|
||||
}
|
||||
let line = self.request_line("control.pending", json!({}));
|
||||
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
outcome(&v).inspect_err(|_| self.drop_conn())
|
||||
}
|
||||
|
||||
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
|
||||
/// saying on stderr how long it takes. An error: the connection, and so the reply, was lost.
|
||||
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, String> {
|
||||
let start = Instant::now();
|
||||
let mut told = start;
|
||||
loop {
|
||||
let Some(o) = &self.outstanding else { return Ok(Waited::Nothing) };
|
||||
let what = o.what.clone();
|
||||
let r = self.ask_pending()?;
|
||||
if r.get("pending").is_none_or(Value::is_null) {
|
||||
self.outstanding = None;
|
||||
return Ok(Waited::Nothing);
|
||||
}
|
||||
if r.get("done").and_then(Value::as_bool) == Some(true) {
|
||||
self.outstanding = None;
|
||||
return Ok(Waited::Done(r.get("reply").cloned().unwrap_or(Value::Null)));
|
||||
}
|
||||
let waited = start.elapsed();
|
||||
if waited >= limit {
|
||||
return Ok(Waited::Running);
|
||||
}
|
||||
if told.elapsed() >= PROGRESS_EVERY {
|
||||
let secs = self.outstanding.as_ref().map_or(0, Outstanding::secs);
|
||||
eprintln!("solidcraft-mcp: {what} is still running in the app ({secs} s); waiting for it");
|
||||
told = Instant::now();
|
||||
}
|
||||
std::thread::sleep(self.waits.poll.min(limit.saturating_sub(waited)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool error for the outstanding request still running after its wait.
|
||||
fn still_running(&self, earlier: bool) -> String {
|
||||
let Some(o) = &self.outstanding else { return format!("{STILL_RUNNING} a request is still running in the app") };
|
||||
let effect = if o.reads_only { "it only reads: nothing changes" } else { "it was not cancelled and will still apply when it finishes" };
|
||||
let settle = self.waits.settle.as_secs();
|
||||
if earlier {
|
||||
format!(
|
||||
"{STILL_RUNNING} an earlier request, {}, is still running in the app ({} s; {effect}), so this call was not sent. Call the \
|
||||
`pending` tool to wait for its result, then send this call again",
|
||||
o.what,
|
||||
o.secs()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{STILL_RUNNING} {} is still running in the app after {} s ({effect}); don't send it again. Call the `pending` tool to \
|
||||
wait for its result (up to {settle} s per call); other calls wait for it first",
|
||||
o.what,
|
||||
o.secs()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Before a new request: wait for one still running from an earlier call, keeping its end for
|
||||
/// `pending`. Still running after the wait: the new request is not sent.
|
||||
fn settle_earlier(&mut self) -> Result<(), String> {
|
||||
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(()) };
|
||||
match self.wait_outstanding(self.waits.settle) {
|
||||
Ok(Waited::Done(reply)) => self.settled = Some(Settled::Finished { what, reply }),
|
||||
Ok(Waited::Nothing) => {}
|
||||
Ok(Waited::Running) => return Err(self.still_running(true)),
|
||||
// The connection was lost, and the earlier reply with it (`pending` says so): the new
|
||||
// request goes on a fresh connection.
|
||||
Err(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `control.pending` for the bridge: the end of the request still running from an earlier
|
||||
/// call, waiting for it up to the settle wait. Never sent on a fresh connection, which has
|
||||
/// nothing pending.
|
||||
fn pending_status(&mut self) -> Result<Value, String> {
|
||||
if let Some(s) = self.settled.take() {
|
||||
return Ok(match s {
|
||||
Settled::Finished { what, reply } => json!({"pending": what, "done": true, "reply": reply}),
|
||||
Settled::Lost { what } => json!({"pending": what, "lost": true}),
|
||||
});
|
||||
}
|
||||
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(json!({"pending": null})) };
|
||||
match self.wait_outstanding(self.waits.settle) {
|
||||
Ok(Waited::Done(reply)) => Ok(json!({"pending": what, "done": true, "reply": reply})),
|
||||
Ok(Waited::Nothing) => Ok(json!({"pending": null})),
|
||||
Ok(Waited::Running) => Ok(json!({"pending": what, "done": false, "running_s": self.outstanding.as_ref().map_or(0, Outstanding::secs)})),
|
||||
Err(_) => {
|
||||
self.settled = None;
|
||||
Ok(json!({"pending": what, "lost": true}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn call_once(&mut self, method: &str, params: Value, retry_busy: bool) -> Result<Value, String> {
|
||||
self.settle_earlier()?;
|
||||
let what = control_pending::describe(method, ¶ms);
|
||||
let reads_only = control_pending::reads_only(method, ¶ms);
|
||||
let again = retry_busy.then(|| params.clone());
|
||||
let sent = Instant::now();
|
||||
let line = self.request_line(method, params);
|
||||
let reply = if method == "engine.batch" {
|
||||
self.send_batch(&line)
|
||||
} else {
|
||||
@@ -189,11 +392,41 @@ impl Backend for Remote {
|
||||
}
|
||||
.map_err(|e| self.explain(&e))?;
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
|
||||
// Timed out in the app, still running (#1618): wait for its result on this connection.
|
||||
if v.get("running").and_then(Value::as_bool) == Some(true) {
|
||||
self.outstanding = Some(Outstanding { what: what.clone(), reads_only, since: sent });
|
||||
eprintln!(
|
||||
"solidcraft-mcp: {what} is still running in the app after {} s; waiting up to {} s more for it",
|
||||
sent.elapsed().as_secs(),
|
||||
self.waits.pending.as_secs()
|
||||
);
|
||||
return match self.wait_outstanding(self.waits.pending)? {
|
||||
Waited::Done(reply) => outcome(&reply),
|
||||
Waited::Nothing => {
|
||||
Err(format!("{what} timed out in the app, which kept no result for it: inspect the design before sending it again"))
|
||||
}
|
||||
Waited::Running => Err(self.still_running(false)),
|
||||
};
|
||||
}
|
||||
// Refused unrun behind a request this bridge did not know of: wait for that one, then
|
||||
// send this one once more.
|
||||
if v.get("busy").and_then(Value::as_bool) == Some(true)
|
||||
&& let Some(params) = again
|
||||
{
|
||||
self.outstanding = Some(Outstanding { what: "an earlier request".into(), reads_only: false, since: Instant::now() });
|
||||
self.settle_earlier()?;
|
||||
return self.call_once(method, params, false);
|
||||
}
|
||||
outcome(&v)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
if method == "control.pending" {
|
||||
return self.pending_status();
|
||||
}
|
||||
self.call_once(method, params, true)
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
true
|
||||
@@ -301,6 +534,8 @@ impl Backend for Headless {
|
||||
}))
|
||||
}
|
||||
"ui.screenshot" => Err("no window in headless mode; use ui.render".into()),
|
||||
// Commands run here as they are called: nothing is ever left running.
|
||||
"control.pending" => Ok(json!({"pending": null})),
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ and dimension (`sketch.dimension {entities, value}`; values may name parameters)
|
||||
(`solid.extrude {distance, operation?: new|join|cut|intersect}`, `solid.revolve`, `solid.fillet {edges: [[x,y,z] point on \
|
||||
edge], radius}`, `solid.chamfer`, patterns, mirror, primitives). Use `batch` for several steps at once, \
|
||||
`list_commands` for ids and parameter docs, `inspect_design` / `measure` to verify, `body_topology` to find edge points, \
|
||||
`set_parameter` to drive the design and `screenshot` to look at it.";
|
||||
`set_parameter` to drive the design and `screenshot` to look at it. A call whose error starts `still running:` was not \
|
||||
cancelled: don't send it again; `pending` waits for its result.";
|
||||
|
||||
pub struct Server {
|
||||
backend: Box<dyn Backend>,
|
||||
|
||||
+336
-3
@@ -167,7 +167,7 @@ fn agent_builds_box_fillet_hole() {
|
||||
assert!((8..=9).contains(&m["bodies"][0]["faces"].as_u64().unwrap()), "{m}");
|
||||
assert!(rel(m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap(), 40.0) < 1e-6, "{m}");
|
||||
|
||||
let d = tool(&mut s, "inspect_design", json!({}));
|
||||
let d = tool(&mut s, "inspect_design", json!({"sketches": true}));
|
||||
assert_eq!(d["timeline"].as_array().unwrap().len(), 5, "{d}");
|
||||
assert!(d["timeline"].as_array().unwrap().iter().all(|f| f["error"].is_null()), "{d}");
|
||||
assert_eq!(d["sketches"][0]["curves"].as_array().unwrap().len(), 4, "{d}");
|
||||
@@ -1289,7 +1289,7 @@ fn batch_reports_a_failed_engine_batch_like_before() {
|
||||
assert!(is_error);
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({"ok": false, "completed": 1, "failed": {"index": 1, "command": "a.two", "error": "boom"}, "rolled_back": true, "undone": 1, "results": [{"x": 1}]})
|
||||
json!({"ok": false, "completed": 1, "failed": {"index": 1, "command": "a.two", "error": "boom"}, "rolled_back": true, "undone": 1, "applied": [], "not_run": [], "results": [{"x": 1}]})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1452,7 +1452,7 @@ fn tool_annotations_tell_the_whole_truth() {
|
||||
for name in ["execute", "batch", "save", "export", "open", "screenshot"] {
|
||||
assert_eq!(hints(name), (false, true, true), "{name}");
|
||||
}
|
||||
for name in ["list_commands", "inspect_design", "measure", "body_topology", "crash_reports"] {
|
||||
for name in ["list_commands", "inspect_design", "measure", "body_topology", "crash_reports", "pending"] {
|
||||
assert_eq!(hints(name), (true, false, false), "{name}");
|
||||
}
|
||||
assert_eq!(hints("new_design"), (false, true, false));
|
||||
@@ -1520,3 +1520,336 @@ fn batch_reports_timings_and_profile() {
|
||||
assert_eq!(body["timings"][0]["command"], "a.one", "{body}");
|
||||
assert!(body["timings"][0].get("profile").is_none(), "{body}");
|
||||
}
|
||||
|
||||
/// A fake app on a loopback port that answers every request line with `answer(request)`. The
|
||||
/// shared state counts connections and records each request's method and command.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn scripted_app(answer: impl Fn(&Value) -> Value + Send + Sync + 'static) -> (String, std::sync::Arc<std::sync::Mutex<(usize, Vec<String>)>>) {
|
||||
use std::io::{BufRead, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new((0usize, Vec::<String>::new())));
|
||||
let log = seen.clone();
|
||||
let answer = std::sync::Arc::new(answer);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
log.lock().unwrap().0 += 1;
|
||||
let (log, answer) = (log.clone(), answer.clone());
|
||||
std::thread::spawn(move || {
|
||||
let mut out = stream.try_clone().unwrap();
|
||||
let mut r = std::io::BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
while r.read_line(&mut line).is_ok_and(|n| n > 0) {
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
let what = match req["params"]["command"].as_str() {
|
||||
Some(c) => format!("{} {c}", req["method"].as_str().unwrap_or("")),
|
||||
None => req["method"].as_str().unwrap_or("").to_string(),
|
||||
};
|
||||
log.lock().unwrap().1.push(what);
|
||||
let mut reply = answer(&req);
|
||||
reply["id"] = req["id"].clone();
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
return;
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(addr, seen)
|
||||
}
|
||||
|
||||
fn quick_waits(pending_ms: u64, settle_ms: u64) -> crate::backend::Waits {
|
||||
use std::time::Duration;
|
||||
crate::backend::Waits { pending: Duration::from_millis(pending_ms), settle: Duration::from_millis(settle_ms), poll: Duration::from_millis(5) }
|
||||
}
|
||||
|
||||
fn running() -> Value {
|
||||
json!({"ok": false, "running": true, "error": "timeout: still running in the app"})
|
||||
}
|
||||
|
||||
/// #1618 over MCP: a request the app says is still running is waited for on the same
|
||||
/// connection (asking `control.pending`), and the call returns its real result.
|
||||
#[test]
|
||||
fn a_timed_out_request_is_waited_for_and_returns_its_result() {
|
||||
use crate::backend::Backend;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let polls = AtomicUsize::new(0);
|
||||
let (addr, seen) = scripted_app(move |req| match req["method"].as_str() {
|
||||
Some("engine.execute") => running(),
|
||||
Some("control.pending") if polls.fetch_add(1, Ordering::SeqCst) < 2 => {
|
||||
json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": false}})
|
||||
}
|
||||
Some("control.pending") => {
|
||||
json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": true, "reply": {"ok": true, "result": {"filleted": true}}}})
|
||||
}
|
||||
_ => json!({"ok": true, "result": null}),
|
||||
});
|
||||
let mut remote = crate::Remote::connect(&addr).unwrap();
|
||||
remote.set_waits(quick_waits(5000, 1000));
|
||||
let r = remote.call("engine.execute", json!({"command": "solid.fillet"})).unwrap();
|
||||
assert_eq!(r, json!({"filleted": true}));
|
||||
let (connections, methods) = seen.lock().unwrap().clone();
|
||||
assert_eq!(connections, 1, "the reply waits on the connection: it is kept");
|
||||
assert_eq!(methods, ["engine.execute solid.fillet", "control.pending", "control.pending", "control.pending"]);
|
||||
// Nothing is outstanding any more.
|
||||
assert_eq!(remote.call("control.pending", json!({})).unwrap(), json!({"pending": null}));
|
||||
}
|
||||
|
||||
/// A request still running after the wait is left to the `pending` tool: the error says so
|
||||
/// (and that it will still apply), later calls are not sent behind it, and `pending` returns
|
||||
/// its result once it lands, on the same connection.
|
||||
#[test]
|
||||
fn a_request_still_running_is_left_to_the_pending_tool() {
|
||||
use crate::backend::{Backend, STILL_RUNNING};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let done = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let finished = done.clone();
|
||||
let (addr, seen) = scripted_app(move |req| match req["method"].as_str() {
|
||||
Some("engine.execute") if req["params"]["command"] != "inspect.measure" => running(),
|
||||
Some("control.pending") if finished.load(Ordering::SeqCst) => {
|
||||
json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": true, "reply": {"ok": true, "result": {"r": 1}}}})
|
||||
}
|
||||
Some("control.pending") => json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": false}}),
|
||||
_ => json!({"ok": true, "result": "ran"}),
|
||||
});
|
||||
let mut remote = crate::Remote::connect(&addr).unwrap();
|
||||
remote.set_waits(quick_waits(60, 60));
|
||||
let e = remote.call("engine.execute", json!({"command": "solid.fillet"})).unwrap_err();
|
||||
assert!(e.starts_with(STILL_RUNNING) && e.contains("solid.fillet") && e.contains("will still apply") && e.contains("`pending`"), "{e}");
|
||||
// Not sent behind it.
|
||||
let e = remote.call("document.inspect", json!({})).unwrap_err();
|
||||
assert!(e.starts_with(STILL_RUNNING) && e.contains("earlier request") && e.contains("not sent"), "{e}");
|
||||
assert!(!seen.lock().unwrap().1.iter().any(|m| m == "document.inspect"), "{:?}", seen.lock().unwrap().1);
|
||||
// `pending` while it still runs.
|
||||
let r = crate::call_tool(&mut remote, "pending", &json!({}));
|
||||
assert_ne!(r["isError"], true, "{r}");
|
||||
let body: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(body["done"], false, "{body}");
|
||||
// It lands: `pending` returns its own result.
|
||||
done.store(true, Ordering::SeqCst);
|
||||
let r = crate::call_tool(&mut remote, "pending", &json!({}));
|
||||
let body: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!((&body["done"], &body["result"]), (&json!(true), &json!({"r": 1})), "{body}");
|
||||
assert_eq!(remote.call("document.inspect", json!({})).unwrap(), "ran");
|
||||
|
||||
// Waited out by a later call instead: that call runs, and `pending` still has the result.
|
||||
done.store(false, Ordering::SeqCst);
|
||||
assert!(remote.call("engine.execute", json!({"command": "solid.chamfer"})).is_err());
|
||||
done.store(true, Ordering::SeqCst);
|
||||
assert_eq!(remote.call("engine.execute", json!({"command": "inspect.measure"})).unwrap(), "ran");
|
||||
let r = crate::call_tool(&mut remote, "pending", &json!({}));
|
||||
let body: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!((&body["done"], &body["result"]), (&json!(true), &json!({"r": 1})), "{body}");
|
||||
assert_eq!(seen.lock().unwrap().0, 1, "one connection throughout");
|
||||
|
||||
// A request that only reads is not said to apply.
|
||||
done.store(false, Ordering::SeqCst);
|
||||
let e = remote.call("engine.execute", json!({"command": "sketch.inspect"})).unwrap_err();
|
||||
assert!(e.contains("only reads") && !e.contains("will still apply"), "{e}");
|
||||
}
|
||||
|
||||
/// The headless session never leaves a request running.
|
||||
#[test]
|
||||
fn pending_is_empty_headless() {
|
||||
let mut s = server();
|
||||
assert_eq!(tool(&mut s, "pending", json!({}))["pending"], Value::Null);
|
||||
}
|
||||
|
||||
/// A backend for the per-step batch path: `fail` maps commands to their errors, and
|
||||
/// `edit.restore_checkpoint` works or not.
|
||||
struct Stub {
|
||||
calls: Vec<(String, Value)>,
|
||||
fail: Vec<(&'static str, String)>,
|
||||
restore_ok: bool,
|
||||
}
|
||||
|
||||
impl crate::backend::Backend for Stub {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.calls.push((method.to_string(), params.clone()));
|
||||
match (method, params["command"].as_str()) {
|
||||
("engine.execute", Some("edit.checkpoint")) => Ok(json!({"checkpoint": 7})),
|
||||
("engine.execute", Some("edit.restore_checkpoint")) if self.restore_ok => Ok(json!({})),
|
||||
("engine.execute", Some("edit.restore_checkpoint")) => Err("busy: request 3 is still running".into()),
|
||||
("engine.execute", Some(c)) => match self.fail.iter().find(|(f, _)| *f == c) {
|
||||
Some((_, e)) => Err(e.clone()),
|
||||
None => Ok(json!({"ran": c})),
|
||||
},
|
||||
(m, _) => Err(format!("unknown method `{m}`")),
|
||||
}
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"stub".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn stub_batch(fail: Vec<(&'static str, String)>, restore_ok: bool, rollback: bool) -> (Stub, Value) {
|
||||
let mut b = Stub { calls: Vec::new(), fail, restore_ok };
|
||||
let args = json!({"commands": [{"command": "a.one"}, {"command": "a.two"}, {"command": "a.three"}], "rollback": rollback});
|
||||
let r = crate::tools::call_tool(&mut b, "batch", &args);
|
||||
assert_eq!(r["isError"], true, "{r}");
|
||||
let body = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
(b, body)
|
||||
}
|
||||
|
||||
/// A failed batch says what really happened: `rolled_back` only when the checkpoint was restored,
|
||||
/// which steps' changes are in the design (`applied`) and which were never sent (`not_run`). A
|
||||
/// step still running in the app counts as applied unless the rollback took it back.
|
||||
#[test]
|
||||
fn a_failed_batch_reports_what_really_happened() {
|
||||
let still = format!("{} `engine.execute` (a.two) is still running in the app", crate::backend::STILL_RUNNING);
|
||||
// Still running, and the rollback could not restore the checkpoint.
|
||||
let (b, body) = stub_batch(vec![("a.two", still.clone())], false, true);
|
||||
assert_eq!((&body["rolled_back"], &body["undone"]), (&json!(false), &json!(0)), "{body}");
|
||||
assert_eq!((&body["applied"], &body["not_run"]), (&json!([0, 1]), &json!([2])), "{body}");
|
||||
assert_eq!(body["failed"]["still_running"], true, "{body}");
|
||||
assert!(!b.calls.iter().any(|(_, p)| p["command"] == "a.three"), "a step after the failure is never sent");
|
||||
// Still running, and the rollback (after the backend waited for it) restored the checkpoint.
|
||||
let (_, body) = stub_batch(vec![("a.two", still)], true, true);
|
||||
assert_eq!((&body["rolled_back"], &body["undone"]), (&json!(true), &json!(1)), "{body}");
|
||||
assert_eq!((&body["applied"], &body["not_run"]), (&json!([]), &json!([2])), "{body}");
|
||||
assert!(body["failed"].get("still_running").is_none(), "{body}");
|
||||
// An ordinary failure whose rollback failed.
|
||||
let (_, body) = stub_batch(vec![("a.two", "boom".into())], false, true);
|
||||
assert_eq!((&body["rolled_back"], &body["applied"]), (&json!(false), &json!([0])), "{body}");
|
||||
// No rollback asked for: nothing is restored, and the reply does not say it was.
|
||||
let (b, body) = stub_batch(vec![("a.two", "boom".into())], true, false);
|
||||
assert_eq!((&body["rolled_back"], &body["applied"], &body["not_run"]), (&json!(false), &json!([0]), &json!([2])), "{body}");
|
||||
assert!(!b.calls.iter().any(|(_, p)| p["command"] == "edit.restore_checkpoint"), "{:?}", b.calls);
|
||||
|
||||
// The app's own engine.batch: its `rolled_back` is believed; an older app's is inferred.
|
||||
let reply = json!({"completed": 1, "results": [{"x": 1}], "failed": {"index": 1, "command": "a.two", "error": "boom"}, "undone": 0, "rolled_back": false});
|
||||
let (_, body, _) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}, {"command": "a.three"}]}));
|
||||
assert_eq!((&body["rolled_back"], &body["applied"], &body["not_run"]), (&json!(false), &json!([0]), &json!([2])), "{body}");
|
||||
let reply = json!({"completed": 1, "results": [{"x": 1}], "failed": {"index": 1, "command": "a.two", "error": "boom"}, "undone": 0});
|
||||
let (_, body, _) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}]}));
|
||||
assert_eq!(body["rolled_back"], false, "undone 0 of 1 completed: not rolled back: {body}");
|
||||
// A whole engine.batch still running in the app is not "may have run": `pending` gets it.
|
||||
let still = format!("{} `engine.batch` is still running in the app", crate::backend::STILL_RUNNING);
|
||||
let (b, body, is_error) = recorded_batch(Err(still), json!({"commands": [{"command": "a.one"}]}));
|
||||
assert!(is_error && body.as_str().is_some_and(|t| t.starts_with(crate::backend::STILL_RUNNING) && !t.contains("may have run")), "{body}");
|
||||
assert_eq!(methods(&b), ["engine.batch"]);
|
||||
}
|
||||
|
||||
/// Batch steps may name the tools an agent knows: `list_commands` and `set_parameter` run as
|
||||
/// engine.commands (filtered as the tool filters) and parameters.change; tools that can't be
|
||||
/// steps fail the batch before anything is sent.
|
||||
#[test]
|
||||
fn batch_steps_may_name_tools() {
|
||||
let catalog = json!([{"id": "solid.box", "label": "Box", "tab": "SOLID", "panel": "CREATE"}, {"id": "sketch.line", "label": "Line", "tab": "SKETCH", "panel": "CREATE"}]);
|
||||
let reply = json!({"completed": 3, "results": [catalog, {"value": 40}, {"value": 20}], "undone": 0});
|
||||
let steps = json!([
|
||||
{"command": "list_commands", "params": {"filter": "box"}},
|
||||
{"command": "set_parameter", "params": {"name": "w", "value": 40}},
|
||||
{"command": "set_parameter", "params": {"name": "h", "expression": "w / 2", "unit": "mm"}},
|
||||
]);
|
||||
let (b, body, is_error) = recorded_batch(Ok(reply), json!({"commands": steps}));
|
||||
assert!(!is_error, "{body}");
|
||||
assert_eq!(
|
||||
b.calls[0].1["commands"],
|
||||
json!([
|
||||
{"command": "engine.commands", "params": {}},
|
||||
{"command": "parameters.change", "params": {"name": "w", "expression": 40}},
|
||||
{"command": "parameters.change", "params": {"name": "h", "expression": "w / 2", "unit": "mm"}},
|
||||
])
|
||||
);
|
||||
assert_eq!(body["results"][0], json!([{"id": "solid.box", "label": "Box", "tab": "SOLID", "panel": "CREATE"}]), "{body}");
|
||||
|
||||
let (b, body, is_error) =
|
||||
recorded_batch(Ok(json!({})), json!({"commands": [{"command": "solid.box"}, {"command": "screenshot", "params": {"view": "iso"}}]}));
|
||||
assert!(is_error, "{body}");
|
||||
assert!(b.calls.is_empty(), "nothing is sent: {:?}", b.calls);
|
||||
assert_eq!((&body["failed"]["index"], &body["not_run"], &body["applied"]), (&json!(1), &json!([0, 1]), &json!([])), "{body}");
|
||||
assert!(body["failed"]["error"].as_str().is_some_and(|e| e.contains("screenshot")), "{body}");
|
||||
let (_, body, _) = recorded_batch(Ok(json!({})), json!({"commands": [{"command": "body_topology", "params": {"body": "Body1"}}]}));
|
||||
assert!(body["failed"]["error"].as_str().is_some_and(|e| e.contains("model.edges") && e.contains("model.faces")), "{body}");
|
||||
|
||||
// Headless, step by step.
|
||||
let mut s = server();
|
||||
let r = tool(
|
||||
&mut s,
|
||||
"batch",
|
||||
json!({"commands": [
|
||||
{"command": "set_parameter", "params": {"name": "w", "value": "40 mm"}},
|
||||
{"command": "solid.box", "params": {"length": "w", "width": 10, "height": 10}},
|
||||
{"command": "list_commands", "params": {"filter": "solid.box"}},
|
||||
{"command": "execute", "params": {"command": "solid.sphere", "params": {"radius": 2, "center": [100, 0, 0]}}},
|
||||
]}),
|
||||
);
|
||||
assert_eq!(r["completed"], 4, "{r}");
|
||||
let ids: Vec<&str> = r["results"][2].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
assert!(!ids.is_empty() && ids.iter().all(|id| id.contains("solid.box")), "{ids:?}");
|
||||
assert!(volume(&mut s) > 4000.0);
|
||||
}
|
||||
|
||||
/// `inspect_design` is small by default (#15): sketch summaries, compact JSON. Full sketch
|
||||
/// contents only for the sketches asked for; `bodies` keeps only those bodies.
|
||||
#[test]
|
||||
fn inspect_design_summarises_sketches_unless_asked() {
|
||||
let mut s = server();
|
||||
let points: Vec<[f64; 2]> = (0..720)
|
||||
.map(|i| {
|
||||
let t = f64::from(i) / 720.0 * std::f64::consts::TAU;
|
||||
[30.0 * t.cos(), 30.0 * t.sin()]
|
||||
})
|
||||
.collect();
|
||||
tool(
|
||||
&mut s,
|
||||
"batch",
|
||||
json!({"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.line", "params": {"points": points, "closed": true}},
|
||||
{"command": "sketch.finish"},
|
||||
{"command": "solid.extrude", "params": {"distance": 5}},
|
||||
]}),
|
||||
);
|
||||
let text = |s: &mut Server, args: Value| {
|
||||
let r = raw_tool(s, "inspect_design", args);
|
||||
(r["result"]["isError"] == json!(true), r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string())
|
||||
};
|
||||
let (_, summary) = text(&mut s, json!({"measure": false}));
|
||||
assert!(summary.len() < 4000, "{} bytes: {summary}", summary.len());
|
||||
assert!(!summary.contains("\n "), "compact JSON");
|
||||
let d: Value = serde_json::from_str(&summary).unwrap();
|
||||
let sk = &d["sketches"][0];
|
||||
assert!(sk["points"].as_u64().is_some_and(|n| n >= 720), "{sk}");
|
||||
assert_eq!((&sk["curves"], &sk["profiles"]), (&json!(720), &json!(1)), "{sk}");
|
||||
assert!(sk["constraints"].is_u64() && sk["dof"].is_number() && sk["plane"].is_object(), "{sk}");
|
||||
assert_eq!(text(&mut s, json!({"measure": false, "sketches": false})).1, summary);
|
||||
assert_eq!(text(&mut s, json!({"measure": false, "sketches": "summary"})).1, summary);
|
||||
let name = sk["name"].as_str().unwrap().to_string();
|
||||
let (_, full) = text(&mut s, json!({"measure": false, "sketches": [name]}));
|
||||
assert!(full.len() > 50_000 && full.len() > 20 * summary.len(), "{} vs {}", full.len(), summary.len());
|
||||
let f: Value = serde_json::from_str(&full).unwrap();
|
||||
assert_eq!(f["sketches"][0]["curves"].as_array().map(Vec::len), Some(720));
|
||||
assert_eq!(text(&mut s, json!({"measure": false, "sketches": true})).1.len(), full.len());
|
||||
let (err, e) = text(&mut s, json!({"sketches": ["Nope"]}));
|
||||
assert!(err && e.contains("no sketch") && e.contains(&name), "{e}");
|
||||
|
||||
let (_, one) = text(&mut s, json!({"bodies": ["Body1"]}));
|
||||
let one: Value = serde_json::from_str(&one).unwrap();
|
||||
assert_eq!(one["bodies"].as_array().map(Vec::len), Some(1), "{one}");
|
||||
assert!(one["bodies"][0]["volume_mm3"].as_f64().is_some_and(|v| v > 0.0) && one["bodies"][0]["visible"] == true, "{one}");
|
||||
let (err, e) = text(&mut s, json!({"bodies": ["Nope"]}));
|
||||
assert!(err && e.contains("no body `Nope`") && e.contains("Body1"), "{e}");
|
||||
}
|
||||
|
||||
/// An unknown argument is refused with the tool's real arguments listed.
|
||||
#[test]
|
||||
fn unknown_arguments_list_the_real_ones() {
|
||||
let mut s = server();
|
||||
for (tool_name, want) in [
|
||||
("screenshot", &["view", "camera", "path", "width", "height", "source", "grid", "axes"][..]),
|
||||
("inspect_design", &["measure", "sketches", "bodies"][..]),
|
||||
("body_topology", &["body"][..]),
|
||||
] {
|
||||
let args = if tool_name == "body_topology" { json!({"body": "Body1", "nonsense": 3}) } else { json!({"nonsense": 3}) };
|
||||
let r = raw_tool(&mut s, tool_name, args);
|
||||
let msg = r["error"]["message"].as_str().unwrap_or_default();
|
||||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||||
assert!(msg.contains("unknown argument `nonsense`") && want.iter().all(|a| msg.contains(a)), "{tool_name}: {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
+337
-60
@@ -7,7 +7,7 @@ use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_engine::timing;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::backend::{Backend, STILL_RUNNING};
|
||||
|
||||
/// Most commands in one `batch` call.
|
||||
const MAX_BATCH: usize = 10_000;
|
||||
@@ -98,10 +98,16 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"batch",
|
||||
"Run several commands",
|
||||
"Run commands in order, stopping at the first error. Each step is {command, params}. On an error the steps \
|
||||
already run are undone (unless rollback: false), so the batch is all-or-nothing; the reply names the failing \
|
||||
step (index from 0), its error, and every result before it. The usual way to build a part: sketch, draw, \
|
||||
dimension, finish, extrude, fillet… in one call.",
|
||||
"Run commands in order, stopping at the first error. Each step is {command, params}: an engine command id \
|
||||
(as list_commands lists them). A step may also name one of these tools with that tool's arguments as params, \
|
||||
and runs the engine command it stands for: set_parameter (parameters.change; `value` or `expression`), \
|
||||
list_commands (engine.commands, with `filter`), execute ({command, params}), measure (inspect.measure), \
|
||||
inspect_design (document.inspect), undo, redo, export, save, open, new_design, crash_reports; screenshot, \
|
||||
body_topology, batch and pending can't be steps. On an error the steps already run are undone (unless \
|
||||
rollback: false), so the batch is all-or-nothing; the reply names the failing step (index from 0), its error, \
|
||||
every result before it, whether the design really went back (rolled_back), the steps whose changes are in the \
|
||||
design (applied) and those never sent (not_run). The usual way to build a part: sketch, draw, dimension, \
|
||||
finish, extrude, fillet… in one call.",
|
||||
obj(
|
||||
json!({
|
||||
"commands": {
|
||||
@@ -123,12 +129,19 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"Inspect the design",
|
||||
"The whole design: user parameters (expression, value, errors), the timeline (features with type, suppressed, \
|
||||
rolled back, error, warning, timing), bodies (volume, area, centre of mass, bounding box, face/edge/vertex \
|
||||
counts), sketches (curves with ids and coordinates, points, constraints, profiles, degrees of freedom, solve \
|
||||
status), the active sketch, selection and undo depth.",
|
||||
counts), sketches, the active sketch, selection and undo depth. Sketches are summarised by default (id, name, \
|
||||
plane, counts of points, curves, constraints and profiles, degrees of freedom, solve status); their full contents \
|
||||
(curves with ids and coordinates, points, constraints, profiles) only for the sketches named in `sketches`, or \
|
||||
all with `sketches: true` (large: a 720-point sketch is some 200 KB). Arguments: measure, sketches, bodies.",
|
||||
obj(
|
||||
json!({
|
||||
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
|
||||
"sketches": {"type": "boolean", "description": "Full sketch contents (default true; false = summary only)"},
|
||||
"sketches": {
|
||||
"type": ["boolean", "string", "array"],
|
||||
"items": {"type": ["string", "integer"]},
|
||||
"description": "Which sketches in full: a list of sketch names or ids, true (all of them), or false / \"summary\" (none: summaries only, the default)",
|
||||
},
|
||||
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Only these bodies, by name (default: all)"},
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
@@ -156,7 +169,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"List edges and faces",
|
||||
"The edges (index, midpoint, length, endpoints) and faces (index, name, area, centroid, plane normal, a `point` that lies \
|
||||
on the face, and for a cylindrical face its `cylinder`: origin, axis, radius) of a body. Use an edge midpoint as the \
|
||||
[x,y,z] point that picks the edge for fillet and chamfer; a face's point or its name picks the face for the face commands.",
|
||||
[x,y,z] point that picks the edge for fillet and chamfer; a face's point or its name picks the face for the face commands. \
|
||||
Arguments: body (required).",
|
||||
obj(json!({"body": string("Body name, e.g. \"Body1\"")}), &["body"]),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
@@ -184,7 +198,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
Connected to the app: a screenshot of the window (source \"window\", the default) or a render of the model \
|
||||
with the app's camera (source \"model\"). Both show an active section (inspect.section) with its cut face \
|
||||
capped. `view` first turns the camera to a standard view or a saved named view (view.save), `camera` to any camera (as view.apply). \
|
||||
`grid: false` and `axes: false` leave the ground grid (on Z 0) and the origin's axes out of a model render.",
|
||||
`grid: false` and `axes: false` leave the ground grid (on Z 0) and the origin's axes out of a model render. \
|
||||
Arguments: view, camera, path, width, height, source, grid, axes (all optional).",
|
||||
obj(
|
||||
json!({
|
||||
"view": string(
|
||||
@@ -239,6 +254,16 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
"pending",
|
||||
"Result of a running request",
|
||||
"Bridged to the app: the result of a request that was still running when its tool call returned (an error \
|
||||
starting \"still running:\"). Waits for it up to 60 s: then its own result (or error), or done: false while \
|
||||
it still runs (call again). pending: null when nothing is running. A request still running was not \
|
||||
cancelled: don't send it again; other calls wait for it before they are sent.",
|
||||
obj(json!({}), &[]),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), Effect::Edits),
|
||||
tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), Effect::Edits),
|
||||
tool(
|
||||
@@ -309,7 +334,10 @@ pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
|
||||
let props = schema["properties"].as_object();
|
||||
for (k, v) in a {
|
||||
let Some(p) = props.and_then(|p| p.get(k)) else {
|
||||
return Err(format!("{name}: unknown argument `{k}`"));
|
||||
let valid: Vec<&str> = props.map(|p| p.keys().map(String::as_str).collect()).unwrap_or_default();
|
||||
let valid = if valid.is_empty() { "none".to_string() } else { valid.join(", ") };
|
||||
let k: String = k.chars().take(64).collect();
|
||||
return Err(format!("{name}: unknown argument `{k}`; its arguments are: {valid}"));
|
||||
};
|
||||
if v.is_null() {
|
||||
continue;
|
||||
@@ -331,11 +359,12 @@ pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A tool result: content blocks plus the error flag.
|
||||
/// A tool result: content blocks plus the error flag. JSON is compact (not pretty-printed): the
|
||||
/// agent reads every byte.
|
||||
fn text_result(v: &Value, is_error: bool) -> Value {
|
||||
let text = match v {
|
||||
Value::String(s) => s.clone(),
|
||||
v => serde_json::to_string_pretty(v).unwrap_or_default(),
|
||||
v => v.to_string(),
|
||||
};
|
||||
let mut r = json!({"content": [{"type": "text", "text": text}]});
|
||||
if is_error {
|
||||
@@ -376,31 +405,15 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
match name {
|
||||
"list_commands" => {
|
||||
let all = b.call("engine.commands", json!({}))?;
|
||||
let Some(f) = str_arg(a, "filter").map(str::to_lowercase).filter(|f| !f.is_empty()) else { return Ok(all) };
|
||||
let hit = |c: &Value| {
|
||||
// Not "params": many docs name other commands (e.g. "usable in sketch.delete"), #269.
|
||||
["id", "label", "panel", "tab"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f)))
|
||||
};
|
||||
Ok(Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default()))
|
||||
Ok(filter_commands(all, str_arg(a, "filter")))
|
||||
}
|
||||
"execute" => {
|
||||
let id = str_arg(a, "command").ok_or("`command` is required")?;
|
||||
let v = exec(b, id, a.get("params").cloned().filter(Value::is_object).unwrap_or(json!({})))?;
|
||||
Ok(if a.get("verbose").and_then(Value::as_bool).unwrap_or(true) { v } else { compact(v) })
|
||||
}
|
||||
"inspect_design" => {
|
||||
let measure = a.get("measure").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut d = b.call("document.inspect", json!({"measure": measure}))?;
|
||||
if a.get("sketches").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let ids: Vec<Value> = d["sketches"].as_array().map(|v| v.iter().map(|s| s["id"].clone()).collect()).unwrap_or_default();
|
||||
let full: Vec<Value> = ids
|
||||
.into_iter()
|
||||
.map(|id| exec(b, "sketch.inspect", json!({"sketch": id.clone()})).unwrap_or_else(|e| json!({"id": id, "error": e})))
|
||||
.collect();
|
||||
d["sketches"] = Value::Array(full);
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
"inspect_design" => inspect_design(b, a),
|
||||
"pending" => pending(b),
|
||||
"measure" => {
|
||||
if let Some(items) = a.get("items") {
|
||||
return exec(b, "inspect.measure", json!({ "items": items }));
|
||||
@@ -436,6 +449,195 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
}
|
||||
}
|
||||
|
||||
/// The command catalog filtered by a case-insensitive substring of id, label, panel or tab
|
||||
/// (`list_commands`; not the parameter docs, which name other commands, #269).
|
||||
fn filter_commands(all: Value, filter: Option<&str>) -> Value {
|
||||
let Some(f) = filter.map(str::to_lowercase).filter(|f| !f.is_empty()) else { return all };
|
||||
let hit =
|
||||
|c: &Value| ["id", "label", "panel", "tab"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f)));
|
||||
Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Names for an error message: at most 40 of them.
|
||||
fn name_list(names: &[String]) -> String {
|
||||
let mut out = names.iter().take(40).cloned().collect::<Vec<_>>().join(", ");
|
||||
if names.len() > 40 {
|
||||
out.push_str(&format!(", … ({} in all)", names.len()));
|
||||
}
|
||||
if out.is_empty() { "none".into() } else { out }
|
||||
}
|
||||
|
||||
/// `inspect_design`: `document.inspect`, with sketch summaries unless sketches are asked for in
|
||||
/// full (by name or id, or all), and only the bodies asked for.
|
||||
fn inspect_design(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<Value, String> {
|
||||
let measure = a.get("measure").and_then(Value::as_bool).unwrap_or(true);
|
||||
let bodies: Option<Vec<String>> = match a.get("bodies").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.and_then(|l| l.iter().map(|n| n.as_str().map(str::to_string)).collect::<Option<Vec<_>>>())
|
||||
.ok_or("inspect_design: `bodies` must be a list of body names")?,
|
||||
),
|
||||
};
|
||||
// With `bodies`, only those are measured.
|
||||
let mut d = b.call("document.inspect", json!({"measure": measure && bodies.is_none()}))?;
|
||||
if let Some(want) = bodies {
|
||||
let all: Vec<Value> = d["bodies"].as_array().cloned().unwrap_or_default();
|
||||
let names: Vec<String> = all.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect();
|
||||
if let Some(missing) = want.iter().find(|n| !names.contains(n)) {
|
||||
return Err(format!("inspect_design: no body `{missing}` (bodies: {})", name_list(&names)));
|
||||
}
|
||||
let mut kept: Vec<Value> = all.into_iter().filter(|x| x["name"].as_str().is_some_and(|n| want.iter().any(|w| w == n))).collect();
|
||||
if measure && !want.is_empty() {
|
||||
let m = exec(b, "inspect.measure", json!({"bodies": want}))?;
|
||||
for body in &mut kept {
|
||||
let measured = m["bodies"].as_array().and_then(|l| l.iter().find(|x| x["name"] == body["name"])).cloned();
|
||||
if let (Some(Value::Object(fields)), Some(o)) = (measured, body.as_object_mut()) {
|
||||
for (k, v) in fields {
|
||||
o.entry(k).or_insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d["bodies"] = Value::Array(kept);
|
||||
}
|
||||
let sketches: Vec<Value> = d["sketches"].as_array().cloned().unwrap_or_default();
|
||||
let full: Vec<Value> = match a.get("sketches") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => Vec::new(),
|
||||
Some(Value::String(t)) if t == "summary" => Vec::new(),
|
||||
Some(Value::Bool(true)) => sketches.iter().map(|x| x["id"].clone()).collect(),
|
||||
Some(Value::String(t)) if t == "full" || t == "all" => sketches.iter().map(|x| x["id"].clone()).collect(),
|
||||
Some(Value::Array(list)) => {
|
||||
let mut ids = Vec::new();
|
||||
for want in list {
|
||||
let found = sketches.iter().find(|x| match want {
|
||||
Value::String(n) => x["name"].as_str() == Some(n.as_str()) || x["id"].as_u64().is_some_and(|id| n.parse::<u64>() == Ok(id)),
|
||||
Value::Number(_) => x["id"] == *want,
|
||||
_ => false,
|
||||
});
|
||||
match found {
|
||||
Some(x) => ids.push(x["id"].clone()),
|
||||
None => {
|
||||
let names: Vec<String> = sketches.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect();
|
||||
return Err(format!("inspect_design: no sketch {want} (sketches: {})", name_list(&names)));
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(format!("inspect_design: `sketches` must be true, false, \"summary\" or a list of sketch names (got {other})"));
|
||||
}
|
||||
};
|
||||
if !full.is_empty() {
|
||||
let out: Vec<Value> = sketches
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
if full.contains(&x["id"]) {
|
||||
exec(b, "sketch.inspect", json!({"sketch": x["id"].clone()})).unwrap_or_else(|e| json!({"id": x["id"], "error": e}))
|
||||
} else {
|
||||
x
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
d["sketches"] = Value::Array(out);
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// `pending`: the end of a request still running when its call returned (bridged only; a
|
||||
/// headless session runs every request to its end).
|
||||
fn pending(b: &mut dyn Backend) -> Result<Value, String> {
|
||||
let p = b.call("control.pending", json!({}))?;
|
||||
let what = p.get("pending").cloned().unwrap_or(Value::Null);
|
||||
if what.is_null() {
|
||||
return Ok(json!({"pending": null, "note": "nothing is running"}));
|
||||
}
|
||||
let name = what.as_str().unwrap_or("the request").to_string();
|
||||
if p.get("lost").and_then(Value::as_bool) == Some(true) {
|
||||
return Err(format!(
|
||||
"the connection to the app was lost while {name} was running, and its result with it: inspect the design (inspect_design) \
|
||||
to see whether it applied before sending it again"
|
||||
));
|
||||
}
|
||||
if p.get("done").and_then(Value::as_bool) == Some(true) {
|
||||
let reply = p.get("reply").cloned().unwrap_or(Value::Null);
|
||||
return match reply.get("ok").and_then(Value::as_bool) {
|
||||
Some(true) => Ok(json!({"pending": what, "done": true, "result": reply.get("result").cloned().unwrap_or(Value::Null)})),
|
||||
_ => Err(format!("{name} failed: {}", reply.get("error").and_then(Value::as_str).unwrap_or("no reply"))),
|
||||
};
|
||||
}
|
||||
Ok(json!({"pending": what, "done": false, "running_s": p.get("running_s"), "note": "still running: call pending again to keep waiting"}))
|
||||
}
|
||||
|
||||
/// A `batch` step as the engine command it runs: an engine command as it is, or one of these
|
||||
/// tools' names (an agent writes the tool it knows) with that tool's arguments as its params.
|
||||
/// `Ok((command, params, filter))`: `filter` narrows a `list_commands` step's catalog; an empty
|
||||
/// `command` means the step is an engine command already.
|
||||
fn batch_step(step: &Value) -> Result<(String, Value, Option<String>), String> {
|
||||
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or("missing `command`")?;
|
||||
let empty = Map::new();
|
||||
let params = step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}));
|
||||
let a = params.as_object().unwrap_or(&empty);
|
||||
let mapped = |c: &str, p: Value| Ok((c.to_string(), p, None));
|
||||
match command {
|
||||
"list_commands" => Ok(("engine.commands".into(), json!({}), str_arg(a, "filter").map(str::to_string))),
|
||||
"set_parameter" => {
|
||||
let mut p = pick(a, &["name", "unit", "comment"]);
|
||||
p["expression"] = a.get("expression").or_else(|| a.get("value")).cloned().unwrap_or(Value::Null);
|
||||
mapped("parameters.change", p)
|
||||
}
|
||||
"execute" => {
|
||||
let inner = str_arg(a, "command").ok_or("an `execute` step needs params {command, params}")?;
|
||||
if !inner.contains('.') && TOOL_STEPS.contains(&inner) {
|
||||
return Err(format!("an `execute` step runs an engine command, not the `{inner}` tool"));
|
||||
}
|
||||
mapped(inner, a.get("params").cloned().filter(Value::is_object).unwrap_or(json!({})))
|
||||
}
|
||||
"measure" => match a.get("items") {
|
||||
Some(items) => mapped("inspect.measure", json!({"items": items})),
|
||||
None => mapped("inspect.measure", str_arg(a, "body").map_or(json!({}), |body| json!({"bodies": [body]}))),
|
||||
},
|
||||
"inspect_design" => mapped("document.inspect", json!({"measure": a.get("measure").and_then(Value::as_bool).unwrap_or(true)})),
|
||||
"undo" => mapped("edit.undo", json!({})),
|
||||
"redo" => mapped("edit.redo", json!({})),
|
||||
"export" => mapped("file.export", pick(a, &["path", "format", "bodies", "mesh_prefix", "effort", "velocity"])),
|
||||
"save" => mapped("file.save", pick(a, &["path", "format"])),
|
||||
"open" => mapped("doc.open", pick(a, &["path", "up", "names"])),
|
||||
"new_design" => mapped("file.new", pick(a, &["name"])),
|
||||
"crash_reports" => match a.get("id") {
|
||||
Some(_) => mapped("diagnostics.crash_report", pick(a, &["id"])),
|
||||
None => mapped("diagnostics.crash_reports", pick(a, &["limit"])),
|
||||
},
|
||||
"body_topology" => Err("`body_topology` is a tool, not a command: in a batch use model.edges {body} and model.faces {body}".into()),
|
||||
"screenshot" => Err("`screenshot` is a tool, not a command: call it after the batch".into()),
|
||||
"batch" => Err("batches don't nest: put the steps in this batch".into()),
|
||||
"pending" => Err("`pending` is a tool, not a command: call it on its own".into()),
|
||||
// An engine command: the step goes as it is.
|
||||
_ => Ok((String::new(), Value::Null, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool names a `batch` step may not be (or is mapped from): `execute` must not wrap them.
|
||||
const TOOL_STEPS: &[&str] = &[
|
||||
"batch",
|
||||
"body_topology",
|
||||
"crash_reports",
|
||||
"execute",
|
||||
"export",
|
||||
"inspect_design",
|
||||
"list_commands",
|
||||
"measure",
|
||||
"new_design",
|
||||
"open",
|
||||
"pending",
|
||||
"redo",
|
||||
"save",
|
||||
"screenshot",
|
||||
"set_parameter",
|
||||
"undo",
|
||||
];
|
||||
|
||||
fn undo_depth(b: &mut dyn Backend) -> Option<u64> {
|
||||
b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64())
|
||||
}
|
||||
@@ -458,15 +660,16 @@ impl Undo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Go back; the number of steps undone.
|
||||
fn rollback(self, b: &mut dyn Backend, steps: usize) -> u64 {
|
||||
/// Go back: whether the design really is back where the batch started, and the number of
|
||||
/// steps undone.
|
||||
fn rollback(self, b: &mut dyn Backend, steps: usize) -> (bool, u64) {
|
||||
match self {
|
||||
Undo::Checkpoint(id) => match exec(b, "edit.restore_checkpoint", json!({"checkpoint": id})) {
|
||||
Ok(_) => steps as u64,
|
||||
Err(_) => 0,
|
||||
Ok(_) => (true, steps as u64),
|
||||
Err(_) => (false, 0),
|
||||
},
|
||||
Undo::Depth(before) => {
|
||||
let after = undo_depth(b).unwrap_or(before);
|
||||
let Some(after) = undo_depth(b) else { return (false, 0) };
|
||||
let mut undone = 0u64;
|
||||
for _ in before..after {
|
||||
if exec(b, "edit.undo", json!({})).is_err() {
|
||||
@@ -474,9 +677,9 @@ impl Undo {
|
||||
}
|
||||
undone += 1;
|
||||
}
|
||||
undone
|
||||
(undone == after.saturating_sub(before), undone)
|
||||
}
|
||||
Undo::None => 0,
|
||||
Undo::None => (false, 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +704,44 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
let verbose = a.get("verbose").and_then(Value::as_bool).unwrap_or(true);
|
||||
let profile = a.get("profile").and_then(Value::as_bool).unwrap_or(false);
|
||||
let timed = profile || a.get("timings").and_then(Value::as_bool).unwrap_or(false);
|
||||
// Steps that name a tool run the engine command it stands for. One that can't be a step
|
||||
// fails the batch before anything is sent.
|
||||
let mut mapped: Vec<Value> = Vec::with_capacity(steps.len());
|
||||
let mut filters: Vec<(usize, String)> = Vec::new();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
match batch_step(step) {
|
||||
Ok((command, _, _)) if command.is_empty() => mapped.push(step.clone()),
|
||||
Ok((command, params, filter)) => {
|
||||
if let Some(f) = filter {
|
||||
filters.push((i, f));
|
||||
}
|
||||
mapped.push(json!({"command": command, "params": params}));
|
||||
}
|
||||
// A step without a command fails where it stands, as before.
|
||||
Err(e) if e == "missing `command`" => mapped.push(step.clone()),
|
||||
Err(e) => {
|
||||
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str);
|
||||
let reply = json!({
|
||||
"ok": false,
|
||||
"completed": 0,
|
||||
"failed": {"index": i, "command": command, "error": e},
|
||||
"rolled_back": false,
|
||||
"undone": 0,
|
||||
"applied": [],
|
||||
"not_run": (0..steps.len()).collect::<Vec<_>>(),
|
||||
"results": [],
|
||||
"note": "nothing was run",
|
||||
});
|
||||
return text_result(&reply, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
let steps = &mapped;
|
||||
// A `list_commands` step's catalog, filtered as the tool filters it.
|
||||
let narrow = |i: usize, v: Value| match filters.iter().find(|(j, _)| *j == i) {
|
||||
Some((_, f)) => filter_commands(v, Some(f)),
|
||||
None => v,
|
||||
};
|
||||
// An app that runs batches itself answers the whole batch in one request, so one frame
|
||||
// (#743). One too long for a control line goes step by step: the app would refuse it unrun.
|
||||
// The margin covers the envelope and the `allow_paths` the backend adds.
|
||||
@@ -511,11 +752,14 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
}
|
||||
if params.to_string().len() + 64 * 1024 <= solidcraft_engine::MAX_LINE {
|
||||
match b.call("engine.batch", params) {
|
||||
Ok(r) => return batch_reply(&r, rollback, verbose),
|
||||
Ok(r) => return batch_reply(&r, steps.len(), rollback, verbose, &narrow),
|
||||
// Older apps and the headless session, or the app wants the steps one by one.
|
||||
Err(e) if e.starts_with("unknown method") || e.starts_with("engine.batch unavailable") => {}
|
||||
// Never sent: no app to send it to.
|
||||
Err(e) if e.starts_with(crate::backend::NOT_RUNNING) => return text_result(&Value::String(e), true),
|
||||
// Never sent: no app to send it to. Or sent and still running (it was not cancelled):
|
||||
// the `pending` tool gets its result.
|
||||
Err(e) if e.starts_with(crate::backend::NOT_RUNNING) || e.starts_with(STILL_RUNNING) => {
|
||||
return text_result(&Value::String(e), true);
|
||||
}
|
||||
// The app may have run it: sending the steps again would apply them twice.
|
||||
Err(e) => {
|
||||
let msg = format!("the app did not answer engine.batch ({e}); it may have run: inspect the design before sending it again");
|
||||
@@ -539,15 +783,31 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
rows.push(took.to_json(i, command.unwrap_or_default()));
|
||||
}
|
||||
match r {
|
||||
Ok(v) => results.push(if verbose { v } else { compact(v) }),
|
||||
Ok(v) => {
|
||||
let v = narrow(i, v);
|
||||
results.push(if verbose { v } else { compact(v) })
|
||||
}
|
||||
Err(e) => {
|
||||
let undone = start.rollback(b, i);
|
||||
// A step still running in the app was not cancelled: the rollback first waits for
|
||||
// it (the backend does), so a restored checkpoint takes it back too.
|
||||
let running = e.starts_with(STILL_RUNNING);
|
||||
let (rolled_back, undone) = start.rollback(b, i);
|
||||
let mut failed = json!({"index": i, "command": command, "error": e});
|
||||
// Applied: the steps before it, unless rolled back; and the step itself while it
|
||||
// still runs, as it will apply when it finishes.
|
||||
let mut applied: Vec<usize> = if rolled_back { Vec::new() } else { (0..i).collect() };
|
||||
if running && !rolled_back {
|
||||
failed["still_running"] = json!(true);
|
||||
applied.push(i);
|
||||
}
|
||||
let reply = json!({
|
||||
"ok": false,
|
||||
"completed": i,
|
||||
"failed": {"index": i, "command": command, "error": e},
|
||||
"rolled_back": rollback,
|
||||
"failed": failed,
|
||||
"rolled_back": rolled_back,
|
||||
"undone": undone,
|
||||
"applied": applied,
|
||||
"not_run": (i + 1..steps.len()).collect::<Vec<_>>(),
|
||||
"results": results,
|
||||
});
|
||||
return text_result(&with_timings(reply, timed, rows, clock.ms()), true);
|
||||
@@ -569,22 +829,39 @@ fn with_timings(mut reply: Value, timed: bool, rows: Vec<Value>, total_ms: f64)
|
||||
}
|
||||
|
||||
/// The `batch` reply from the app's `engine.batch` answer, shaped as the per-step path shapes it.
|
||||
fn batch_reply(r: &Value, rollback: bool, verbose: bool) -> Value {
|
||||
let results: Vec<Value> =
|
||||
r["results"].as_array().cloned().unwrap_or_default().into_iter().map(|v| if verbose { v } else { compact(v) }).collect();
|
||||
fn batch_reply(r: &Value, len: usize, rollback: bool, verbose: bool, narrow: &dyn Fn(usize, Value) -> Value) -> Value {
|
||||
let results: Vec<Value> = r["results"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| narrow(i, v))
|
||||
.map(|v| if verbose { v } else { compact(v) })
|
||||
.collect();
|
||||
let (mut reply, error) = match r.get("failed").filter(|f| !f.is_null()) {
|
||||
None => (json!({"ok": true, "completed": results.len(), "results": results}), false),
|
||||
Some(failed) => (
|
||||
json!({
|
||||
"ok": false,
|
||||
"completed": r["completed"],
|
||||
"failed": failed,
|
||||
"rolled_back": rollback,
|
||||
"undone": r["undone"],
|
||||
"results": results,
|
||||
}),
|
||||
true,
|
||||
),
|
||||
Some(failed) => {
|
||||
let completed = r["completed"].as_u64().map_or(results.len(), |c| c as usize).min(len);
|
||||
// An app that says whether it went back is believed; an older one went back when it
|
||||
// undid every completed step.
|
||||
let rolled_back =
|
||||
r.get("rolled_back").and_then(Value::as_bool).unwrap_or_else(|| rollback && r["undone"].as_u64() == Some(completed as u64));
|
||||
let applied: Vec<usize> = if rolled_back { Vec::new() } else { (0..completed).collect() };
|
||||
(
|
||||
json!({
|
||||
"ok": false,
|
||||
"completed": r["completed"],
|
||||
"failed": failed,
|
||||
"rolled_back": rolled_back,
|
||||
"undone": r["undone"],
|
||||
"applied": applied,
|
||||
"not_run": (completed + 1..len).collect::<Vec<_>>(),
|
||||
"results": results,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
}
|
||||
};
|
||||
// The app timed the steps where they ran.
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
|
||||
@@ -51,13 +51,17 @@ answered within 60 seconds (3600 seconds for `engine.batch`) is replied to with
|
||||
`{"ok": false, "running": true, "error": "timeout: … is still running …"}`. The same 16 MiB line
|
||||
limit applies to the MCP server.
|
||||
|
||||
A timed-out request has not failed: it is still running in the app and will still apply (a long
|
||||
fillet, say), so don't simply send it again. The connection keeps its reply (#1618). Ask for it
|
||||
A timed-out request has not failed: it is still running in the app, and a change still applies
|
||||
when it finishes (a long fillet, say), so don't simply send it again. The timeout's text says
|
||||
which: a request that only reads (`ui.inspect`, `document.inspect`, an inspection command)
|
||||
"only reads: nothing changes". The connection keeps its reply (#1618). Ask for it
|
||||
with `control.pending`: `{"pending": <id>, "method": …, "done": false}` while it runs, then
|
||||
`{"pending": <id>, "method": …, "done": true, "reply": {…}}` (its own reply, once), and
|
||||
`{"pending": null}` when nothing is outstanding. Until it lands, any other request on that
|
||||
connection is refused at once with `{"ok": false, "busy": true, "error": "busy: …"}` and not
|
||||
run, so nothing is queued to apply unseen behind it.
|
||||
run, so nothing is queued to apply unseen behind it. `control.pending` belongs to the
|
||||
connection: a new connection has nothing pending. The bridged MCP server does this itself
|
||||
([mcp.md](mcp.md#long-running-requests)).
|
||||
|
||||
Any request's `params` may carry `allow_paths: ["/abs/folder", …]`: every file that request
|
||||
reads, writes or deletes (command paths such as `file.export`'s `path` or `doc.recovery_*`'s
|
||||
@@ -74,7 +78,7 @@ MCP server sends its folders this way on every request ([mcp.md](mcp.md)). Witho
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`: command id, `params`: JSON object | the command's result |
|
||||
| `engine.script` | `commands`: `[{command, params}, …]` | results, stops at the first error |
|
||||
| `engine.batch` | `commands`: `[{command, params}, …]` (at most 10 000), `rollback?`: bool (default true), `timings?`, `profile?`: bool (default false) | `{completed, results, undone}`, plus `failed: {index, command, error}` when a step fails, and with `timings` `timings: [{index, command, ms}]` (the failing step included) and `total_ms`; `profile` adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`. Runs the steps in one request, so within one frame, each as `engine.execute` runs it. The first failure stops the batch, and with `rollback` the design goes back to how it was before it (`undone`: the steps taken back). Refused before anything runs, with an error starting `engine.batch unavailable`, while the agent cursor is shown or when a step loads a file (`doc.open`, `file.insert_step`, `file.insert_mesh`). Send such steps one by one. Answered when done, for up to 3600 s |
|
||||
| `engine.batch` | `commands`: `[{command, params}, …]` (at most 10 000), `rollback?`: bool (default true), `timings?`, `profile?`: bool (default false) | `{completed, results, undone}`, plus `failed: {index, command, error}` when a step fails, and with `timings` `timings: [{index, command, ms}]` (the failing step included) and `total_ms`; `profile` adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`. Runs the steps in one request, so within one frame, each as `engine.execute` runs it. The first failure stops the batch, and with `rollback` the design goes back to how it was before it (`undone`: the steps taken back; `rolled_back`: whether the design really is back at its checkpoint). Refused before anything runs, with an error starting `engine.batch unavailable`, when a step loads a file (`doc.open`, `file.insert_step`, `file.insert_mesh`). Send such steps one by one. While the agent cursor is shown, the pointer first acts out a short summary (the toolbar buttons of a few of its commands, within 1.5 s), then the whole batch runs. Answered when done, for up to 3600 s |
|
||||
| `engine.commands` | — | every command: id, label, tab, panel, icon, shortcut, params doc, enabled |
|
||||
| `document.inspect` | `measure?`: bool | parameters, timeline (errors, timings), bodies, sketches, selection |
|
||||
|
||||
@@ -155,7 +159,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `pan_reverse_x` (default false: horizontal scrolling pans the other way), `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system` or a theme file's id: a built-in one such as `gruvbox-dark`, or an imported one; the reply's `theme_ids` lists them), `theme_import` (a `solidcraft-theme/1` file's JSON or text: adds the theme and turns it on; the reply's `theme_imported` is its id), `theme_remove` (an imported theme's id), `theme_export: true` (the reply's `theme_file` is the theme on now as a complete file; see [colour themes](book/src/guide/ui-tour.md#colour-themes)), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ms`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to its picks (at most three: a long point list is summarised by its first, middle and last point on screen), then runs and replies, within 1.5 s per request whatever its size (the glides get shorter); an `engine.batch` is acted out as one request. Later requests wait their turn. `"animate": false` on a call skips it |
|
||||
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `attach?`: a file's path (a picture, a text file or a CAD file, for the next message; CAD files are imported at once), `remove_file?` (index of a file on the next message), `send?`: text (files alone make a message too), `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages, with their `files`), `attachments` (the files on the next message, by name), `loading` (pictures still being read for it, by name), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
|
||||
| `ui.resize` | `width`, `height` | |
|
||||
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
|
||||
|
||||
+29
-6
@@ -99,8 +99,8 @@ file dialogs are never limited.
|
||||
### Tool annotations
|
||||
|
||||
Each tool says what it may do, for clients that ask before running tools (or auto-approve on
|
||||
these hints). `list_commands`, `inspect_design`, `measure`, `body_topology` and `crash_reports`
|
||||
are read-only. `set_parameter`, `undo` and `redo` change the design undoably. `new_design`
|
||||
these hints). `list_commands`, `inspect_design`, `measure`, `body_topology`, `crash_reports` and
|
||||
`pending` are read-only. `set_parameter`, `undo` and `redo` change the design undoably. `new_design`
|
||||
replaces it (destructive). `execute`, `batch`, `open`, `save`, `export` and `screenshot` (whose
|
||||
`path` writes a file) can read, write, overwrite or delete files: they are not read-only,
|
||||
`destructiveHint` and `openWorldHint` are true.
|
||||
@@ -144,14 +144,15 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t
|
||||
|---|---|---|
|
||||
| `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. |
|
||||
| `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. |
|
||||
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?` (default false) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`) and every result before it. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. |
|
||||
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
|
||||
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?` (default false) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `execute` (`{command, params}`), `measure`, `inspect_design` (`document.inspect`, summaries), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch` and `pending` can't be steps: such a batch fails before anything is sent. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app), also while the app shows the agent cursor (the pointer acts out a short summary of the batch first). Older apps get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. |
|
||||
| `inspect_design` | `measure?` (default true), `sketches?` (a list of sketch names or ids, `true` for all, or `false` / `"summary"`: the default), `bodies?` (names; default all) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches, selection, undo depth. Each sketch is summarised (`id`, `name`, `plane`, the number of `points`, `curves`, `constraints` and `profiles`, `dof`, `status`, `visible`); its full contents (curves with ids and coordinates, points, constraints, profiles), as `execute` `sketch.inspect` gives them, only when `sketches` names it (or is `true`): a sketch of 720 points is some 200 KB in full. `bodies` keeps only the bodies named (and measures only them); an unknown sketch or body name is an error that lists the names there are. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
|
||||
| `measure` | `body?` or `items?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: see below. |
|
||||
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). |
|
||||
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
|
||||
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: a render with the viewport's pipelines on a headless GPU device (see `solidcraft-cli gpu`; with no adapter, the CPU rasterizer, without analysis colours), fitted to the model, or from the named view or camera; the reply's `renderer` says which (`gpu`, `gpu-software`, `cpu`, with a `warning` for `cpu`). Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. A window that is minimised or on another macOS Space presents no frame: with no `source` the reply is a model render and says so (`fallback`); `source: "window"` is an error saying why, at once. Agents that don't need the app's chrome can ask for `source: "model"`. |
|
||||
| `export` | `path`, `format?` (step, 3mf, 3mf-tagged, stl, stla, obj, urdf), `bodies?`, `mesh_prefix?`, `effort?`, `velocity?` | STEP, 3MF or mesh export; URDF writes the whole assembly with its meshes next to it (`docs/urdf.md`). |
|
||||
| `undo`, `redo` | — | Undo / redo one change. |
|
||||
| `pending` | — | Bridged: the result of a request that was still running when its call returned (an error starting `still running:`), waiting up to 60 s for it; `done: false` while it runs (call again), `pending: null` when nothing runs. See [Long-running requests](#long-running-requests). |
|
||||
| `crash_reports` | `id?`, `limit?` | The crash reports on this computer, newest first; with `id`, the whole report (backtrace, command, recent commands, `design_path`/`replay_path`). See [crash-reports.md](crash-reports.md). |
|
||||
| `new_design` | `name?` | Start an empty design. |
|
||||
| `open` | `path` | Open a `.solidcraft` design, a STEP file (`.step`/`.stp`) as a new design holding its bodies (an Import base feature), or a 3MF/STL file as mesh bodies. |
|
||||
@@ -184,8 +185,30 @@ Resources: `solidcraft://design` (inspect JSON with measurements) and `solidcraf
|
||||
(the catalog).
|
||||
|
||||
Errors: an unknown tool or arguments that don't match the tool's schema are JSON-RPC
|
||||
`-32602` errors; an engine failure (bad command parameters, a feature that fails) is a tool
|
||||
result with `isError: true` and the engine's message, which the model can read and correct.
|
||||
`-32602` errors (an unknown argument's error lists the tool's arguments); an engine failure
|
||||
(bad command parameters, a feature that fails) is a tool result with `isError: true` and the
|
||||
engine's message, which the model can read and correct. Tool results that are JSON are compact
|
||||
(not pretty-printed).
|
||||
|
||||
### Long-running requests
|
||||
|
||||
The app answers a control request that runs longer than 60 seconds (a big fillet, say) with
|
||||
"still running" ([control-protocol.md](control-protocol.md#limits)); it has not failed, and was
|
||||
not cancelled. The bridged server then keeps the connection, where the reply will come, and
|
||||
asks the app (`control.pending`) every half second for up to 240 seconds more, saying on stderr
|
||||
how long it waits; when the request finishes, the tool call returns its real result. If it still
|
||||
runs after that, the tool call fails with an error starting `still running:` that names the
|
||||
request and says whether it will still apply (a request that only reads changes nothing). Don't
|
||||
send it again: call the `pending` tool, which waits up to 60 seconds for its result each time.
|
||||
Any other tool call first waits up to 60 seconds for it too, and is not sent while it still runs
|
||||
(so nothing is queued to apply unseen behind it); a request that finishes in that wait keeps its
|
||||
result for `pending`. If the connection to the app is lost meanwhile, the result is lost with it,
|
||||
and `pending` says so: inspect the design before sending a change again.
|
||||
|
||||
A `batch` step that is still running stops the batch like a failure. Its rollback first waits for
|
||||
the step (as above), so a restored checkpoint takes the step back too; if it still runs, the
|
||||
reply says `rolled_back: false`, marks the step `still_running` and lists it in `applied`, as it
|
||||
will apply when it finishes.
|
||||
|
||||
`batch` rollback goes back to an engine checkpoint (`edit.checkpoint`) taken before the first
|
||||
step: the design, the active sketch and the undo history are restored exactly as they were,
|
||||
|
||||
@@ -133,7 +133,7 @@ After `set_parameter L=120` the volume is 35335.2 mm³, and the holes stay in th
|
||||
|---|---|
|
||||
| `list_commands {}` | **Over 100 KB.** Never call it unfiltered; filter on a distinctive word such as `"hole"` or `"slot"`. |
|
||||
| `body_topology` | 25-45 KB |
|
||||
| `inspect_design {}` | 12 KB, and it grows with the sketches |
|
||||
| `inspect_design {}` | 12 KB; sketches are summaries (counts, DOF, plane) unless named in `sketches` |
|
||||
| `inspect_design {"measure":false,"sketches":false}` | 4 KB: parameters, timeline errors, bodies |
|
||||
| `sketch.inspect` | 5 KB per small sketch |
|
||||
| `parameters.list {"filter":"feature"}` | 3 KB |
|
||||
|
||||
Reference in New Issue
Block a user