From 36bb76cb1921de8b34c2a81d797c9904516d8980 Mon Sep 17 00:00:00 2001 From: Lukas Mathis Date: Thu, 25 Jun 2026 13:18:31 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Fixed=20a=20problem=20where,=20after=20conv?= =?UTF-8?q?ersion,=20the=20app=20would=20sometimes=20not=20end=20up=20in?= =?UTF-8?q?=20a=20state=20where=20"=E2=AC=87=20Download=20CRX=20package"?= =?UTF-8?q?=20is=20visible.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/src/conversion.rs | 19 +++++++++++++++++++ app/src/agent_runner.rs | 9 ++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/agent/src/conversion.rs b/agent/src/conversion.rs index 4de88e0a..c1631284 100644 --- a/agent/src/conversion.rs +++ b/agent/src/conversion.rs @@ -478,6 +478,25 @@ impl ConversionAgent { self.package.clone() } + /// Guarantee a downloadable package when one can be built. + /// + /// Every tree edit invalidates the package (see [`Self::aem_translated_edited`]), + /// and it is only rebuilt when the agent explicitly calls `build_aem_package`. + /// If a run finishes right after an edit, `self.package` is `None` and the UI + /// has nothing to offer for download. Call this at the end of a run: when a + /// working AEM tree exists but no package is current, build one from the + /// latest tree. Returns the resulting package, if any. + pub fn ensure_package(&mut self) -> Option> { + if self.package.is_none() && self.aem_translated.is_some() { + let cfg = self.config().ok()?; + let (aem, translations) = self.lower_aem_translated().ok()?; + let pkg = + blueprint::to_aem_package_from_node_with_translations(&aem, &cfg, translations); + self.package = Some(pkg); + } + self.package.clone() + } + /// The resolved form code, if the AEM config has been loaded. pub fn form_code(&self) -> Option { self.aem_config.as_ref().map(|c| c.form_code.clone()) diff --git a/app/src/agent_runner.rs b/app/src/agent_runner.rs index c507a97f..f3e9879f 100644 --- a/app/src/agent_runner.rs +++ b/app/src/agent_runner.rs @@ -281,7 +281,7 @@ async fn drive_agent( } finalize( - &agent, + &mut agent, &profile, structured_session, start, @@ -292,7 +292,7 @@ async fn drive_agent( /// Build the final `ProcessingState` from the agent's working trees. fn finalize( - agent: &ConversionAgent, + agent: &mut ConversionAgent, profile: &Option, structured_session: String, start: std::time::Instant, @@ -312,7 +312,10 @@ fn finalize( state.ai_mode = true; state.envelope = Some(envelope); state.merged_json = merged_json; - state.aem_package = agent.package(); + // Guarantee a downloadable package: the agent may have finished right after + // a tree edit (which invalidates the package) without a final rebuild. Build + // one from the latest tree so the UI always offers a download. + state.aem_package = agent.ensure_package(); state.form_code = form_code; state.agent_aem_session = agent.aem_session(); state.aem_uploaded = agent.aem_uploaded(); From 72bec02aef3e85b83cea048e5c00c9ed9276dfc6 Mon Sep 17 00:00:00 2001 From: Lukas Mathis Date: Mon, 29 Jun 2026 15:06:54 +0200 Subject: [PATCH 2/4] Fix Dioxus app setup instructions --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2ccb0338..8eb3a513 100644 --- a/README.md +++ b/README.md @@ -28,15 +28,20 @@ Decodes PDFs and extracts structured data for automated forms conversion. ## Prerequisites - [Rust](https://rustup.rs/) (edition 2024) -- [Dioxus CLI](https://dioxuslabs.com/learn/0.6/getting_started) — only needed for the desktop app +- [Dioxus CLI](https://dioxuslabs.com/learn/0.7/getting_started/) — only needed for the desktop app Dioxus can easily be installed using cargo-binstall: ```sh cargo install cargo-binstall -cargo binstall dioxus-cli@0.7.3 +cargo binstall dioxus-cli@0.7.9 ``` +After installing, make sure `dx --version` prints `dioxus 0.7.9`. If it +prints Deno help or executes `deno x`, your shell is resolving a different +`dx` binary first. Put `~/.cargo/bin` before that binary in `PATH`, or call +the Dioxus CLI directly as `~/.cargo/bin/dx`. + In order to version large files we need the git lfs extension ```sh @@ -112,14 +117,14 @@ It bundles an AI conversion agent that drives the engine's tools turn by turn to ```sh cd app -dx serve --platform desktop +~/.cargo/bin/dx serve --platform desktop --package blueprint-app ``` ### Production Build ```sh cd app -dx build --release --platform desktop +~/.cargo/bin/dx build --release --platform desktop --package blueprint-app ``` ## MCP Server From dc43d1487fa264c4583bebb1035d302b15232774 Mon Sep 17 00:00:00 2001 From: Lukas Mathis Date: Tue, 30 Jun 2026 13:59:02 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Fixed=20another=20problem=20where,=20after?= =?UTF-8?q?=20conversion,=20the=20app=20would=20sometimes=20not=20end=20up?= =?UTF-8?q?=20in=20a=20state=20where=20"=E2=AC=87=20Download=20CRX=20packa?= =?UTF-8?q?ge"=20is=20visible.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/src/conversion.rs | 82 ++++++++++++++------- app/src/agent_runner.rs | 119 +++++++++++++++++++++++++++++-- app/src/components/agent_flow.rs | 10 +++ 3 files changed, 180 insertions(+), 31 deletions(-) diff --git a/agent/src/conversion.rs b/agent/src/conversion.rs index c1631284..5cd768c6 100644 --- a/agent/src/conversion.rs +++ b/agent/src/conversion.rs @@ -73,7 +73,12 @@ language and see the rendered pages. Steps:\n\ a. Read each state with get_flattened_structure_for_state (every language × every configurator \ selection, e.g. EN/Private-Person, DE/Company) plus its page image. The XFA is the authority for \ verbatim text in each language; the images are the authority for layout and section order.\n\ - b. Build the whole tree in one set_aem_translated call: lay out the sections in source order; \ + b. Author the tree. A small form may be passed whole in one set_aem_translated call, but for a \ +large form (many sections/fields, repeatable sections, or several languages) do NOT emit it all at \ +once — a single tool call whose output exceeds the per-turn limit is cut off and discarded in full. \ +Instead set a skeleton first with set_aem_translated (the root plus the top-level panels/sections and \ +their titles), then add each section's fields with insert_aem_translated_node, building the tree up in \ +small calls. Lay out the sections in source order; \ for every text field include EVERY source language (pair translations by meaning and layout \ position — never leave a language blank or collapse to one); give each fillable field the right \ component type, options (real labels AND values), required/visible state and column width; nest \ @@ -485,16 +490,37 @@ impl ConversionAgent { /// If a run finishes right after an edit, `self.package` is `None` and the UI /// has nothing to offer for download. Call this at the end of a run: when a /// working AEM tree exists but no package is current, build one from the - /// latest tree. Returns the resulting package, if any. - pub fn ensure_package(&mut self) -> Option> { + /// latest tree. + /// + /// Returns `Ok(None)` when no tree has been authored yet (nothing to build), + /// `Ok(Some(pkg))` for the current/just-built package, and `Err` when a tree + /// exists but packaging failed — so the caller can surface *why* there is no + /// download instead of silently showing none. + pub fn ensure_package(&mut self) -> Result>, String> { if self.package.is_none() && self.aem_translated.is_some() { - let cfg = self.config().ok()?; - let (aem, translations) = self.lower_aem_translated().ok()?; - let pkg = - blueprint::to_aem_package_from_node_with_translations(&aem, &cfg, translations); + let pkg = self.build_package()?; self.package = Some(pkg); } - self.package.clone() + Ok(self.package.clone()) + } + + /// Build the AEM package from the current translated tree, isolating any + /// panic in the package writer (it relies on many internal + /// `.unwrap()`/`.expect()` calls) so a build failure becomes a recoverable + /// error instead of aborting the whole conversion task. Assumes a tree + /// exists; callers check `aem_translated` first. + fn build_package(&mut self) -> Result, String> { + let cfg = self.config()?; + let (aem, translations) = self.lower_aem_translated()?; + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + blueprint::to_aem_package_from_node_with_translations(&aem, &cfg, translations) + })) + .map_err(|_| { + "The AEM package writer failed unexpectedly while building the \ + package. The tree may contain an unsupported shape — inspect it \ + with get_aem_translated_outline and simplify the offending node." + .to_string() + }) } /// The resolved form code, if the AEM config has been loaded. @@ -687,7 +713,7 @@ impl ConversionAgent { // §2 multilingual AEM tree (AemNodeTranslated) — authored directly. t( "set_aem_translated", - "Set the WHOLE working AEM tree as an AemNodeTranslated JSON object (call get_schema('aem_translated') for the exact shape). Use this for the initial authoring of the form; for small fixes afterwards use the targeted editors below. Text fields (title/label/content and option labels) are per-language maps like {\"de\":\"…\",\"en\":\"…\"}; include EVERY source language. Invalidates the package.", + "Set the WHOLE working AEM tree as an AemNodeTranslated JSON object (call get_schema('aem_translated') for the exact shape). Use this for initial authoring; for a large form set only a skeleton here (root + top-level panels/sections with titles) and add each section's fields with insert_aem_translated_node, since a single call whose output exceeds the per-turn limit is cut off and discarded in full. For small fixes afterwards use the targeted editors below. Text fields (title/label/content and option labels) are per-language maps like {\"de\":\"…\",\"en\":\"…\"}; include EVERY source language. Invalidates the package.", serde_json::json!({"root": {"type":"object"}}), serde_json::json!(["root"]), ), @@ -1100,21 +1126,14 @@ impl ConversionAgent { } // §5 output - "build_aem_package" => { - let cfg = match self.config() { - Ok(c) => c, - Err(e) => return ToolReply::Error(e), - }; - let (aem, translations) = match self.lower_aem_translated() { - Ok(pair) => pair, - Err(e) => return ToolReply::Error(e), - }; - let pkg = - blueprint::to_aem_package_from_node_with_translations(&aem, &cfg, translations); - let size = pkg.len(); - self.package = Some(pkg); - ToolReply::Text(format!("Built package ({size} bytes).")) - } + "build_aem_package" => match self.build_package() { + Ok(pkg) => { + let size = pkg.len(); + self.package = Some(pkg); + ToolReply::Text(format!("Built package ({size} bytes).")) + } + Err(e) => ToolReply::Error(e), + }, "get_package_info" => match &self.package { Some(pkg) => { let files = crate::references::unzip_package(pkg).unwrap_or_default(); @@ -1489,6 +1508,21 @@ mod tests { ); } + #[test] + fn ensure_package_without_tree_is_ok_none() { + // No AEM tree authored yet → nothing to build, and crucially NOT an + // error: ensure_package distinguishes "no tree" (Ok(None)) from "a tree + // exists but packaging failed" (Err) so finalize can tell the user why + // there is no download instead of silently showing none. + let mut agent = ConversionAgent::new( + Some("ubs".into()), + Vec::new(), + None, + "test-ensure-package".into(), + ); + assert!(matches!(agent.ensure_package(), Ok(None))); + } + #[test] fn form_path_trims_slashes() { assert_eq!( diff --git a/app/src/agent_runner.rs b/app/src/agent_runner.rs index f3e9879f..62de650d 100644 --- a/app/src/agent_runner.rs +++ b/app/src/agent_runner.rs @@ -14,13 +14,37 @@ use blueprint::{DocumentEnvelope, StructuredNode}; use crate::models::{AgentStep, AgentStepKind, AgentStepStatus, ProcessingState, ProcessingStep}; use crate::platform::tool_result_message; -/// Output-token cap per agent turn. -const AGENT_MAX_TOKENS: u32 = 16000; +/// Output-token cap per agent turn. Sized generously so authoring turns — which +/// emit large `set_aem_translated` / `insert_aem_translated_node` payloads — are +/// far less likely to be truncated mid-tool-call. A turn that still overflows is +/// handled explicitly (see the `max_tokens` branch in [`drive_agent`]) rather +/// than silently dropping the (incomplete) tool call. +const AGENT_MAX_TOKENS: u32 = 32000; /// Max streamed turns before the loop bails out (the agent makes many calls). const MAX_ITERATIONS: usize = 200; /// How many consecutive `validate_aem_package` calls with identical output /// are allowed before the loop gives up and finalizes with what's built. const MAX_VALIDATE_REPEATS: usize = 3; +/// How many consecutive output-token-truncated turns are tolerated before the +/// loop stops nudging and finalizes with whatever tree exists. Bounds the cost +/// of a model that keeps re-attempting one oversized call instead of chunking. +const MAX_TRUNCATE_REPEATS: usize = 3; + +/// Shown in the activity log when a turn is cut off at the output-token cap. +const TRUNCATED_TURN_NOTICE: &str = "⚠ The model's response hit the per-turn \ +output limit and was cut off — asking it to author the tree in smaller steps."; + +/// Fed back to the model after a truncated turn to steer it off one-shot +/// authoring (the usual cause of truncation) and toward incremental edits. +const TRUNCATED_TURN_GUIDANCE: &str = "Your previous response was cut off at the \ +output-token limit before it finished, so any tool call it contained was \ +incomplete and was NOT applied. Do not emit the whole AEM tree in a single \ +set_aem_translated call. Author it incrementally instead: first set a skeleton \ +with set_aem_translated (the root plus the top-level panels/sections and their \ +titles), then add each section's fields with insert_aem_translated_node, and \ +refine with the granular editors (set_aem_translated_field / \ +replace_aem_translated_node). Keep every individual tool call small enough to \ +fit within the output limit."; /// Run the autonomous agent end-to-end, streaming activity into /// `processing_state.agent_steps` and finalizing the result on completion. @@ -150,6 +174,8 @@ async fn drive_agent( // called validate_aem_package and returned the same output. let mut last_validate_output: Option = None; let mut validate_repeat_count: usize = 0; + // Track consecutive turns truncated at the output-token cap (see below). + let mut truncate_repeat_count: usize = 0; for _ in 0..MAX_ITERATIONS { let turn = @@ -164,8 +190,13 @@ async fn drive_agent( { Ok(t) => t, Err(e) => { + // Record the error but fall through to finalize instead of + // bailing out here: the agent may already have authored a + // working tree, and finalize/ensure_package will still build + // a downloadable package from it. (A truly empty run + // finalizes with no package and this error shown.) processing_state.write().error = Some(format!("Agent failed: {e}")); - return; + break; } }; @@ -182,6 +213,55 @@ async fn drive_agent( ); } + // The turn was cut off at the per-turn output-token cap — almost always + // the model trying to emit the entire AEM tree in one giant + // set_aem_translated call. Anthropic truncates mid-tool-input, so the + // partial JSON does not parse and the tool call is dropped (never + // executed). Breaking here would finalize with no tree and therefore no + // downloadable package, so instead steer the model to author the tree + // incrementally and let the loop continue so it can recover. + if turn.stop_reason.as_deref() == Some("max_tokens") { + truncate_repeat_count += 1; + push_step( + &mut processing_state, + AgentStep { + id: String::new(), + kind: AgentStepKind::Thought, + label: TRUNCATED_TURN_NOTICE.into(), + detail: String::new(), + status: AgentStepStatus::Done, + }, + ); + if truncate_repeat_count >= MAX_TRUNCATE_REPEATS { + processing_state.write().warnings.push( + "The model repeatedly exceeded the output limit while authoring \ + the tree — building what's available. The result may be \ + incomplete and need manual follow-up." + .into(), + ); + break; + } + // The assistant message (with any partial tool_use blocks) was + // already appended to history; the API requires a tool_result for + // every tool_use before the next turn, so answer them all with the + // recovery guidance. With no tool calls, nudge via a user message. + if turn.tool_calls.is_empty() { + history.push(serde_json::json!({ + "role": "user", + "content": [{"type": "text", "text": TRUNCATED_TURN_GUIDANCE}], + })); + } else { + let results: Vec<(String, ToolReply)> = turn + .tool_calls + .iter() + .map(|tc| (tc.id.clone(), ToolReply::Error(TRUNCATED_TURN_GUIDANCE.into()))) + .collect(); + history.push(tool_result_message(results)); + } + continue; + } + truncate_repeat_count = 0; + if turn.stop_reason.as_deref() != Some("tool_use") || turn.tool_calls.is_empty() { break; } @@ -306,21 +386,46 @@ fn finalize( }; let merged_json = serde_json::to_string_pretty(&envelope).ok(); let form_code = agent.form_code(); + // Guarantee a downloadable package: the agent may have finished right after + // a tree edit (which invalidates the package) without a final rebuild, so + // build one from the latest tree. Computed before taking the UI lock; the + // build itself is panic-isolated inside the agent (see `build_package`). + // Ok(None) = no tree authored; Err = a tree exists but packaging failed. + let package_result = agent.ensure_package(); let mut state = processing_state.write(); state.step = ProcessingStep::Complete; state.ai_mode = true; state.envelope = Some(envelope); state.merged_json = merged_json; - // Guarantee a downloadable package: the agent may have finished right after - // a tree edit (which invalidates the package) without a final rebuild. Build - // one from the latest tree so the UI always offers a download. - state.aem_package = agent.ensure_package(); + match package_result { + Ok(pkg) => state.aem_package = pkg, + Err(e) => { + state.aem_package = None; + // Surface the real packaging failure rather than a vague "no + // download". Don't clobber an API error already recorded above. + if state.error.is_none() { + state.error = Some(format!("Could not build the AEM package: {e}")); + } + } + } state.form_code = form_code; state.agent_aem_session = agent.aem_session(); state.aem_uploaded = agent.aem_uploaded(); state.aem_form_path = agent.aem_form_path(); state.elapsed_secs = Some(start.elapsed().as_secs()); + // The run is Complete but there is nothing to download and no error to + // explain it — never leave the user staring at a result screen with no + // package and no reason. This means the tree was never authored (e.g. the + // run was cut short), so say so. + if state.aem_package.is_none() && state.error.is_none() { + state.warnings.push( + "The conversion finished without a downloadable package — the AEM tree \ + was never completed (the run may have been cut short). Re-run the \ + conversion, or use the feedback box to ask the agent to finish the tree." + .into(), + ); + } drop(state); let _ = profile; diff --git a/app/src/components/agent_flow.rs b/app/src/components/agent_flow.rs index 94819003..bf2d6d04 100644 --- a/app/src/components/agent_flow.rs +++ b/app/src/components/agent_flow.rs @@ -514,6 +514,16 @@ fn RunBox( } } } + if !state.warnings.is_empty() { + div { class: "progress-warnings", + strong { "Warnings:" } + ul { + for warning in state.warnings.iter() { + li { "{warning}" } + } + } + } + } if let Some(error) = &state.error { div { class: "progress-error", strong { "Error: " } From 23a246b9050cb097ab65707c2bc4ca4984bc723c Mon Sep 17 00:00:00 2001 From: Lukas Mathis Date: Tue, 7 Jul 2026 10:23:25 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Fixed=20another=20problem=20where,=20after?= =?UTF-8?q?=20conversion,=20the=20app=20would=20sometimes=20not=20end=20up?= =?UTF-8?q?=20in=20a=20state=20where=20"=E2=AC=87=20Download=20CRX=20packa?= =?UTF-8?q?ge"=20is=20visible.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- agent/src/conversion.rs | 5 + app/src/agent_runner.rs | 571 ++++++++++++++++++++++++++++------------ app/src/platform.rs | 60 +++-- app/src/settings.rs | 8 +- 5 files changed, 460 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index 8eb3a513..d2be2a70 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ The app is built with [Dioxus](https://dioxuslabs.com/) and targets the desktop. It bundles an AI conversion agent that drives the engine's tools turn by turn to convert a form interactively. The agent uses the Anthropic API — set the API key and model (default `claude-opus-4-8`) in the app's settings. Every tree change is versioned into a local edit-history SQLite database, so conversions can be reviewed and resumed. -### Development +### Developmentt ```sh cd app diff --git a/agent/src/conversion.rs b/agent/src/conversion.rs index 5cd768c6..a763a1c1 100644 --- a/agent/src/conversion.rs +++ b/agent/src/conversion.rs @@ -483,6 +483,11 @@ impl ConversionAgent { self.package.clone() } + /// `true` once a working AEM (translated) tree has been authored. + pub fn has_aem_tree(&self) -> bool { + self.aem_translated.is_some() + } + /// Guarantee a downloadable package when one can be built. /// /// Every tree edit invalidates the package (see [`Self::aem_translated_edited`]), diff --git a/app/src/agent_runner.rs b/app/src/agent_runner.rs index 62de650d..5ca87b31 100644 --- a/app/src/agent_runner.rs +++ b/app/src/agent_runner.rs @@ -29,6 +29,21 @@ const MAX_VALIDATE_REPEATS: usize = 3; /// loop stops nudging and finalizes with whatever tree exists. Bounds the cost /// of a model that keeps re-attempting one oversized call instead of chunking. const MAX_TRUNCATE_REPEATS: usize = 3; +/// Extra "continue" rounds the app grants itself when the agent stops without +/// calling `finish` — the model quit with a plain-text turn, or the turn budget +/// ran out (e.g. after over-long exploration). Each round re-prompts the agent +/// to complete the remaining work with a fresh, smaller turn budget. +const MAX_CONTINUATION_ROUNDS: usize = 2; +/// Turn budget for each continuation round. Smaller than [`MAX_ITERATIONS`]: +/// a continuation should take stock and finish, not restart exploration. +const CONTINUATION_MAX_ITERATIONS: usize = 80; +/// How many times a failed API turn is retried (with backoff) before the run +/// gives up. A single transient 429/5xx/network hiccup must not end a long run. +const TURN_RETRIES: usize = 2; + +/// Shown in the activity log when the app auto-continues a stopped run. +const CONTINUATION_NOTICE: &str = "⚠ The agent stopped before finishing — \ +asking it to complete the remaining work."; /// Shown in the activity log when a turn is cut off at the output-token cap. const TRUNCATED_TURN_NOTICE: &str = "⚠ The model's response hit the per-turn \ @@ -154,9 +169,27 @@ Then call finish.", .await; } +/// How one round of the agent loop ended (see [`drive_agent`]). +enum RoundEnd { + /// Terminal: the agent called `finish`, or a recovery path already + /// prepared the best available result (repeated truncation, stuck + /// validation, persistent API failure). + Done, + /// The model stopped requesting tools without calling `finish`. + StoppedEarly, + /// The round's turn budget ran out before the agent called `finish`. + OutOfTurns, +} + /// Drive the agent loop to completion: stream turns, execute tools, version /// each step, and finalize the result. Shared by [`run_agent`] and /// [`run_agent_feedback`]. +/// +/// Runs in rounds: the main round has [`MAX_ITERATIONS`] turns; if the agent +/// stops without calling `finish` (plain-text stop or budget exhausted), up to +/// [`MAX_CONTINUATION_ROUNDS`] smaller rounds re-prompt it to complete the +/// remaining work, so a stalled run self-continues instead of finalizing a +/// half-done (or never-started) tree. #[allow(clippy::too_many_arguments)] async fn drive_agent( mut agent: ConversionAgent, @@ -171,192 +204,265 @@ async fn drive_agent( let tools = agent.tools(); // Escape hatch for a stuck validate loop: track how many consecutive turns - // called validate_aem_package and returned the same output. + // called validate_aem_package and returned the same output. (All repeat + // counters persist across continuation rounds.) let mut last_validate_output: Option = None; let mut validate_repeat_count: usize = 0; // Track consecutive turns truncated at the output-token cap (see below). let mut truncate_repeat_count: usize = 0; + // Continuation rounds already granted (see RoundEnd handling below). + let mut continuation_rounds: usize = 0; + + loop { + // The first round gets the full budget; continuations get a smaller one + // — they are meant to take stock and finish, not restart exploration. + let budget = if continuation_rounds == 0 { + MAX_ITERATIONS + } else { + CONTINUATION_MAX_ITERATIONS + }; + + let end = 'round: { + for _ in 0..budget { + let turn = match stream_turn_with_retry( + &mut history, + &tools, + &settings, + &mut processing_state, + ) + .await + { + Ok(t) => t, + Err(e) => { + // Persistent failure even after retries. Record the + // error but still fall through to finalize: the agent + // may already have authored a working tree, and + // ensure_package will build a downloadable package + // from it. (A truly empty run finalizes with no + // package and this error shown.) + processing_state.write().error = Some(format!("Agent failed: {e}")); + break 'round RoundEnd::Done; + } + }; - for _ in 0..MAX_ITERATIONS { - let turn = - match crate::platform::anthropic_stream_turn( - &mut history, - &tools, - &settings.anthropic_api_key, - &settings.anthropic_model, - AGENT_MAX_TOKENS, - ) - .await - { - Ok(t) => t, - Err(e) => { - // Record the error but fall through to finalize instead of - // bailing out here: the agent may already have authored a - // working tree, and finalize/ensure_package will still build - // a downloadable package from it. (A truly empty run - // finalizes with no package and this error shown.) - processing_state.write().error = Some(format!("Agent failed: {e}")); - break; + if !turn.text.trim().is_empty() { + push_step( + &mut processing_state, + AgentStep { + id: String::new(), + kind: AgentStepKind::Thought, + label: turn.text.trim().to_string(), + detail: String::new(), + status: AgentStepStatus::Done, + }, + ); } - }; - if !turn.text.trim().is_empty() { - push_step( - &mut processing_state, - AgentStep { - id: String::new(), - kind: AgentStepKind::Thought, - label: turn.text.trim().to_string(), - detail: String::new(), - status: AgentStepStatus::Done, - }, - ); - } + // The turn was cut off at the per-turn output-token cap — + // almost always the model trying to emit the entire AEM tree in + // one giant set_aem_translated call. Anthropic truncates + // mid-tool-input, so the partial JSON does not parse and the + // tool call is dropped (never executed). Breaking here would + // finalize with no tree and therefore no downloadable package, + // so instead steer the model to author the tree incrementally + // and let the loop continue so it can recover. + if turn.stop_reason.as_deref() == Some("max_tokens") { + truncate_repeat_count += 1; + push_step( + &mut processing_state, + AgentStep { + id: String::new(), + kind: AgentStepKind::Thought, + label: TRUNCATED_TURN_NOTICE.into(), + detail: String::new(), + status: AgentStepStatus::Done, + }, + ); + if truncate_repeat_count >= MAX_TRUNCATE_REPEATS { + processing_state.write().warnings.push( + "The model repeatedly exceeded the output limit while authoring \ + the tree — building what's available. The result may be \ + incomplete and need manual follow-up." + .into(), + ); + break 'round RoundEnd::Done; + } + // The assistant message (with any partial tool_use blocks) + // was already appended to history; the API requires a + // tool_result for every tool_use before the next turn, so + // answer them all with the recovery guidance. With no tool + // calls, nudge via a user message. + if turn.tool_calls.is_empty() { + push_user_text(&mut history, TRUNCATED_TURN_GUIDANCE); + } else { + let results: Vec<(String, ToolReply)> = turn + .tool_calls + .iter() + .map(|tc| { + (tc.id.clone(), ToolReply::Error(TRUNCATED_TURN_GUIDANCE.into())) + }) + .collect(); + history.push(tool_result_message(results)); + } + continue; + } + truncate_repeat_count = 0; + + if turn.stop_reason.as_deref() != Some("tool_use") || turn.tool_calls.is_empty() { + // The turn may still carry tool_use blocks (a "refusal" + // stop, or a stream that ended cleanly mid-message and left + // stop_reason unset). They are already in history, and the + // API requires every tool_use to be answered by a + // tool_result in the next message — a continuation round + // would otherwise 400 on every subsequent call. Answer them + // without executing (the turn never committed to them; the + // inputs may be truncated). + if !turn.tool_calls.is_empty() { + let results: Vec<(String, ToolReply)> = turn + .tool_calls + .iter() + .map(|tc| { + ( + tc.id.clone(), + ToolReply::Error( + "Not executed — the turn ended before completing. \ + Re-issue this call if it is still needed." + .into(), + ), + ) + }) + .collect(); + history.push(tool_result_message(results)); + } + break 'round RoundEnd::StoppedEarly; + } - // The turn was cut off at the per-turn output-token cap — almost always - // the model trying to emit the entire AEM tree in one giant - // set_aem_translated call. Anthropic truncates mid-tool-input, so the - // partial JSON does not parse and the tool call is dropped (never - // executed). Breaking here would finalize with no tree and therefore no - // downloadable package, so instead steer the model to author the tree - // incrementally and let the loop continue so it can recover. - if turn.stop_reason.as_deref() == Some("max_tokens") { - truncate_repeat_count += 1; - push_step( - &mut processing_state, - AgentStep { - id: String::new(), - kind: AgentStepKind::Thought, - label: TRUNCATED_TURN_NOTICE.into(), - detail: String::new(), - status: AgentStepStatus::Done, - }, - ); - if truncate_repeat_count >= MAX_TRUNCATE_REPEATS { - processing_state.write().warnings.push( - "The model repeatedly exceeded the output limit while authoring \ - the tree — building what's available. The result may be \ - incomplete and need manual follow-up." - .into(), - ); - break; - } - // The assistant message (with any partial tool_use blocks) was - // already appended to history; the API requires a tool_result for - // every tool_use before the next turn, so answer them all with the - // recovery guidance. With no tool calls, nudge via a user message. - if turn.tool_calls.is_empty() { - history.push(serde_json::json!({ - "role": "user", - "content": [{"type": "text", "text": TRUNCATED_TURN_GUIDANCE}], - })); - } else { - let results: Vec<(String, ToolReply)> = turn - .tool_calls - .iter() - .map(|tc| (tc.id.clone(), ToolReply::Error(TRUNCATED_TURN_GUIDANCE.into()))) - .collect(); + let mut results: Vec<(String, ToolReply)> = Vec::new(); + let mut stuck = false; + for tc in &turn.tool_calls { + push_step( + &mut processing_state, + AgentStep { + id: tc.id.clone(), + kind: AgentStepKind::Tool, + label: tc.name.clone(), + detail: summarize_input(&tc.input), + status: AgentStepStatus::Running, + }, + ); + let reply = agent.execute(&tc.name, &tc.input).await; + let ok = !matches!(reply, ToolReply::Error(_)); + set_step_status( + &mut processing_state, + &tc.id, + if ok { + AgentStepStatus::Done + } else { + AgentStepStatus::Error + }, + ); + + // Detect a stuck validate loop: same output N times in a row. + if tc.name == "validate_aem_package" { + let output = match &reply { + ToolReply::Text(s) => s.clone(), + ToolReply::Error(s) => format!("error:{s}"), + ToolReply::Image { .. } => "image".into(), + }; + if last_validate_output.as_deref() == Some(&output) { + validate_repeat_count += 1; + if validate_repeat_count >= MAX_VALIDATE_REPEATS { + stuck = true; + } + } else { + last_validate_output = Some(output); + validate_repeat_count = 1; + } + } else { + // Any other tool means the agent is making progress; + // reset the counter. + validate_repeat_count = 0; + last_validate_output = None; + } + + results.push((tc.id.clone(), reply)); + } history.push(tool_result_message(results)); - } - continue; - } - truncate_repeat_count = 0; - if turn.stop_reason.as_deref() != Some("tool_use") || turn.tool_calls.is_empty() { - break; - } + if stuck { + processing_state.write().warnings.push( + "Validation produced the same result 3 times in a row — building what's available. \ + Some issues (e.g. missing fragment paths) may require manual follow-up." + .into(), + ); + + // Ensure the package reflects the latest AEM tree, then upload. + for (id, name, detail) in [ + ("recovery-build", "build_aem_package", "recovery"), + ("recovery-upload", "upload_to_aem", "recovery"), + ] { + push_step( + &mut processing_state, + AgentStep { + id: id.into(), + kind: AgentStepKind::Tool, + label: name.into(), + detail: detail.into(), + status: AgentStepStatus::Running, + }, + ); + let reply = agent.execute(name, &serde_json::json!({})).await; + let ok = !matches!(reply, ToolReply::Error(_)); + set_step_status( + &mut processing_state, + id, + if ok { AgentStepStatus::Done } else { AgentStepStatus::Error }, + ); + // Don't attempt upload if build failed. + if name == "build_aem_package" && !ok { + break; + } + } - let mut results: Vec<(String, ToolReply)> = Vec::new(); - let mut stuck = false; - for tc in &turn.tool_calls { - push_step( - &mut processing_state, - AgentStep { - id: tc.id.clone(), - kind: AgentStepKind::Tool, - label: tc.name.clone(), - detail: summarize_input(&tc.input), - status: AgentStepStatus::Running, - }, - ); - let reply = agent.execute(&tc.name, &tc.input).await; - let ok = !matches!(reply, ToolReply::Error(_)); - set_step_status( - &mut processing_state, - &tc.id, - if ok { - AgentStepStatus::Done - } else { - AgentStepStatus::Error - }, - ); + break 'round RoundEnd::Done; + } - // Detect a stuck validate loop: same output N times in a row. - if tc.name == "validate_aem_package" { - let output = match &reply { - ToolReply::Text(s) => s.clone(), - ToolReply::Error(s) => format!("error:{s}"), - ToolReply::Image { .. } => "image".into(), - }; - if last_validate_output.as_deref() == Some(&output) { - validate_repeat_count += 1; - if validate_repeat_count >= MAX_VALIDATE_REPEATS { - stuck = true; - } - } else { - last_validate_output = Some(output); - validate_repeat_count = 1; + if agent.is_finished() { + break 'round RoundEnd::Done; } - } else { - // Any other tool means the agent is making progress; reset counter. - validate_repeat_count = 0; - last_validate_output = None; } - - results.push((tc.id.clone(), reply)); - } - history.push(tool_result_message(results)); - - if stuck { - processing_state.write().warnings.push( - "Validation produced the same result 3 times in a row — building what's available. \ - Some issues (e.g. missing fragment paths) may require manual follow-up." - .into(), - ); - - // Ensure the package reflects the latest AEM tree, then upload. - for (id, name, detail) in [ - ("recovery-build", "build_aem_package", "recovery"), - ("recovery-upload", "upload_to_aem", "recovery"), - ] { + RoundEnd::OutOfTurns + }; + + match end { + RoundEnd::Done => break, + // The agent stopped without calling finish — either the model quit + // with a plain-text turn or the round's turn budget ran out. Rather + // than finalizing a half-done (or never-started) tree, grant a + // bounded number of continuation rounds that re-prompt the agent to + // take stock and complete the remaining work. + RoundEnd::StoppedEarly | RoundEnd::OutOfTurns => { + if continuation_rounds >= MAX_CONTINUATION_ROUNDS { + processing_state.write().warnings.push( + "The agent did not finish within its turn budget, even after \ + being asked to continue — finalizing with what was built." + .into(), + ); + break; + } + continuation_rounds += 1; push_step( &mut processing_state, AgentStep { - id: id.into(), - kind: AgentStepKind::Tool, - label: name.into(), - detail: detail.into(), - status: AgentStepStatus::Running, + id: String::new(), + kind: AgentStepKind::Thought, + label: CONTINUATION_NOTICE.into(), + detail: String::new(), + status: AgentStepStatus::Done, }, ); - let reply = agent.execute(name, &serde_json::json!({})).await; - let ok = !matches!(reply, ToolReply::Error(_)); - set_step_status( - &mut processing_state, - id, - if ok { AgentStepStatus::Done } else { AgentStepStatus::Error }, - ); - // Don't attempt upload if build failed. - if name == "build_aem_package" && !ok { - break; - } + push_user_text(&mut history, &continuation_prompt(&agent)); } - - break; - } - - if agent.is_finished() { - break; } } @@ -432,6 +538,105 @@ fn finalize( current_session.set(Some(structured_session)); } +// ── Loop helpers ───────────────────────────────────────────────────────────── + +/// Run one streamed turn, retrying transient API failures with backoff so a +/// single 429/5xx/network hiccup does not end a long run. Configuration errors +/// (missing/invalid API key) fail immediately — retrying cannot fix them. +/// +/// Safe to retry: [`crate::platform::anthropic_stream_turn`] only appends the +/// assistant message to `history` on success, so a failed attempt leaves the +/// conversation unchanged. +async fn stream_turn_with_retry( + history: &mut Vec, + tools: &[serde_json::Value], + settings: &crate::settings::AppSettings, + processing_state: &mut Signal, +) -> Result { + let mut last_err = String::new(); + for attempt in 0..=TURN_RETRIES { + if attempt > 0 { + push_step( + processing_state, + AgentStep { + id: String::new(), + kind: AgentStepKind::Thought, + label: format!( + "⚠ API error — retrying (attempt {attempt}/{TURN_RETRIES}): {last_err}" + ), + detail: String::new(), + status: AgentStepStatus::Done, + }, + ); + tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await; + } + match crate::platform::anthropic_stream_turn( + history, + tools, + &settings.anthropic_api_key, + &settings.anthropic_model, + AGENT_MAX_TOKENS, + ) + .await + { + Ok(t) => return Ok(t), + Err(e) => { + // Configuration/auth problems are not transient; surface them + // right away instead of stalling through pointless retries. + if e.contains("not configured") + || e.contains("(401") + || e.contains("(403") + || e.contains("authentication") + { + return Err(e); + } + last_err = e; + } + } + } + Err(last_err) +} + +/// Append `text` to the conversation as user content: onto the trailing user +/// message when there is one (the API requires tool_result blocks to lead a +/// message, so the text goes after them), otherwise as a new user message. +fn push_user_text(history: &mut Vec, text: &str) { + if let Some(last) = history.last_mut() + && last["role"] == "user" + && let Some(blocks) = last.get_mut("content").and_then(|c| c.as_array_mut()) + { + blocks.push(serde_json::json!({"type": "text", "text": text})); + return; + } + history.push(serde_json::json!({ + "role": "user", + "content": [{"type": "text", "text": text}], + })); +} + +/// The re-prompt fed to the agent when it stops without calling `finish`. +/// States what already exists so the agent takes stock and completes the work +/// instead of restarting exploration (the usual way a run exhausts its turns). +fn continuation_prompt(agent: &ConversionAgent) -> String { + let state = if agent.package().is_some() { + "A package has already been built from your working AEM tree." + } else if agent.has_aem_tree() { + "A working AEM tree exists, but no current package has been built from it." + } else { + "No working AEM tree has been authored yet." + }; + format!( + "You stopped before calling finish. {state} Complete the remaining work \ + now, efficiently. Do NOT restart exploration or re-read source states \ + you have already inspected; if a tool output you need was elided from \ + earlier turns, re-fetch it once and use it immediately. Take stock with \ + get_aem_translated_outline if a tree exists. Author whatever is missing \ + — for a large form set a skeleton with set_aem_translated and add each \ + section with insert_aem_translated_node — then build_aem_package, \ + validate_aem_package, and call finish." + ) +} + // ── UI step helpers ────────────────────────────────────────────────────────── fn push_step(processing_state: &mut Signal, step: AgentStep) { @@ -471,4 +676,38 @@ mod tests { let long = serde_json::json!({"q": "x".repeat(500)}); assert!(summarize_input(&long).chars().count() <= 121); } + + #[test] + fn push_user_text_appends_to_trailing_user_message() { + // Trailing user message (tool results): the text is appended after + // them, keeping tool_result blocks first as the API requires. + let mut h = vec![serde_json::json!({"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "tu1", + "content": [{"type": "text", "text": "ok"}]}, + ]})]; + push_user_text(&mut h, "continue"); + assert_eq!(h.len(), 1); + assert_eq!(h[0]["content"][0]["type"], "tool_result"); + assert_eq!(h[0]["content"][1]["type"], "text"); + assert_eq!(h[0]["content"][1]["text"], "continue"); + } + + #[test] + fn push_user_text_pushes_after_assistant_message() { + let mut h = vec![serde_json::json!({"role": "assistant", "content": [ + {"type": "text", "text": "hi"}, + ]})]; + push_user_text(&mut h, "continue"); + assert_eq!(h.len(), 2); + assert_eq!(h[1]["role"], "user"); + assert_eq!(h[1]["content"][0]["text"], "continue"); + } + + #[test] + fn continuation_prompt_reflects_missing_tree() { + let agent = ConversionAgent::new(None, Vec::new(), None, "test-continuation".into()); + let p = continuation_prompt(&agent); + assert!(p.contains("No working AEM tree has been authored yet")); + assert!(p.contains("call finish")); + } } diff --git a/app/src/platform.rs b/app/src/platform.rs index 2031992f..4d8cd586 100644 --- a/app/src/platform.rs +++ b/app/src/platform.rs @@ -270,7 +270,13 @@ use std::sync::atomic::{AtomicUsize, Ordering}; /// Default trailing messages kept verbatim by [`evict_stale_history`]. Even, so /// whole assistant+`tool_result` turn-pairs survive (the latest data stays /// intact). Overridable at runtime via [`configure_eviction`]. -pub const DEFAULT_KEEP_RECENT_MESSAGES: usize = 4; +/// +/// Sized so the conversion agent can hold one full working set at once — e.g. +/// both language variants of `get_flattened_structure_for_state` plus a page +/// image or two (4 turn-pairs). With only 2 turn-pairs protected, the agent's +/// reference data was elided before it could use it, sending it into +/// re-fetch loops that exhausted the turn budget without ever authoring. +pub const DEFAULT_KEEP_RECENT_MESSAGES: usize = 8; /// Default: tool-result text longer than this (chars) is elided once stale. pub const DEFAULT_ELIDE_TEXT_OVER_CHARS: usize = 2000; /// Default: `tool_use` input longer than this (chars) is elided once stale. @@ -709,10 +715,16 @@ pub async fn anthropic_stream_turn( input, }); } - history.push(serde_json::json!({ - "role": "assistant", - "content": assistant_content, - })); + // Never append an all-empty assistant message: the Messages API rejects + // empty content on any non-final assistant message, so a fully empty turn + // (no text, no tool blocks — e.g. a stream that ended before any content + // event) would poison the history for every later call in the run. + if !assistant_content.is_empty() { + history.push(serde_json::json!({ + "role": "assistant", + "content": assistant_content, + })); + } Ok(TurnOutput { text: response_text, @@ -843,7 +855,8 @@ mod tests { } /// History with stale heavy content (big image, big text, big tool input) in - /// old turns and a small recent turn-pair. Total exceeds the size gate. + /// old turns and small recent turn-pairs (enough of them to fill the + /// DEFAULT_KEEP_RECENT_MESSAGES tail). Total exceeds the size gate. fn big_history() -> Vec { let big_input = json!({"tree": "X".repeat(3000)}); vec![ @@ -854,8 +867,12 @@ mod tests { result_text("tu2", &"x".repeat(5000)), // 4 evict assistant_tool_use("tu3", "get_structured", json!({})), // 5 recent result_text("tu3", "small recent result"), // 6 recent - assistant_tool_use("tu4", "finish", json!({})), // 7 recent - result_text("tu4", "done"), // 8 recent + assistant_tool_use("tu4", "get_source_info", json!({})), // 7 recent + result_text("tu4", "info"), // 8 recent + assistant_tool_use("tu5", "list_states", json!({})), // 9 recent + result_text("tu5", "states"), // 10 recent + assistant_tool_use("tu6", "finish", json!({})), // 11 recent + result_text("tu6", "done"), // 12 recent ] } @@ -903,8 +920,8 @@ mod tests { .count() }; // No blocks deleted: every tool_use still has its tool_result. - assert_eq!(count("assistant", "tool_use"), 4); - assert_eq!(count("user", "tool_result"), 4); + assert_eq!(count("assistant", "tool_use"), 6); + assert_eq!(count("user", "tool_result"), 6); } #[test] @@ -918,17 +935,24 @@ mod tests { #[test] fn size_gated_below_threshold() { - // A small history (well under EVICT_TRIGGER_BYTES) is left untouched even - // though it contains an over-threshold text block. + // A small history (well under EVICT_TRIGGER_BYTES) is left untouched + // even though it contains an over-threshold text block OUTSIDE the + // protected tail — long enough (> 1 + keep_recent messages) that only + // the byte gate, not tail protection, is what spares it. let original = vec![ - user_text("SYSTEM"), - assistant_tool_use("tu1", "get_xfa", json!({})), - result_text("tu1", &"x".repeat(DEFAULT_ELIDE_TEXT_OVER_CHARS + 100)), - assistant_tool_use("tu2", "get_structured", json!({})), + user_text("SYSTEM"), // 0 protected + assistant_tool_use("tu1", "get_xfa", json!({})), // 1 evictable + result_text("tu1", &"x".repeat(DEFAULT_ELIDE_TEXT_OVER_CHARS + 100)), // 2 evictable + assistant_tool_use("tu2", "get_structured", json!({})), // 3 recent result_text("tu2", "recent"), - assistant_tool_use("tu3", "finish", json!({})), - result_text("tu3", "done"), + assistant_tool_use("tu3", "list_states", json!({})), + result_text("tu3", "states"), + assistant_tool_use("tu4", "get_source_info", json!({})), + result_text("tu4", "info"), + assistant_tool_use("tu5", "finish", json!({})), + result_text("tu5", "done"), ]; + assert!(original.len() > 1 + DEFAULT_KEEP_RECENT_MESSAGES); let mut h = original.clone(); evict_stale_history(&mut h); assert_eq!(h, original); diff --git a/app/src/settings.rs b/app/src/settings.rs index f49bab97..f7e6e7cb 100644 --- a/app/src/settings.rs +++ b/app/src/settings.rs @@ -139,7 +139,13 @@ impl AppSettings { /// otherwise show in the UI and read as "off"). fn normalize_eviction(&mut self) { let d = Self::default(); - if self.evict_keep_recent_messages == 0 { + // `4` is migrated too: settings saves persist every field, so installs + // that saved under the old default carry a `4` the user never chose — + // and a 2-turn-pair window is exactly what caused the agent's + // reference data to be elided before use (re-fetch loops that exhaust + // the turn budget; see platform::DEFAULT_KEEP_RECENT_MESSAGES). A + // deliberately tighter window is still expressible as 2 or 6. + if self.evict_keep_recent_messages == 0 || self.evict_keep_recent_messages == 4 { self.evict_keep_recent_messages = d.evict_keep_recent_messages; } if self.evict_text_over_chars == 0 {