//! Post-phase summarization worker. //! //! Watches `mission_phases` for terminal-state transitions and, for //! each one that hasn't been summarized yet, aggregates every //! `topology_runs.checkpoint.outputs[]` bound to that phase plus the //! phase's `mission_tasks` + `mission_artifacts` and asks Claude Opus //! 4.8 to produce a structured completion card: //! //! { narrative, metrics, sources, tooling, next_actions } //! //! The output lands in `mission_phase_summaries` (one row per //! phase_id, upserted). The mission phase card in the UI renders it //! below the phase's other detail so the operator sees "what did this //! phase actually accomplish, what did it produce, and what's next". //! //! Runs on a slow tick (30s) — summarization is cheap to defer, and //! the LLM call is the expensive part. use serde_json::{json, Value}; use sqlx::PgPool; use sqlx::Row; use std::time::Duration; use uuid::Uuid; const DEFAULT_MODEL: &str = "claude-opus-5"; const POLL_INTERVAL: Duration = Duration::from_secs(30); /// Cap the raw material we send to the model. Missions can produce /// hundreds of KB of agent output; we slice by turn and by phase /// artifact but still bound the total prompt. const MAX_OUTPUT_BYTES: usize = 120_000; /// The longest prefix of `s` that is at most `max_bytes` and ends on a /// character boundary. /// /// `&s[..max_bytes]` PANICS when the cut lands inside a multi-byte character, /// and `s` here is agent-authored turn output — arbitrary UTF-8, routinely /// containing arrows, box-drawing and emoji. The panic would take down the /// evaluation sweep for a phase whose only crime was writing a long enough /// line with a non-ASCII character at the wrong offset. /// /// Exactly the bug the clawhdf5 agents found and fixed in /// `clawhdf5-migrate/src/validate.rs` this week, in our own code. fn clamp_to_char_boundary(s: &str, max_bytes: usize) -> &str { if s.len() <= max_bytes { return s; } let mut end = max_bytes; while end > 0 && !s.is_char_boundary(end) { end -= 1; } &s[..end] } fn model_name() -> String { std::env::var("CLAWMATES_SUMMARIZER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()) } /// The runtime is carried purely so the summarizer can reach the SAME /// providers as everything else. It used to hand-roll its own HTTPS POST with /// `x-api-key: $ANTHROPIC_API_KEY`, which is why no audit of `.complete(` call /// sites ever found it — and why every phase summary on this deployment died /// with "credit balance is too low" while the phases themselves ran fine. pub fn spawn(pool: PgPool, runtime: cm_runtime::Runtime) { tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(45)).await; let mut ticker = tokio::time::interval(POLL_INTERVAL); ticker.tick().await; loop { ticker.tick().await; if let Err(e) = sweep_once(&pool, &runtime).await { eprintln!("phase_summarizer: sweep failed: {e}"); } } }); } async fn sweep_once(pool: &PgPool, runtime: &cm_runtime::Runtime) -> Result<(), String> { // Terminal phases with no summary yet. let rows = sqlx::query( "SELECT mp.id, mp.mission_id, mp.kind FROM mission_phases mp LEFT JOIN mission_phase_summaries mps ON mps.phase_id = mp.id WHERE mp.status IN ('completed', 'failed') AND mps.id IS NULL LIMIT 10", ) .fetch_all(pool) .await .map_err(|e| format!("scan phases: {e}"))?; for row in rows { let phase_id: Uuid = row.get("id"); let mission_id: Uuid = row.get("mission_id"); let kind: String = row.get("kind"); if let Err(e) = summarize_one(pool, runtime, mission_id, phase_id, &kind).await { // Persist an error row so we don't infinite-retry a broken // phase — the UI can surface "summary unavailable: ". eprintln!("phase_summarizer: {phase_id} ({kind}) failed: {e}"); let _ = record_error(pool, mission_id, phase_id, &kind, &e).await; } } Ok(()) } async fn summarize_one( pool: &PgPool, runtime: &cm_runtime::Runtime, mission_id: Uuid, phase_id: Uuid, kind: &str, ) -> Result<(), String> { let material = collect_material(pool, mission_id, phase_id).await?; if material.outputs.is_empty() && material.tasks_created == 0 && material.artifacts.is_empty() { // Nothing to summarize. Write a placeholder so we don't retry // this phase every 30s. return upsert_summary( pool, mission_id, phase_id, kind, "claude-opus-5", "This phase produced no recorded output. The agents may have failed \ to reach their working directory or found nothing to act on.", &json!({ "outputs": 0, "tasks": 0, "artifacts": 0, }), &json!([]), &json!([]), &json!([]), &json!([]), ) .await; } let (narrative, structured, answered_by) = call_anthropic(runtime, kind, &material).await?; let metrics = structured .get("metrics") .cloned() .unwrap_or_else(|| json!({})); let sources = structured .get("sources") .cloned() .unwrap_or_else(|| json!([])); let tooling = structured .get("tooling") .cloned() .unwrap_or_else(|| json!([])); let next_actions = structured .get("next_actions") .cloned() .unwrap_or_else(|| json!([])); let artifacts = serde_json::to_value(&material.artifacts).unwrap_or(json!([])); upsert_summary( pool, mission_id, phase_id, kind, &answered_by, &narrative, &metrics, &sources, &artifacts, &tooling, &next_actions, ) .await } struct PhaseMaterial { /// Concatenated per-turn outputs across every topology_run bound to /// this phase, trimmed to `MAX_OUTPUT_BYTES`. outputs: String, /// Original count (pre-trim) — helps the LLM understand the scale /// even when we truncated. output_count: usize, /// Total tokens across runs (from checkpoint.totals.tokens). tokens: u64, turns: u64, tasks_created: usize, tasks_completed: usize, tasks_failed: usize, artifacts: Vec, task_summaries: Vec, } #[derive(serde::Serialize)] struct ArtifactRef { path: String, kind: String, title: Option, } #[derive(serde::Serialize)] struct TaskRef { external_id: Option, title: String, status: String, } /// Render this phase's material as plain evidence text. /// /// Shared with the completion evaluator (`crate::evaluator`), which judges a /// `done_when` condition against exactly the same material the summarizer /// writes its card from — turn outputs, task counts, artifacts. Reusing this /// keeps the two from disagreeing about what the phase actually produced, and /// the truncation/aggregation logic only has to be right once. pub async fn collect_evidence( pool: &PgPool, mission_id: Uuid, phase_id: Uuid, ) -> Result { let m = collect_material(pool, mission_id, phase_id).await?; let mut s = String::with_capacity(m.outputs.len() + 512); s.push_str(&format!( "turns: {}\ntokens: {}\nagent outputs: {}\ntasks: {} created, {} completed, {} failed\n", m.turns, m.tokens, m.output_count, m.tasks_created, m.tasks_completed, m.tasks_failed, )); if !m.artifacts.is_empty() { s.push_str("\nartifacts written:\n"); for a in m.artifacts.iter().take(40) { s.push_str(&format!("- {} ({})\n", a.path, a.kind)); } } if !m.task_summaries.is_empty() { s.push_str("\ntask states:\n"); for t in m.task_summaries.iter().take(40) { s.push_str(&format!( "- {} [{}] {}\n", t.external_id.as_deref().unwrap_or("-"), t.status, t.title )); } } s.push_str("\nagent turn output:\n"); s.push_str(&m.outputs); Ok(s) } async fn collect_material( pool: &PgPool, mission_id: Uuid, phase_id: Uuid, ) -> Result { // Runs — checkpoint.outputs + totals aggregated. let run_rows = sqlx::query( "SELECT checkpoint FROM topology_runs WHERE mission_id = $1 AND mission_phase_id = $2", ) .bind(mission_id) .bind(phase_id) .fetch_all(pool) .await .map_err(|e| format!("load runs: {e}"))?; let mut concat = String::new(); let mut output_count = 0usize; let mut tokens = 0u64; let mut turns = 0u64; for row in &run_rows { let cp: Option = row.get("checkpoint"); let Some(cp) = cp else { continue }; if let Some(t) = cp.get("totals") { tokens += t.get("tokens").and_then(|v| v.as_u64()).unwrap_or(0); turns += t.get("turns").and_then(|v| v.as_u64()).unwrap_or(0); } if let Some(arr) = cp.get("outputs").and_then(|v| v.as_array()) { for (i, item) in arr.iter().enumerate() { output_count += 1; if concat.len() >= MAX_OUTPUT_BYTES { continue; } let s = match item { Value::String(s) => s.clone(), other => other.to_string(), }; concat.push_str(&format!("\n\n── turn {} ──\n", i + 1)); let remaining = MAX_OUTPUT_BYTES.saturating_sub(concat.len()); if s.len() > remaining { concat.push_str(clamp_to_char_boundary(&s, remaining)); concat.push_str("\n… (truncated)"); } else { concat.push_str(&s); } } } } // Tasks — count by status, keep small summaries. let task_rows = sqlx::query( "SELECT external_id, title, status FROM mission_tasks WHERE mission_id = $1 AND phase_id = $2 ORDER BY created_at LIMIT 40", ) .bind(mission_id) .bind(phase_id) .fetch_all(pool) .await .map_err(|e| format!("load tasks: {e}"))?; let mut task_summaries = Vec::new(); let mut tasks_completed = 0usize; let mut tasks_failed = 0usize; for row in &task_rows { let status: String = row.get("status"); match status.as_str() { "complete" => tasks_completed += 1, "failed" => tasks_failed += 1, _ => {} } task_summaries.push(TaskRef { external_id: row.get("external_id"), title: row.get("title"), status, }); } // Artifacts. let artifact_rows = sqlx::query( "SELECT path, kind, title FROM mission_artifacts WHERE mission_id = $1 AND phase_id = $2 ORDER BY created_at LIMIT 40", ) .bind(mission_id) .bind(phase_id) .fetch_all(pool) .await .map_err(|e| format!("load artifacts: {e}"))?; let artifacts: Vec = artifact_rows .into_iter() .map(|r| ArtifactRef { path: r.get("path"), kind: r.get("kind"), title: r.get("title"), }) .collect(); Ok(PhaseMaterial { outputs: concat, output_count, tokens, turns, tasks_created: task_rows.len(), tasks_completed, tasks_failed, artifacts, task_summaries, }) } /// Returns the narrative, the parsed object, and **the model that answered** — /// which may be a fallback link rather than `model_name()`, and is recorded as /// such. async fn call_anthropic( runtime: &cm_runtime::Runtime, kind: &str, material: &PhaseMaterial, ) -> Result<(String, Value, String), String> { let model = model_name(); let system = system_prompt(kind); let user = user_prompt(kind, material); let (raw, answered_by) = crate::subscription::complete_with_fallback( runtime, &system, &user, &model, 4096, false, ) .await?; let raw = raw.trim().to_string(); if raw.is_empty() { return Err(format!("{answered_by} returned empty text")); } // Model returns a JSON object; extract narrative + rest. let parsed: Value = serde_json::from_str(&strip_code_fence(&raw)).map_err(|e| { format!( "summarizer JSON parse failed: {e}. Raw head: {}", &raw[..raw.len().min(400)] ) })?; let narrative = parsed .get("narrative") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); if narrative.is_empty() { return Err("summarizer response missing narrative".into()); } Ok((narrative, parsed, answered_by)) } /// Trim a leading/trailing ```json … ``` fence the model sometimes wraps /// around its output despite being asked for raw JSON. fn strip_code_fence(s: &str) -> String { let t = s.trim(); let stripped = t .strip_prefix("```json") .or_else(|| t.strip_prefix("```")) .unwrap_or(t); let stripped = stripped.trim_start_matches('\n'); stripped .strip_suffix("```") .map(|s| s.trim_end_matches('\n')) .unwrap_or(stripped) .to_string() } fn system_prompt(kind: &str) -> String { let base = "You are the mission phase summarizer. Read the agent-produced \ material below and produce a compact JSON object that the operator \ UI will render as a completion card. Extract concrete facts from the \ outputs — never invent findings, PRs, files, or counts that the \ source material does not support.\n\ \n\ Return raw JSON (no code fence, no preamble). The shape MUST be:\n\ \n\ {\n \ \"narrative\": string, // 2–5 sentences: what this phase actually accomplished\n \ \"metrics\": { ... }, // kind-specific counts, see below\n \ \"sources\": [ ... ], // things the agents CONSULTED (URLs, files, docs)\n \ \"tooling\": [ ... ], // concrete recommendations — new skills / scripts / MCP tools worth wiring into the platform\n \ \"next_actions\": [ ... ] // what should happen next — cards to file, follow-ups for the next phase\n\ }\n\ \n\ Every array element is an object with at least a `title` and a short `note`. \ Sources also include a `url` or `path` when identifiable. Tooling \ entries include a `kind` ('skill' | 'script' | 'mcp' | 'workflow') and a \ `why` (what problem it solves that surfaced in the phase).\n\ \n\ Keep it tight — the card is small. If a section has nothing to say, \ return an empty array."; let kind_hint = match kind { "research" => "\n\nMetrics shape for RESEARCH:\n\ { \"insights\": , \"sources_gathered\": , \"int_cards\": , \ \"artifacts_saved\": , \"handoffs_to_coding\": }\n\ Focus the narrative on WHAT WAS LEARNED and WHAT THE CODING PHASE \ NEEDS TO DO next. INT-XX markers in the raw outputs are the count of \ concrete follow-up cards produced.", "coding" => "\n\nMetrics shape for CODING:\n\ { \"cards_picked_up\": , \"cards_closed\": , \"commits\": , \ \"tests_added\": , \"tests_passing\": , \"tests_failing\": , \ \"issues_found\": , \"issues_fixed\": }\n\ Focus the narrative on WHAT WAS BUILT, WHAT PASSED VALIDATION, and \ WHAT'S STILL OPEN. Commit hashes and PR/branch names are useful in \ `sources` when visible.", "benchmark" => "\n\nMetrics shape for BENCHMARK:\n\ { \"baselines\": , \"comparisons\": , \"regressions\": , \ \"improvements\": }\n\ Report the deltas the agents actually measured.", "security_scan" => "\n\nMetrics shape for SECURITY:\n\ { \"findings\": , \"by_severity\": { \"crit\": , \"high\": , \"med\": , \"low\": }, \ \"patches_proposed\": }", _ => "", }; format!("{base}{kind_hint}") } fn user_prompt(kind: &str, m: &PhaseMaterial) -> String { let task_head: Vec = m .task_summaries .iter() .take(30) .map(|t| { format!( "- [{}] {} — {}", t.status, t.external_id.as_deref().unwrap_or("--"), t.title, ) }) .collect(); let artifact_head: Vec = m .artifacts .iter() .take(30) .map(|a| { format!( "- [{}] {}{}", a.kind, a.path, a.title .as_deref() .map(|t| format!(" — {t}")) .unwrap_or_default(), ) }) .collect(); format!( "Phase kind: {kind}\n\ Aggregate stats:\n\ - runs.checkpoint.outputs total (pre-truncate): {output_count}\n\ - total turns across runs: {turns}\n\ - total tokens across runs: {tokens}\n\ - mission_tasks in this phase: {tasks_created} (completed: {tasks_completed}, failed: {tasks_failed})\n\ - mission_artifacts in this phase: {artifact_count}\n\ \n\ Mission tasks in this phase (up to 30):\n\ {tasks}\n\ \n\ Mission artifacts in this phase (up to 30):\n\ {arts}\n\ \n\ Concatenated per-turn agent outputs (up to {max_bytes} bytes):\n\ {outputs}", kind = kind, output_count = m.output_count, turns = m.turns, tokens = m.tokens, tasks_created = m.tasks_created, tasks_completed = m.tasks_completed, tasks_failed = m.tasks_failed, artifact_count = m.artifacts.len(), tasks = if task_head.is_empty() { "(none)".to_string() } else { task_head.join("\n") }, arts = if artifact_head.is_empty() { "(none)".to_string() } else { artifact_head.join("\n") }, max_bytes = MAX_OUTPUT_BYTES, outputs = if m.outputs.is_empty() { "(no outputs)".to_string() } else { m.outputs.clone() }, ) } #[allow(clippy::too_many_arguments)] async fn upsert_summary( pool: &PgPool, mission_id: Uuid, phase_id: Uuid, kind: &str, model: &str, narrative: &str, metrics: &Value, sources: &Value, artifacts: &Value, tooling: &Value, next_actions: &Value, ) -> Result<(), String> { sqlx::query( "INSERT INTO mission_phase_summaries (mission_id, phase_id, kind, model, narrative, metrics, sources, artifacts, tooling, next_actions, generated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now()) ON CONFLICT (phase_id) DO UPDATE SET kind = EXCLUDED.kind, model = EXCLUDED.model, narrative = EXCLUDED.narrative, metrics = EXCLUDED.metrics, sources = EXCLUDED.sources, artifacts = EXCLUDED.artifacts, tooling = EXCLUDED.tooling, next_actions = EXCLUDED.next_actions, generated_at = now(), error = NULL", ) .bind(mission_id) .bind(phase_id) .bind(kind) .bind(model) .bind(narrative) .bind(metrics) .bind(sources) .bind(artifacts) .bind(tooling) .bind(next_actions) .execute(pool) .await .map_err(|e| format!("upsert summary: {e}"))?; Ok(()) } async fn record_error( pool: &PgPool, mission_id: Uuid, phase_id: Uuid, kind: &str, err: &str, ) -> Result<(), String> { sqlx::query( "INSERT INTO mission_phase_summaries (mission_id, phase_id, kind, model, narrative, error) VALUES ($1, $2, $3, 'error', 'Summary generation failed.', $4) ON CONFLICT (phase_id) DO UPDATE SET error = EXCLUDED.error, generated_at = now()", ) .bind(mission_id) .bind(phase_id) .bind(kind) .bind(err) .execute(pool) .await .map_err(|e| format!("record error: {e}"))?; Ok(()) } #[cfg(test)] mod tests { use super::*; /// Agent output is arbitrary UTF-8. A byte-offset cut that lands inside a /// multi-byte character must not panic — that panic would take down the /// evaluation sweep for the phase, and the only trigger is an agent /// happening to write a long enough line containing a non-ASCII character. #[test] fn truncation_never_splits_a_multibyte_character() { // 4-byte characters, so every offset not a multiple of 4 is // mid-character and would panic a naive `&s[..cut]`. let s = "😀".repeat(10); for cut in 0..=s.len() { let out = clamp_to_char_boundary(&s, cut); assert!(out.len() <= cut, "must respect the budget at cut={cut}"); assert!(s.starts_with(out), "must stay a prefix at cut={cut}"); } } /// Mixed-width text: the cut must land on a boundary, never inside `é`. #[test] fn truncation_handles_mixed_width_text() { let s = "héllo wörld"; for cut in 0..=s.len() { let out = clamp_to_char_boundary(s, cut); assert!(s.starts_with(out)); } } #[test] fn truncation_returns_everything_when_it_fits() { assert_eq!(clamp_to_char_boundary("héllo", 100), "héllo"); } }