diff --git a/README.md b/README.md index 2ccb0338..d2be2a70 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 @@ -108,18 +113,18 @@ 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 -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 diff --git a/agent/src/conversion.rs b/agent/src/conversion.rs index 4de88e0a..a763a1c1 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 \ @@ -478,6 +483,51 @@ 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`]), + /// 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 `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 pkg = self.build_package()?; + self.package = Some(pkg); + } + 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. pub fn form_code(&self) -> Option { self.aem_config.as_ref().map(|c| c.form_code.clone()) @@ -668,7 +718,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"]), ), @@ -1081,21 +1131,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(); @@ -1470,6 +1513,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 c507a97f..5ca87b31 100644 --- a/app/src/agent_runner.rs +++ b/app/src/agent_runner.rs @@ -14,13 +14,52 @@ 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; +/// 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 \ +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. @@ -130,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, @@ -147,141 +204,270 @@ 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) => { - processing_state.write().error = Some(format!("Agent failed: {e}")); - return; + 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, - }, - ); - } - - if turn.stop_reason.as_deref() != Some("tool_use") || turn.tool_calls.is_empty() { - 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 - }, - ); - - // 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; + // 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; } - } else { - last_validate_output = Some(output); - validate_repeat_count = 1; + // 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; } - } 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)); + 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; + } - 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(), - ); + 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"), + ] { + 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; + } + } + + break 'round RoundEnd::Done; + } - // 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"), - ] { + if agent.is_finished() { + break 'round RoundEnd::Done; + } + } + 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; } } finalize( - &agent, + &mut agent, &profile, structured_session, start, @@ -292,7 +478,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, @@ -306,24 +492,151 @@ 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; - state.aem_package = agent.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; 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) { @@ -363,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/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: " } 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 {