From c26107201f9d68ae00c94bbb02503d3dde2ae8dc Mon Sep 17 00:00:00 2001 From: grunch Date: Tue, 28 Apr 2026 12:17:41 -0300 Subject: [PATCH 1/3] fix(mediation): three production bugs from 2026-04-27 Alice/Bob test trade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Duplicated "Hello, I'm Serbero..." greeting in clarification messages. The runtime's `draft_and_send_initial_message` already prefixes a one-line self-introduction at session open, but the model was also producing the same greeting inside its `*_clarification` text — both `phase3-message-templates.md` instructed it to and `phase3-system.md` told it to "always identify yourself", which it was reasonably interpreting as "greet on every message." Result: the greeting appeared twice in a single chat message. Fix at the prompt layer: drop the greeting from the "First Clarifying Question" template (the runtime owns it), strip the "Thank you for your response" preamble from the follow-up template, narrow the system-prompt identity rule to "answer truthfully if asked" instead of "always identify yourself", and add an explicit no-greeting / no-self-introduction hard rule to the classifier prompt's clarifying-question section. 2. Cooperative self-resolution template wording presumed the recipient gave the update. The `[en]/[es]/[pt]` sections opened with "Thanks for the update" / "Gracias por la actualización" / "Obrigado pela atualização", which is correct for the party who replied but jarring for the counterparty who only said "hola". Reworded to a neutral lead-in ("From what each of you has shared, ...") that applies symmetrically to both parties. Banned-keyword audit and opt-in invariants are unaffected. 3. PANIC: `set_session_state: illegal transition summary_delivered -> superseded_by_human`. Regression introduced by the carve-out commit that added the EXISTS clause for post-invitation `summary_delivered` sessions to `latest_open_session_for`. The dispute_resolved handler at `src/handlers/dispute_resolved.rs` has two distinct paths: lines 156-271 walk live sessions through `SupersededByHuman → Closed`, and lines 286-322 walk `summary_delivered` sessions through the legal direct `summary_delivered → Closed` transition. The carve-out broke that contract by surfacing post-invitation summary_delivered rows on the SupersededByHuman path, where the debug-asserted state-machine guard panics. Reverted only the `latest_open_session_for` carve-out; left the `list_live_sessions` carve-out in place because the ingest tick still needs to observe a later party reply on a post-invitation session for the `PartyRequestedHuman` opt-in. Updated doc comments on both functions to make the intentional divergence explicit, and rewrote the inline comment in `mediation::mod.rs` that described the old (uniform-exclusion) behaviour. --- prompts/phase3-classification.md | 9 +++++ prompts/phase3-message-templates.md | 25 ++++++++---- prompts/phase3-self-resolution.md | 6 +-- prompts/phase3-system.md | 10 ++++- src/db/mediation.rs | 62 +++++++++++++++-------------- src/mediation/mod.rs | 7 +++- 6 files changed, 75 insertions(+), 44 deletions(-) diff --git a/prompts/phase3-classification.md b/prompts/phase3-classification.md index e798027..9a7d8c9 100644 --- a/prompts/phase3-classification.md +++ b/prompts/phase3-classification.md @@ -83,6 +83,15 @@ Hard rules: "Seller:". The transport layer handles recipient routing; these prefixes only leak confusion into the other party's chat if they ever land on the wrong side. +- Do NOT begin the question with a greeting or self-introduction + (e.g. "Hello, I'm Serbero, an automated mediation assistance + system..."), and do NOT append a sign-off or signature. Identity is + disclosed once, by the runtime, in the very first message of the + session; the system prompt's "always identify yourself" rule is + satisfied by that opening line and by the message envelope. Every + subsequent clarification round must open directly with the + question itself. Repeating the greeting on every round duplicates + the introduction inside a single chat message and is a defect. - Both strings MUST be non-empty. If you cannot produce a useful question for one side, pick a different `suggested_action` (`summarize` or `escalate`) instead of emitting a half-populated diff --git a/prompts/phase3-message-templates.md b/prompts/phase3-message-templates.md index cc654e1..c576d84 100644 --- a/prompts/phase3-message-templates.md +++ b/prompts/phase3-message-templates.md @@ -11,21 +11,30 @@ layer. ## First Clarifying Question -"Hello, I'm Serbero, an automated mediation assistant helping the -assigned solver review this dispute. I'd like to understand your -perspective. Could you please describe what happened from your point -of view? Specifically: [SPECIFIC_QUESTION]" +"I'd like to understand your perspective. Could you please describe +what happened from your point of view? Specifically: [SPECIFIC_QUESTION]" — Replace `[SPECIFIC_QUESTION]` with one concrete, dispute-specific question. Do not return the literal token `[SPECIFIC_QUESTION]`. +The one-time "Hello, I'm Serbero, an automated mediation assistant +helping the assigned solver review this dispute. " self-introduction +is added by the runtime (`mediation::draft_and_send_initial_message`) +exactly once at session open, so the model MUST NOT repeat it inside +the clarification body. Repeating it produces a duplicated greeting in +a single chat message and is a defect. + ## Follow-Up Clarification -"Thank you for your response. To help the solver make a well-informed -decision, I have a follow-up question: [SPECIFIC_QUESTION]" +"[SPECIFIC_QUESTION]" -— Same rule: substitute a concrete follow-up question; never emit the -bracketed token. +— Substitute a concrete follow-up question and emit nothing else: no +greeting, no self-introduction, no "Thank you for your response" +preamble, no sign-off. Round 2+ messages travel through +`mediation::draft_and_send_followup_message`, which does NOT prefix a +greeting; the entire user-visible body is whatever the model returned. +Adding a preamble or signature here will surface verbatim in the +party's chat. ## Cooperative Summary Preamble diff --git a/prompts/phase3-self-resolution.md b/prompts/phase3-self-resolution.md index e8c15db..8211e05 100644 --- a/prompts/phase3-self-resolution.md +++ b/prompts/phase3-self-resolution.md @@ -35,13 +35,13 @@ the detected code has no matching `[xx]` section in this file. fallback_language = "en" [en] -template = "Thanks for the update — it sounds like the two of you may be close to coordinating the next step between yourselves. I'll keep monitoring this conversation in case anything changes." +template = "From what each of you has shared, it sounds like the two of you may be close to coordinating the next step between yourselves. I'll keep monitoring this conversation in case anything changes." human_assistance_optin = "If you'd prefer human assistance instead, just let me know in this chat and I'll route you to the assigned solver." [es] -template = "Gracias por la actualización: parece que ustedes dos podrían estar cerca de coordinar el siguiente paso entre sí. Sigo atento a esta conversación por si algo cambia." +template = "Por lo que cada uno ha compartido, parece que ustedes dos podrían estar cerca de coordinar el siguiente paso entre sí. Sigo atento a esta conversación por si algo cambia." human_assistance_optin = "Si prefieres asistencia humana, dímelo en este chat y te conecto con la persona asignada al caso." [pt] -template = "Obrigado pela atualização — parece que vocês dois podem estar perto de coordenar o próximo passo entre si. Continuo acompanhando esta conversa caso algo mude." +template = "Pelo que cada um compartilhou, parece que vocês dois podem estar perto de coordenar o próximo passo entre si. Continuo acompanhando esta conversa caso algo mude." human_assistance_optin = "Se preferir assistência humana, me avise neste chat e eu encaminho você para a pessoa designada." diff --git a/prompts/phase3-system.md b/prompts/phase3-system.md index ff0e356..b22b040 100644 --- a/prompts/phase3-system.md +++ b/prompts/phase3-system.md @@ -13,8 +13,14 @@ limits, and honesty discipline. These rules apply to every reasoning call. from both parties and drafting a clear, neutral summary. - You do NOT have authority over the dispute outcome. The human solver makes the final decision. -- Always identify yourself as an assistance system. Never claim to be - a human, mediator, judge, arbitrator, or solver. +- Never claim to be a human, mediator, judge, arbitrator, or solver. + If a party directly asks who or what you are, answer truthfully that + you are Serbero, an automated mediation assistance system. The + one-time identity disclosure that opens the session is handled by + the runtime, not by you — do NOT prefix every clarification, every + summary, or every cooperative invitation with a "Hello, I'm + Serbero..." preamble. Repeating the introduction inside a chat + message that already carries Serbero's voice is a defect. ## Authority Limits diff --git a/src/db/mediation.rs b/src/db/mediation.rs index 336aabd..351fd7a 100644 --- a/src/db/mediation.rs +++ b/src/db/mediation.rs @@ -274,11 +274,20 @@ pub struct LiveSession { } /// List all mediation sessions that are NOT in a terminal or -/// handed-off state. Same exclusion set as -/// [`latest_open_session_for`]: `closed`, `summary_delivered`, -/// `escalation_recommended`, `superseded_by_human`. The engine uses -/// this to decide which sessions to poll for inbound replies on each -/// tick and to rebuild in-memory chat material at startup. +/// handed-off state. Base exclusion set: `closed`, +/// `summary_delivered`, `escalation_recommended`, +/// `superseded_by_human`. The engine uses this to decide which +/// sessions to poll for inbound replies on each tick and to rebuild +/// in-memory chat material at startup. +/// +/// **Diverges intentionally from [`latest_open_session_for`]** via the +/// Feature 005 carve-out below: the ingest tick must keep watching +/// post-invitation `summary_delivered` sessions so a later party reply +/// can still trigger the `PartyRequestedHuman` opt-in. The +/// dispute_resolved handler uses `latest_open_session_for` (no +/// carve-out) so summarized sessions take the legal +/// `summary_delivered → closed` direct transition instead of the +/// illegal SupersededByHuman walk. pub fn list_live_sessions(conn: &Connection) -> Result> { use std::str::FromStr; @@ -393,12 +402,17 @@ pub fn set_session_state( /// `superseded_by_human`) are excluded — a dispute that was closed /// or escalated earlier must not block a later session open. /// -/// Feature 005 carve-out (mirrors [`list_live_sessions`]): a session -/// in `summary_delivered` that received the cooperative-self-resolution -/// invitation is still considered live so the human-assistance opt-in -/// path can fire on a later party reply. The carve-out is scoped by -/// the `self_resolution_offered` audit row, so legacy -/// `summary_delivered` sessions stay terminal. +/// **Diverges intentionally from [`list_live_sessions`].** The ingest +/// tick needs to keep watching post-invitation `summary_delivered` +/// sessions so a later party reply can trigger the +/// `PartyRequestedHuman` opt-in; this lookup, by contrast, gates +/// new-session-open eligibility and the dispute_resolved handler's +/// SupersededByHuman walk. The handler at +/// `src/handlers/dispute_resolved.rs` has a dedicated path that closes +/// `summary_delivered` sessions via the legal direct +/// `summary_delivered → closed` transition; surfacing them here would +/// route them through the illegal `summary_delivered → +/// superseded_by_human` step instead. /// /// Used by the engine to gate session opens and, crucially, re-checked /// inside the final open-session DB transaction to close the @@ -410,25 +424,15 @@ pub fn latest_open_session_for( use std::str::FromStr; match conn.query_row( - "SELECT s.session_id, s.state FROM mediation_sessions s - WHERE s.dispute_id = ?1 - AND ( - s.state NOT IN ( - 'closed', - 'summary_delivered', - 'escalation_recommended', - 'superseded_by_human' - ) - OR ( - s.state = 'summary_delivered' - AND EXISTS ( - SELECT 1 FROM mediation_events e - WHERE e.session_id = s.session_id - AND e.kind = 'self_resolution_offered' - ) - ) + "SELECT session_id, state FROM mediation_sessions + WHERE dispute_id = ?1 + AND state NOT IN ( + 'closed', + 'summary_delivered', + 'escalation_recommended', + 'superseded_by_human' ) - ORDER BY s.started_at DESC + ORDER BY started_at DESC LIMIT 1", params![dispute_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), diff --git a/src/mediation/mod.rs b/src/mediation/mod.rs index c5b44c4..d812a9a 100644 --- a/src/mediation/mod.rs +++ b/src/mediation/mod.rs @@ -1326,8 +1326,11 @@ pub async fn deliver_summary( // session at `summary_delivered` blocks re-eligibility // (since `summary_delivered` is treated as live by the // eligibility EXISTS clause) while still being recognised - // as terminal by `list_live_sessions` and - // `latest_open_session_for`. The legal `summary_delivered + // as terminal by `latest_open_session_for`. + // `list_live_sessions` keeps the row visible only when a + // prior `self_resolution_offered` audit row exists, so the + // ingest tick can still observe a later party reply for + // the human-assistance opt-in. The legal `summary_delivered // → closed` transition is taken later by the // `dispute_resolved` handler when Mostro closes the // dispute, or stays put indefinitely if the dispute never From 4f32481d5c91514144be7a39f8ecb9f0ac7c42f4 Mon Sep 17 00:00:00 2001 From: grunch Date: Tue, 28 Apr 2026 12:41:12 -0300 Subject: [PATCH 2/3] review: drop stale "always identify yourself" reference + lock filter divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups against the previous commit, raised in the PR review: 1. `prompts/phase3-classification.md`: the new no-greeting hard rule pointed at the system prompt's "always identify yourself" rule, but that phrase was rewritten in the same commit (to "answer truthfully if asked") so the cross-reference dangled. Reworded the block to point at the runtime owner of the introduction (`mediation::draft_and_send_initial_message`) directly. 2. `src/db/mediation.rs`: added a regression test that locks the intentional divergence between `list_live_sessions` (post-invitation `summary_delivered` rows STAY visible so the ingest tick can observe a later party reply for `PartyRequestedHuman`) and `latest_open_session_for` (post-invitation `summary_delivered` rows STAY invisible so the dispute_resolved handler closes them via the legal direct `summary_delivered → closed` transition instead of the illegal `SupersededByHuman` walk). A change that re-aligns the two filters in either direction will now fail this test. --- prompts/phase3-classification.md | 14 +++--- src/db/mediation.rs | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/prompts/phase3-classification.md b/prompts/phase3-classification.md index 9a7d8c9..be515a6 100644 --- a/prompts/phase3-classification.md +++ b/prompts/phase3-classification.md @@ -85,13 +85,13 @@ Hard rules: ever land on the wrong side. - Do NOT begin the question with a greeting or self-introduction (e.g. "Hello, I'm Serbero, an automated mediation assistance - system..."), and do NOT append a sign-off or signature. Identity is - disclosed once, by the runtime, in the very first message of the - session; the system prompt's "always identify yourself" rule is - satisfied by that opening line and by the message envelope. Every - subsequent clarification round must open directly with the - question itself. Repeating the greeting on every round duplicates - the introduction inside a single chat message and is a defect. + system..."), and do NOT append a sign-off or signature. The runtime + discloses Serbero's identity exactly once, by hard-prefixing a + one-line introduction on the very first outbound of the session + (`mediation::draft_and_send_initial_message`); every subsequent + clarification round must open directly with the question itself. + Repeating the greeting on top of the runtime prefix duplicates the + introduction inside a single chat message and is a defect. - Both strings MUST be non-empty. If you cannot produce a useful question for one side, pick a different `suggested_action` (`summarize` or `escalate`) instead of emitting a half-populated diff --git a/src/db/mediation.rs b/src/db/mediation.rs index 351fd7a..cc00b24 100644 --- a/src/db/mediation.rs +++ b/src/db/mediation.rs @@ -773,6 +773,86 @@ mod tests { } } + /// Regression guard for the 2026-04-27 panic + /// (`set_session_state: illegal transition summary_delivered -> + /// superseded_by_human`). The two queries diverge on purpose: + /// + /// * `list_live_sessions` keeps a `summary_delivered` row visible + /// when it carries a prior `self_resolution_offered` audit row, + /// so the ingest tick can still observe a later party reply + /// for the `PartyRequestedHuman` opt-in. + /// * `latest_open_session_for` excludes `summary_delivered` + /// unconditionally, so the dispute_resolved handler closes + /// summarized sessions via the legal direct + /// `summary_delivered → closed` transition (the dedicated + /// query at `src/handlers/dispute_resolved.rs` lines 286-322) + /// instead of the illegal `SupersededByHuman` walk. + /// + /// A change that re-aligns the two filters in either direction + /// breaks one of those properties and must be caught here. + #[test] + fn summary_delivered_row_with_invitation_diverges_between_filters() { + let conn = fresh(); + insert_session(&conn, &new_session("pol-hash-divergence")).unwrap(); + conn.execute( + "UPDATE mediation_sessions SET state = 'summary_delivered' + WHERE session_id = 'sess-1'", + [], + ) + .unwrap(); + + // Without a `self_resolution_offered` audit row, `summary_delivered` + // is terminal for BOTH queries (legacy behaviour). + let live = list_live_sessions(&conn).unwrap(); + assert!( + live.is_empty(), + "legacy summary_delivered without invitation must be excluded \ + from list_live_sessions; got {live:?}" + ); + assert!( + latest_open_session_for(&conn, "dispute-xyz") + .unwrap() + .is_none(), + "legacy summary_delivered without invitation must be excluded \ + from latest_open_session_for" + ); + + // Add the `self_resolution_offered` audit row. + conn.execute( + "INSERT INTO mediation_events (session_id, kind, payload_json, occurred_at) + VALUES ('sess-1', 'self_resolution_offered', '{}', 100)", + [], + ) + .unwrap(); + + // Carve-out applies to `list_live_sessions` only. + let live = list_live_sessions(&conn).unwrap(); + assert_eq!( + live.len(), + 1, + "post-invitation summary_delivered must be visible to \ + list_live_sessions so the ingest tick can observe a later \ + party reply for the PartyRequestedHuman opt-in; got {live:?}" + ); + assert_eq!(live[0].session_id, "sess-1"); + assert_eq!(live[0].state, MediationSessionState::SummaryDelivered); + + // ...and MUST NOT apply to `latest_open_session_for`. Surfacing + // this row here is what triggered the 2026-04-27 panic — the + // dispute_resolved handler would walk it through + // `SupersededByHuman → Closed`, but `summary_delivered → + // superseded_by_human` is not a legal state-machine edge. + assert!( + latest_open_session_for(&conn, "dispute-xyz") + .unwrap() + .is_none(), + "post-invitation summary_delivered MUST stay invisible to \ + latest_open_session_for; the dedicated summary_delivered → \ + closed path in dispute_resolved.rs handles closure via the \ + legal direct transition" + ); + } + #[test] fn set_session_state_updates_state_and_transition_ts() { let conn = fresh(); From e172ef73f2ffe5b05515834db65fe315d1faf676 Mon Sep 17 00:00:00 2001 From: grunch Date: Tue, 28 Apr 2026 12:56:11 -0300 Subject: [PATCH 3/3] fix(reasoning): round-0 ask_clarification regression on empty transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed 2026-04-28: a freshly-opened Alice/Bob dispute (event 6380cdd2…, dispute 4c64e6ab-…) stayed at `initiated` because the opening classify+take call returned `Escalate(LowConfidence)` before any chat-transport step. Mostro never saw Serbero take the dispute, so the dispute lifecycle did not advance. Log signature: classify_for_start: rationale persisted dispute-scoped classification=unclear confidence=0.12 decision=Escalate(LowConfidence) opening-call escalate: dispute-scoped handoff recorded, no take trigger=low_confidence The round-0 bypass in `policy::classify_to_decision` accepts low-confidence classifications iff `suggested_action = ask_clarification`. With confidence 0.12 and decision LowConfidence the bypass did not engage, so the model returned `suggested_action ∈ { summarize, escalate }` instead of `ask_clarification`. Two contributing causes, both fixed here: 1. **Blank transcript section.** With `transcript = ""` the user prompt rendered "## Transcript\n\n## Output contract", which the `gpt-5.4-mini` router model read as "transcript missing" rather than "transcript empty because no replies yet". The model fell back to a defensive `summarize | escalate`. Fix: emit a literal `(no replies yet — round 0)` marker when `transcript.is_empty()`, so the round-0 signal is unambiguous in the prompt body. 2. **Hard-rule escape hatch interpreted too broadly.** The classifier prompt's "If you cannot produce a useful question for one side, pick a different `suggested_action` (`summarize` or `escalate`)" rule had no round-0 carve-out. With an empty transcript a literal reading is "I have no information about this dispute, so I cannot produce a useful question for either side, so I should escalate." Fix: added an explicit Round-0 contract section to `prompts/phase3-classification.md` stating that round 0 MUST return `ask_clarification` with both opening questions, and narrowed the escape-hatch wording to `round_count >= 1` — round 0's job is to start the chat, not classify or escalate. Pre-fix the "First Clarifying Question" template carried the "Hello, I'm Serbero, …" greeting, which apparently anchored the model toward `ask_clarification` on round 0 even without an explicit contract section. Removing that greeting (necessary to fix the duplicated-greeting bug) exposed this latent fragility. Lib + integration tests pass; no schema change. --- prompts/phase3-classification.md | 34 +++++++++++++++++++++++++++++--- src/reasoning/openai.rs | 27 +++++++++++++++++++------ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/prompts/phase3-classification.md b/prompts/phase3-classification.md index be515a6..04dd1f8 100644 --- a/prompts/phase3-classification.md +++ b/prompts/phase3-classification.md @@ -57,6 +57,32 @@ Every classification MUST include a rationale explaining the chosen label and confidence. Stored in the audit store only; referenced by id in general logs (FR-120). +## Round-0 contract (opening call, empty transcript) + +When `round_count = 0` AND the `## Transcript` section is empty +(or contains the literal marker `(no replies yet — round 0)`): + +- `suggested_action` MUST be `ask_clarification`. Round 0 is the + call that opens the chat with both parties — picking `summarize` + or `escalate` here aborts the session before Serbero ever talks + to anyone, and Mostro never sees Serbero take the dispute. There + is intentionally nothing to summarize or escalate yet; your job + is to start the conversation. +- `classification` MUST be `unclear` and `confidence` MUST be low + (≤ 0.3). The policy layer applies a round-0 bypass that accepts + low-confidence `ask_clarification` so the chat can open. Do not + fabricate a higher confidence — the round-0 bypass is the + designed path, not a workaround. +- Both `buyer_clarification` and `seller_clarification` MUST be + populated using the "First Clarifying Question" template from + the message-templates bundle, with the `[SPECIFIC_QUESTION]` + token replaced by the role-appropriate generic opener (buyer: + fiat sent? proof of transfer; seller: fiat received? if not, + what proof the buyer shared). The "If you cannot produce a + useful question" escape hatch in the Hard Rules below does NOT + apply on round 0 — on round 0 a generic opener IS the useful + question, because no transcript exists yet. + ## Clarifying Questions (per-party) When `suggested_action = ask_clarification`, emit TWO distinct @@ -93,9 +119,11 @@ Hard rules: Repeating the greeting on top of the runtime prefix duplicates the introduction inside a single chat message and is a defect. - Both strings MUST be non-empty. If you cannot produce a useful - question for one side, pick a different `suggested_action` - (`summarize` or `escalate`) instead of emitting a half-populated - clarification. + question for one side at `round_count >= 1` (because the existing + transcript already answered everything you would ask), pick a + different `suggested_action` (`summarize` or `escalate`) instead + of emitting a half-populated clarification. This escape hatch + does NOT apply on round 0 — see the Round-0 contract above. - Each question stands on its own — don't cross-reference the other party's text, since each party only ever sees theirs. diff --git a/src/reasoning/openai.rs b/src/reasoning/openai.rs index 4ddaeea..b4d90d7 100644 --- a/src/reasoning/openai.rs +++ b/src/reasoning/openai.rs @@ -481,12 +481,27 @@ pub(super) fn build_classification_prompt(r: &ClassificationRequest) -> String { // the exact bytes the session's `policy_hash` pins. An auditor // can later grep the git-committed bundle for this hash and // recover the full prompt context. - let transcript = r - .transcript - .iter() - .map(|e| format!("[{}] {}: {}", e.inner_event_created_at, e.party, e.content)) - .collect::>() - .join("\n"); + // + // When the transcript is empty (round 0, before any party reply) + // render a literal marker instead of a blank section so the model + // sees an unambiguous round-0 signal. A blank "## Transcript" + // section reads as "transcript missing", which on `gpt-5.4-mini` + // (and similar router models) pushed the classifier toward + // `suggested_action = summarize | escalate` instead of + // `ask_clarification` — observed 2026-04-28 with the + // Alice/Bob test trade staying at `initiated` because the + // opening classify+take pre-empted itself with + // `Escalate(LowConfidence)`. The Round-0 contract section in + // `prompts/phase3-classification.md` keys off this marker. + let transcript = if r.transcript.is_empty() { + "(no replies yet — round 0)".to_string() + } else { + r.transcript + .iter() + .map(|e| format!("[{}] {}: {}", e.inner_event_created_at, e.party, e.content)) + .collect::>() + .join("\n") + }; // Feature 005: only ask for the `human_requested` field on rounds // following a `self_resolution_offered` audit row. Asking on every // round wastes tokens and risks false positives (a buggy provider