# EpiRAG - agents.py # Multi-agent swarm debate engine with real-time SSE callbacks (v2). # # Architecture: # Round 1 - all debate agents respond in parallel (fast, independent views) # Rounds 2+ - agents respond sequentially, each sees full transcript before replying # Final - Epsilon synthesizes, then Zeta audits citations only # # Agent roster (3 debate + 2 support): # Alpha (llama-3.3-70b-versatile) - Skeptic # Beta (openai/gpt-oss-120b) - Deep Reasoner # Gamma (qwen/qwen3.6-27b) - Pattern Connector # Epsilon (openai/gpt-oss-20b) - Synthesizer # Zeta (llama-3.1-8b-instant) - Citation Auditor import concurrent.futures from groq import Groq AGENTS = [ { "name": "Alpha", "model": "llama-3.3-70b-versatile", "provider": "groq", "client_type": "groq", "color": "red", "personality": ( "You are Agent Alpha - a ruthless Skeptic. " "Challenge every claim aggressively. Demand evidence from the sources. " "Point out what is NOT supported. Be blunt and relentless. " "After every factual statement you accept, cite the source: [Source: ]." ) }, { "name": "Beta", "model": "openai/gpt-oss-120b", "provider": "groq", "client_type": "groq", "color": "yellow", "personality": ( "You are Agent Beta - a Deep Reasoner. " "Think step by step. Show your chain of thought. " "Identify hidden assumptions and logical gaps the other agents miss. " "Precision and thoroughness over speed. " "After every factual statement, cite the source: [Source: ]." ) }, { "name": "Gamma", "model": "qwen/qwen3.6-27b", "provider": "groq", "client_type": "groq", "color": "green", "personality": ( "You are Agent Gamma - a Pattern Connector. " "Find non-obvious connections between different sources. " "Look for relationships and synthesis opportunities others miss. " "Think laterally and creatively. " "After every factual statement, cite the source: [Source: ]." ) }, { "name": "Epsilon", "model": "openai/gpt-oss-20b", "provider": "groq", "client_type": "groq", "color": "blue", "personality": ( "You are Agent Epsilon - the Synthesizer. " "Read all agents' arguments and produce a final authoritative answer. " "CRITICAL RULE: If the corpus excerpts don't contain the answer but the topic is " "within epidemic modeling, network science, or mathematical epidemiology - " "synthesize the answer from your own training knowledge. Label such facts as " "[General Knowledge] instead of a paper name. Never say 'cannot answer' for " "in-domain questions. For corpus-sourced facts use [Source: name]. " "Use LaTeX for all mathematical expressions (e.g. $R_0$, $\\beta$)." ) }, { "name": "Zeta", "model": "llama-3.1-8b-instant", "provider": "groq", "client_type": "groq", "color": "orange", "personality": ( "You are Agent Zeta - the Citation Auditor. " "You receive a synthesized answer and the original source excerpts. " "OUTPUT FORMAT (strict): reproduce the answer text exactly as-is, making ONLY " "small inline tweaks where a [Source: name] tag cites a source that is NOT " "in the provided excerpts. In that case, change the tag to [Source: not verified]. " "NEVER output an 'Audit results:' section, bullet list of audit findings, or " "any commentary outside the answer text itself. " "Claims with [General Knowledge] tags - leave completely untouched. " "Claims with NO source tag - leave completely untouched. " "Just output the corrected answer and nothing else." ) }, ] MAX_ROUNDS = 2 MAX_TOKENS_AGENT = 280 MAX_TOKENS_SYNTH = 900 MAX_TOKENS_ZETA = 800 # raised - Zeta was truncating mid-sentence at 500 TIMEOUT_SECONDS = 25 CONTEXT_LIMIT = 2500 # chars fed to synthesizer/zeta AGENT_CONTEXT_LIMIT = 1200 # chars fed to individual debate agents (keep prompts short) DOMAIN_GUARD = """ SCOPE: EpiRAG - strictly epidemic modeling, network science, mathematical epidemiology. If the question is completely off-topic (not related to these fields), say so and stop. If the question IS in-domain but the provided excerpts lack the answer: - Use your training knowledge to answer. - Label each such fact as [General Knowledge] instead of inventing source names. - Do NOT fabricate paper titles or authors. - Never say 'I cannot answer' for in-domain questions - always try. """ def _call_agent(agent, messages, groq_key, hf_token=None, max_tokens=MAX_TOKENS_AGENT): """Call a Groq-hosted agent. hf_token kept for signature compatibility.""" try: client = Groq(api_key=groq_key) resp = client.chat.completions.create( model=agent["model"], messages=messages, temperature=0.7, max_tokens=max_tokens ) return resp.choices[0].message.content.strip() except Exception as e: return f"[{agent['name']} error: {str(e)[:100]}]" def _round1_msgs(agent, question, context): ctx = context[:AGENT_CONTEXT_LIMIT] + "..." if len(context) > AGENT_CONTEXT_LIMIT else context return [ {"role": "system", "content": f"{DOMAIN_GUARD}\n\n{agent['personality']}"}, {"role": "user", "content": ( f"Context from research papers/web:\n\n{ctx}\n\n---\n\n" f"Question: {question}\n\n" f"Answer concisely based on context. Cite sources after claims using [Source: name]. Stay in character." )} ] def _round_n_msgs(agent, question, context, full_transcript: str): """ Rounds 2+: each agent receives the transcript of all previous rounds (sequential mode) for richer context before responding. """ ctx = context[:AGENT_CONTEXT_LIMIT] + "..." if len(context) > AGENT_CONTEXT_LIMIT else context # Cap transcript too - agents only need the gist, not the full wall of text transcript_cap = full_transcript[-2000:] if len(full_transcript) > 2000 else full_transcript return [ {"role": "system", "content": f"{DOMAIN_GUARD}\n\n{agent['personality']}"}, {"role": "user", "content": ( f"Context:\n\n{ctx}\n\nQuestion: {question}\n\n" f"=== DEBATE TRANSCRIPT SO FAR ===\n\n{transcript_cap}\n\n" f"=== YOUR TURN ===\n\n" f"Refine your position. Where do you agree or disagree? Be concise and cite sources." )} ] def _synth_msgs(question, context, all_rounds): transcript = "" for i, rnd in enumerate(all_rounds, 1): transcript += f"\n\n{'='*40}\nROUND {i}\n{'='*40}\n" for name, ans in rnd.items(): transcript += f"\n-- {name} --\n{ans}\n" ctx = context[:CONTEXT_LIMIT] + "..." if len(context) > CONTEXT_LIMIT else context return [ {"role": "system", "content": ( f"{DOMAIN_GUARD}\n\nYou are the Synthesizer. " "Produce the single best final answer following these rules STRICTLY:\n" "1. For facts taken directly from the provided context excerpts, cite with [Source: name].\n" "2. For established domain knowledge (textbook definitions, well-known models, " " standard equations) that is NOT in the excerpts - write it WITHOUT any [Source:] tag. " " Do NOT invent source names. Do NOT write [Source: ...] for things you know from training.\n" "3. Resolve agent disagreements using the strongest evidence from excerpts.\n" "4. Use LaTeX for all math (e.g. $R_0$, $\\beta$, $$\\frac{dI}{dt} = \\beta SI$$).\n" "5. Be concise and structured. End with: CONFIDENCE: HIGH / MEDIUM / LOW" )}, {"role": "user", "content": ( f"Context excerpts from corpus:\n\n{ctx}\n\n---\n\n" f"Question: {question}\n\n---\n\n" f"Debate transcript:{transcript}\n\n---\n\n" f"Produce the final synthesized answer. Only use [Source: name] for things " f"that appear in the context excerpts above." )} ] def _factcheck_msgs(question, context, synthesis): ctx = context[:CONTEXT_LIMIT] + "..." if len(context) > CONTEXT_LIMIT else context # List the excerpt source names so Zeta knows what's available import re as _re source_names = list(dict.fromkeys(_re.findall(r'\[(?:LOCAL|WEB)\]\s*-\s*([^\[\]\(]+?)(?:\s*\(relevance)', ctx))) sources_list = ", ".join(source_names) if source_names else "(see excerpts above)" return [ {"role": "system", "content": f"{DOMAIN_GUARD}\n\n{_get_zeta()['personality']}"}, {"role": "user", "content": ( f"Available source excerpts (source names: {sources_list}):\n\n{ctx}\n\n---\n\n" f"Question: {question}\n\n---\n\n" f"Synthesized answer to audit:\n\n{synthesis}\n\n---\n\n" f"Check only explicit [Source: name] citations. " f"Uncited claims (no [Source:] tag) are established domain knowledge - leave them alone. " f"Output the answer with only small inline corrections where a cited source name " f"is not in the list above." )} ] def _get_zeta(): return next(a for a in AGENTS if a["name"] == "Zeta") def _converged(answers): agree = ["i agree", "correct", "you're right", "i concur", "well said", "exactly", "this is accurate", "that's right"] hits = sum(1 for a in answers.values() if any(p in a.lower() for p in agree)) return hits >= len(answers) * 0.5 def _build_transcript(all_rounds: list[dict]) -> str: """Build a full text transcript of all rounds completed so far.""" lines = [] for i, rnd in enumerate(all_rounds, 1): lines.append(f"=== Round {i} ===") for name, ans in rnd.items(): lines.append(f"-- {name} --\n{ans}") lines.append("") return "\n".join(lines) def run_debate(question, context, groq_key, hf_token, callback=None): """ Run the full multi-agent swarm debate (v2). Flow: Round 1 - parallel (independent first thoughts) Rounds 2-N - sequential (each agent sees full transcript before responding) Synthesis - Epsilon synthesizes Fact-check - Zeta verifies citations callback(event: dict) is called after each agent responds, enabling SSE streaming. event shapes: {"type": "agent_done", "round": int, "name": str, "color": str, "text": str} {"type": "round_start", "round": int} {"type": "synthesizing"} {"type": "factchecking"} {"type": "done", "consensus": bool, "rounds": int} Returns: {"final_answer", "debate_rounds", "consensus", "rounds_run", "agent_count"} """ def emit(event): if callback: callback(event) debate_agents = [a for a in AGENTS if a["name"] not in ("Epsilon", "Zeta")] synthesizer = next(a for a in AGENTS if a["name"] == "Epsilon") fact_checker = _get_zeta() agent_colors = {a["name"]: a["color"] for a in AGENTS} debate_rounds = [] # -- Round 1: Parallel emit({"type": "round_start", "round": 1}) round1 = {} with concurrent.futures.ThreadPoolExecutor(max_workers=len(debate_agents)) as ex: futures = { ex.submit(_call_agent, agent, _round1_msgs(agent, question, context), groq_key, hf_token): agent for agent in debate_agents } for future in concurrent.futures.as_completed(futures, timeout=TIMEOUT_SECONDS * 2): agent = futures[future] try: answer = future.result(timeout=TIMEOUT_SECONDS) except Exception as e: answer = f"[{agent['name']} timed out: {e}]" round1[agent["name"]] = answer emit({"type": "agent_done", "round": 1, "name": agent["name"], "color": agent_colors[agent["name"]], "text": answer}) debate_rounds.append(round1) consensus = _converged(round1) rounds_run = 1 # -- Rounds 2+: Sequential (each agent sees full transcript) while not consensus and rounds_run < MAX_ROUNDS: rounds_run += 1 emit({"type": "round_start", "round": rounds_run}) full_transcript = _build_transcript(debate_rounds) next_round = {} for agent in debate_agents: msgs = _round_n_msgs(agent, question, context, full_transcript) answer = _call_agent(agent, msgs, groq_key, hf_token) next_round[agent["name"]] = answer emit({"type": "agent_done", "round": rounds_run, "name": agent["name"], "color": agent_colors[agent["name"]], "text": answer}) # Update transcript between agents so each sees the others' answers debate_rounds_temp = debate_rounds + [next_round] full_transcript = _build_transcript(debate_rounds_temp) debate_rounds.append(next_round) consensus = _converged(next_round) # -- Synthesis emit({"type": "synthesizing"}) ctx_trunc = context[:4000] if len(context) > 4000 else context synthesis = _call_agent( synthesizer, _synth_msgs(question, ctx_trunc, debate_rounds), groq_key, hf_token, max_tokens=MAX_TOKENS_SYNTH ) # -- Fact-check emit({"type": "factchecking"}) final = _call_agent( fact_checker, _factcheck_msgs(question, ctx_trunc, synthesis), groq_key, hf_token, max_tokens=MAX_TOKENS_ZETA ) emit({"type": "done", "consensus": consensus, "rounds": rounds_run}) return { "final_answer": final, "debate_rounds": debate_rounds, "consensus": consensus, "rounds_run": rounds_run, "agent_count": len(debate_agents) }