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:
SS-EN
2026-10-18 18:16:41 -04:00
parent d9c3fdb80f
commit 120d26f769
11 changed files with 1096 additions and 96 deletions
+43 -3
View File
@@ -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, &params);
let reads = control_pending::reads_only(&method, &params);
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.
+5 -1
View File
@@ -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,
+82
View File
@@ -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`");
}
}
+1
View File
@@ -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
View File
@@ -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, &params);
let reads_only = control_pending::reads_only(method, &params);
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}`")),
}
}
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+9 -5
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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 |