From 263c4d1ceb133af8a000dff2265e898572da4e77 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:50:52 +0800 Subject: [PATCH] fix(studio): a prompt always starts an image; bundle errors say what a bundle is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First live runs of App Studio on the desktop development build, with the system agent (an OpenAI model) driving the tools from a plain brief: - `studio.image`: the model sent a job of its own making ("1", "2", "new", a name) beside every prompt, six times over two turns, even after being told not to; the refusal taught it nothing and no image was ever asked for. A call with a prompt now starts a generation whatever else it carries, only a call without one polls; every property has a description and the tool text says the tool assigns the job id. - `studio.bundle_check`: the model passed `main.splash` itself, then a folder without a manifest, and got "manifest.json: No such file…". The error and the tool text now say a bundle is a folder in the workspace holding manifest.json and main.splash. Co-Authored-By: Claude Fable 5.1 --- crates/shell/src/host_tools/studio.rs | 2 +- crates/shell/src/host_tools/studio_bundles.rs | 7 +++-- crates/shell/src/host_tools/studio_image.rs | 27 ++++++++++++------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/crates/shell/src/host_tools/studio.rs b/crates/shell/src/host_tools/studio.rs index fc981bb0..83899e7b 100644 --- a/crates/shell/src/host_tools/studio.rs +++ b/crates/shell/src/host_tools/studio.rs @@ -66,7 +66,7 @@ fn tool_declaration(app: &str, name: &str) -> Value { } fn app_declaration(app: &str, name: &str) -> Value { let (description,properties,required)=match name { - "studio.bundle_check" => ("Stage and validate an offline main.splash developer bundle from your conversation workspace. Computes and stamps the digest only on a private copy; never modifies source, signs a publisher release, or installs.",json!({"bundle_path":{"type":"string","minLength":1,"maxLength":1024}}),vec!["bundle_path"]), + "studio.bundle_check" => ("Stage and validate an offline main.splash developer bundle from your conversation workspace. bundle_path is a folder in that workspace holding manifest.json and main.splash (not the file itself). Computes and stamps the digest only on a private copy; never modifies source, signs a publisher release, or installs.",json!({"bundle_path":{"type":"string","minLength":1,"maxLength":1024,"description":"A folder in your workspace holding manifest.json and main.splash."}}),vec!["bundle_path"]), "studio.install" => ("Install an offline main.splash bundle locally as a developer app. Requires dev.studio.* id. It appears in Home while its developer grant remains valid; its own state persists across close/reopen. No catalog or publisher signature is created.",json!({"bundle_path":{"type":"string","minLength":1,"maxLength":1024}}),vec!["bundle_path"]), "studio.open" => ("Open a visible contained app. Supply exactly one of bundle_path (disposable preview state) or app_id (previous developer install, persistent app state). Returns an instance id after layout; inspect it before targeting input.",json!({"bundle_path":{"type":"string","minLength":1,"maxLength":1024},"app_id":{"type":"string","minLength":1,"maxLength":100}}),vec![]), "studio.input" => ("Send a real tap or text event to a visible enabled widget in your Studio app instance. Use the widget id from inspect. Never targets another app or host UI.",json!({"instance_id":{"type":"string","minLength":1,"maxLength":100},"widget_id":{"type":"string","minLength":1,"maxLength":512},"action":{"type":"string","enum":["tap","text","scroll"]},"text":{"type":"string","maxLength":2048},"delta_y":{"type":"number","minimum":-2000,"maximum":2000}}),vec!["instance_id","widget_id","action"]), diff --git a/crates/shell/src/host_tools/studio_bundles.rs b/crates/shell/src/host_tools/studio_bundles.rs index b9580d60..50025784 100644 --- a/crates/shell/src/host_tools/studio_bundles.rs +++ b/crates/shell/src/host_tools/studio_bundles.rs @@ -121,8 +121,11 @@ fn admit_snapshot( expected: Option<(&str, &str)>, ) -> Result { let manifest_path = root.join("manifest.json"); - let text = - std::fs::read_to_string(&manifest_path).map_err(|e| format!("manifest.json: {e}"))?; + // The first thing an agent gets wrong is the shape of a bundle: say what + // one is, not only what was not found. + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!("manifest.json: {e}. A bundle is a folder in your workspace holding manifest.json and main.splash; pass that folder as bundle_path") + })?; if text.len() > 64 * 1024 { return Err("manifest exceeds 64 KiB".into()); } diff --git a/crates/shell/src/host_tools/studio_image.rs b/crates/shell/src/host_tools/studio_image.rs index 7896e2ce..e69e4b47 100644 --- a/crates/shell/src/host_tools/studio_image.rs +++ b/crates/shell/src/host_tools/studio_image.rs @@ -72,13 +72,13 @@ const NO_SUCH_JOB: &str = "no such image job in this conversation"; pub fn declaration(app: &str) -> Value { json!({ "name": TOOL, "app": app, - "description": "Developer mode only; the system agent's tool. Generate one image with the person's configured image provider and write it into your workspace, for an app's artwork or a design to build from. Give prompt (and optionally size, seed, class) to start; the reply has path, width, height and format, or, when the provider needs longer than this call, a job id: call again with only that job until state is done. Each generation spends the daily image budget and is approved by developer mode without asking; some providers refuse seed or 512x512; the image never leaves the workspace by itself.", + "description": "Developer mode only; the system agent's tool. Generate one image with the person's configured image provider and write it into your workspace, for an app's artwork or a design to build from. To start, give prompt (and optionally size, seed, class) and nothing else: the tool assigns the job id itself. The reply has path, width, height and format, or, when the provider needs longer than this call, {job, state: \"running\"}: then call again with only that job value, repeating until state is done. A call with a prompt always starts a new generation, whatever else it carries. Each generation spends the daily image budget and is approved by developer mode without asking; some providers refuse seed or 512x512; the image never leaves the workspace by itself.", "input_schema": {"type":"object","properties":{ - "prompt":{"type":"string","minLength":1,"maxLength":4096}, - "size":{"type":"string","enum":SIZES}, - "seed":{"type":"integer","minimum":0,"maximum":4294967295u64}, - "class":{"type":"string","enum":["fast","strong"]}, - "job":{"type":"string","minLength":1,"maxLength":100} + "prompt":{"type":"string","minLength":1,"maxLength":4096,"description":"What to generate. Starts a new generation; leave job out."}, + "size":{"type":"string","enum":SIZES,"description":"Pixel size; default 1024x1024."}, + "seed":{"type":"integer","minimum":0,"maximum":4294967295u64,"description":"Optional seed; some providers refuse it."}, + "class":{"type":"string","enum":["fast","strong"],"description":"Model class; default fast."}, + "job":{"type":"string","minLength":1,"maxLength":100,"description":"Only to poll: the job value a previous reply returned. Never invent one, and do not send it with a prompt."} },"additionalProperties":false}, "output_schema": {"type":"object","properties":{ "job":{"type":"string"},"state":{"type":"string","enum":["running","done"]}, @@ -590,9 +590,13 @@ pub fn execute( let run = || -> Result { let scope = scoped::Workspace::open(root, call.context_id.as_deref())?; let owner = Owner::of(app, call); + // A prompt starts a generation, whatever else the call carries: a + // model that fills every field sends a `job` of its own making with + // its prompt, and a refusal here taught it nothing (observed on the + // first live runs). Only a call without a prompt polls. let (id, started) = match (call.args["job"].as_str(), call.args["prompt"].as_str()) { (Some(job), None) => (job.to_string(), None), - (None, Some(_)) => { + (_, Some(_)) => { // Built before a slot is taken: an invalid request holds none. let service_call = service_call(app, &call.args)?; let key = NEXT_KEY.fetch_add(1, Ordering::Relaxed); @@ -628,7 +632,7 @@ pub fn execute( makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); (id, Some(key)) } - _ => return Err("give prompt to start an image, or job alone to poll one".into()), + (None, None) => return Err("give prompt to start an image, or the job value a reply returned to poll one".into()), }; // Wait for this call's share of the deadline; a running job outlives // the call and is polled with its id. @@ -874,9 +878,12 @@ mod tests { DELAY_MS.store(1500, Ordering::SeqCst); let calls = CALLS.load(Ordering::SeqCst); let cancel = Arc::new(AtomicBool::new(false)); - let first = run(call("h1", "sys", json!({"prompt": "a red square"}), 2000), &workspace, cancel.clone(), || {}).unwrap(); + // The job a model invents beside its prompt is ignored: the call + // starts a generation and the reply names the real job. + let first = run(call("h1", "sys", json!({"prompt": "a red square", "job": "new"}), 2000), &workspace, cancel.clone(), || {}).unwrap(); assert_eq!(first["state"], "running"); let job = first["job"].as_str().unwrap().to_string(); + assert_ne!(job, "new"); // Another conversation cannot collect it. let other = run(call("o1", "other", json!({"job": job}), 2000), &workspace, cancel.clone(), || {}); assert!(other.unwrap_err().contains(NO_SUCH_JOB)); @@ -1201,6 +1208,8 @@ mod tests { assert!(super::super::schema::check(schema, &json!({"job":"j"})).is_ok()); assert!(super::super::schema::check(schema, &json!({"prompt":"x","size":"640x480"})).is_err()); assert!(super::super::schema::check(schema, &json!({"prompt":"x","output_path":"a.png"})).is_err()); + // A prompt with a made-up job is a start, not a refusal (see execute). + assert!(super::super::schema::check(schema, &json!({"prompt":"x","job":"new"})).is_ok()); assert_eq!(decl["risk"], "act"); let request = service_call_in(SYSTEM, &json!({"prompt":"p","size":"512x512","seed":3}), std::env::temp_dir(), SERVICE); assert_eq!(request.service, "model.image");