epirag / agents.py
RohanB67's picture
Update agents.py
a2e9180 verified
Raw
History Blame Contribute Delete
14.6 kB
# 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>]."
)
},
{
"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>]."
)
},
{
"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>]."
)
},
{
"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)
}