From f75ff740ac2fa63340656e1e8215440f5e48063d Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 28 Aug 2026 14:14:56 +0700 Subject: [PATCH] feat(agent): hive-mind consensus synthesis pakai LLM nyata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit synth_consensus selama ini Cuma concatenate output node lalu dilabeli "Consensus" — tidak ada sintesis. Kini: - Resolve kredensial LLM (provider/model/base_url/api_key) dari Store, sumber yang sama dgn execute_cycle. - Kirim prompt sintesis ke model: minta distilasi node outputs jadi satu laporan konsensus berisi AGREEMENTS / CONFLICTS / KEY FINDINGS / RECOMMENDATION. - Graceful fallback ke summary concatenation bila panggilan LLM gagal / output kosong, supaya sintesis konsensus tidak pernah merusak siklus hive-mind (konsisten dgn filosofi isolated-errors utk node). - Batasi output per-node (MAX_NODE_OUTPUT_CHARS=4000, char-safe via truncate_chars) agar prompt tetap bounded. - test: +2 (truncation char-safe pada output besar multi-byte; concat summary memuat semua node id). Verifikasi: cargo check/clippy -D warnings/fmt clean; test infra 62 (0 gagal). Disk root sudah di-cargo clean (free 43.7GB, turun 98% -> 63%). --- Cargo.lock | 22 +-- .../src/workflow/hive_mind/synthesis.rs | 162 ++++++++++++++++-- 2 files changed, 163 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50cb3e4..0198f45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4862,7 +4862,7 @@ dependencies = [ [[package]] name = "zesdex-api" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "argon2", @@ -4885,7 +4885,7 @@ dependencies = [ [[package]] name = "zesdex-application" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "base64", @@ -4903,7 +4903,7 @@ dependencies = [ [[package]] name = "zesdex-bootstrap" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "chrono", @@ -4920,7 +4920,7 @@ dependencies = [ [[package]] name = "zesdex-daemon" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "base64", @@ -4944,7 +4944,7 @@ dependencies = [ [[package]] name = "zesdex-domain" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "base64", @@ -4960,7 +4960,7 @@ dependencies = [ [[package]] name = "zesdex-gateway" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "axum", @@ -4987,7 +4987,7 @@ dependencies = [ [[package]] name = "zesdex-grpc" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "axum", @@ -5004,7 +5004,7 @@ dependencies = [ [[package]] name = "zesdex-infrastructure" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "argon2", @@ -5052,7 +5052,7 @@ dependencies = [ [[package]] name = "zesdex-tui" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "base64", @@ -5078,7 +5078,7 @@ dependencies = [ [[package]] name = "zesdex-web" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "axum", @@ -5098,7 +5098,7 @@ dependencies = [ [[package]] name = "zesdex-ws" -version = "1.20.0" +version = "1.20.1" dependencies = [ "anyhow", "axum", diff --git a/apps/infrastructure/src/workflow/hive_mind/synthesis.rs b/apps/infrastructure/src/workflow/hive_mind/synthesis.rs index cc18d63..318e60a 100644 --- a/apps/infrastructure/src/workflow/hive_mind/synthesis.rs +++ b/apps/infrastructure/src/workflow/hive_mind/synthesis.rs @@ -1,28 +1,128 @@ //! Consensus synthesis — reconciles multiple node outputs into one assessment. +//! +//! Flow: load settings → resolve LLM credentials → ask the model to distill the +//! node outputs into a single consensus (conflicts, agreements, key findings) +//! → return the synthesized text. If the LLM call fails for any reason, we +//! degrade gracefully to a concatenation-based summary so consensus synthesis +//! never breaks the surrounding hive-mind cycle (mirrors the isolated-errors +//! philosophy used for the nodes themselves). use anyhow::Result; -use tracing::info; +use tracing::{info, warn}; -use crate::tools::ToolCtx; +use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; +use zesdex_domain::core::message::ChatMessage; +use zesdex_domain::core::Store; use zesdex_domain::workflow::NodeOutput; -/// Synthesize a consensus from all node outputs. +use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; +use crate::tools::ToolCtx; + +/// Maximum characters of node output to feed into the synthesis prompt per node. +/// Keeps the prompt bounded so a huge/talkative node cannot blow up the request. +const MAX_NODE_OUTPUT_CHARS: usize = 4000; + +/// Synthesize a consensus from all node outputs using the LLM. /// -/// Flow: combine node outputs → return consensus text. -/// Uses simple concatenation-based synthesis (avoids LLM call dependency). +/// Flow: combine node outputs → ask the model to reconcile them into a single +/// consensus → return the synthesized text. Falls back to a plain +/// concatenation summary if the LLM is unreachable or the call fails. pub async fn synthesize_consensus(nodes: &[NodeOutput], _tool_ctx: &ToolCtx) -> Result { info!("Synthesizing consensus from {} nodes", nodes.len()); + let combined = build_combined_body(nodes); + + // 1. Resolve LLM credentials (same source of truth as execute_cycle). + let store = Store::new(); + let settings = JsonSettingsRepository::new() + .load(&store.base_dir) + .unwrap_or_default(); + let app_config = JsonAppConfigRepository::new() + .load(&store.base_dir) + .unwrap_or_default(); + + let (provider, model) = + crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); + let base_url = app_config + .providers + .get(&provider) + .map(|p| p.api_base.clone()) + .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()); + let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); + + let client = crate::llm::provider::LlmClient::new(api_key, model, Some(base_url)); + + // 2. Build the synthesis prompt. + let system_msg = ChatMessage::system( + "You are a consensus synthesizer for a multi-agent hive mind. \ + Several independent nodes analysed a problem and produced the outputs \ + below. Distill them into ONE coherent consensus report with these \ + sections:\n\ + - AGREEMENTS: points multiple nodes converge on.\n\ + - CONFLICTS: contradictory conclusions, with which node(s) support each side.\n\ + - KEY FINDINGS: the most important, actionable takeaways.\n\ + - RECOMMENDATION: a single recommended next action, or 'no clear consensus' \ + if the outputs are too divergent.\n\ + Be concise and factual. If a node errored, note it and ignore its content.\n\ + Do not invent facts not present in the node outputs.", + ); + let user_msg = ChatMessage::user(format!( + "Consolidate these {} node outputs into a single consensus:\n\n{}", + nodes.len(), + combined + )); + + // 3. Call the model and gracefully degrade on failure. + match call_consensus(&client, &[system_msg, user_msg]).await { + Ok(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + warn!("consensus LLM returned empty output; falling back to concat summary"); + Ok(concat_summary(nodes)) + } else { + Ok(format!( + "# Consensus Synthesis\n\n\ + Nodes synthesized: {}\n\n{}", + nodes.len(), + trimmed + )) + } + } + Err(e) => { + warn!(error = %e, "consensus LLM call failed; falling back to concat summary"); + Ok(concat_summary(nodes)) + } + } +} + +/// Run the LLM consensus call and return the assistant text. +async fn call_consensus( + client: &crate::llm::provider::LlmClient, + messages: &[ChatMessage], +) -> Result { + use zesdex_application::ports::ProviderService; + let (msg, _) = client.chat(messages, None, Some(1024), Some(0.3)).await?; + Ok(msg.content.unwrap_or_default()) +} + +/// Build the concatenated node-output body for the prompt. +fn build_combined_body(nodes: &[NodeOutput]) -> String { let mut combined = String::new(); for node in nodes { + let output = crate::utils::truncate_chars(&node.output, MAX_NODE_OUTPUT_CHARS); combined.push_str(&format!( - "\n## {} — {}\n\n{}\n", - node.id, node.directive, node.output + "\n## {} — {}\n{}\n", + node.id, node.directive, output )); } + combined +} - Ok(format!( - "# Consensus Synthesis\n\ +/// Fallback: a plain concatenation summary (the behaviour of the original stub). +fn concat_summary(nodes: &[NodeOutput]) -> String { + let combined = build_combined_body(nodes); + format!( + "# Consensus Synthesis\n\n\ Nodes synthesized: {}\n\n\ ## Summary\n\ The following node outputs were collected:\n\ @@ -31,5 +131,47 @@ pub async fn synthesize_consensus(nodes: &[NodeOutput], _tool_ctx: &ToolCtx) -> Review the individual node outputs above for detailed findings.", nodes.len(), combined - )) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use zesdex_domain::workflow::NodeOutput; + + fn node(id: &str, output: &str) -> NodeOutput { + NodeOutput { + id: id.to_string(), + directive: "directive".to_string(), + output: output.to_string(), + } + } + + #[test] + fn build_combined_body_truncates_oversized_output() { + let long = "é".repeat(MAX_NODE_OUTPUT_CHARS + 500); + let body = build_combined_body(&[node("n1", &long)]); + // Must contain the header and a char-truncated (<= cap) payload without + // panicking on a multi-byte boundary. + assert!(body.contains("## n1")); + // Header "## n1 — directive\n" ~= 20 chars, so char count stays just above cap. + let char_count = body.chars().count(); + assert!( + char_count <= MAX_NODE_OUTPUT_CHARS + 50, + "expected body near {MAX_NODE_OUTPUT_CHARS} chars, got {char_count}" + ); + assert!( + char_count > 1000, + "expected a many-node output, got small: {char_count}" + ); + } + + #[test] + fn concat_summary_includes_all_node_ids() { + let nodes = vec![node("node-a", "a out"), node("node-b", "b out")]; + let summary = concat_summary(&nodes); + assert!(summary.contains("node-a")); + assert!(summary.contains("node-b")); + assert!(summary.contains("Nodes synthesized: 2")); + } }