import os
import re
import json
from datetime import datetime
from typing import List, Dict, Any, Optional
from collections import defaultdict, Counter
import numpy as np
from src.agent import Query, Agent
from src.models import LanguageModel
from src.checker import ANSWER_CHECKER
from src.paths import MEMORY_DATA_DIR
from src.prompts import DEBATE_SUMMARY_PROMPT
def remove_think_tags(text: str) -> str:
return re.sub(r'.*?', '', text, flags=re.DOTALL)
class MultiAgentDebate:
"""
Orchestrates a multi-round debate for a batch of queries.
A fixed list of Agent instances is shared across all queries; each agent
stores conversation history per query_id internally, so no duplication
of agent objects is needed.
Round 0 : every agent answers independently.
Round 1+: every agent sees the other agents' previous responses and revises.
All queries are processed in a single batched model call per round;
the batch sent to LanguageModel has shape (num_active_queries * num_agents).
"""
def __init__(
self,
agents: List[Agent],
model: LanguageModel,
task_type: str,
max_round: int = 3,
consensus_threshold: float = 0.8,
if_summarize_state: bool = False,
if_confidence_weight: bool = False,
) -> None:
self.agents = agents
self.model = model
self.max_round = max_round
self.consensus_threshold = consensus_threshold
self.if_summarize_state = if_summarize_state
self.if_confidence_weight = if_confidence_weight
self.task_type = task_type
self._checker = ANSWER_CHECKER[task_type]
# Persistent debate state (reset each `run`)
self.current_rounds = {}
self.agent_responses = defaultdict(lambda: defaultdict(dict))
self.consensus_flags = {}
self.debate_log = {}
# prev_resp_embeddings[qid][agent_id_str] = unit-norm float32 ndarray
self._prev_resp_embeddings = defaultdict(lambda: dict())
# ------------------------------------------------------------------
# Public entry point
# ------------------------------------------------------------------
def run(self, queries: List[Query], is_training: bool = True, batch_idx: int = 0) -> List[Dict[str, Any]]:
"""
Run a full debate for a batch of queries.
Args:
queries: list of Query objects
Returns:
list of debate_log entries (one per query), each with keys:
query, rounds, final_answer, consensus
"""
self._clear_debate()
self._initialize_debate(queries)
for round_idx in range(self.max_round):
if is_training:
# force all queries to run all rounds during training
active_queries = queries
else:
active_queries = [q for q in queries if not self.consensus_flags[q.query_id]]
if not active_queries:
break
print(f"\n>>>>>> Round {round_idx}\n")
self._run_debate_round(active_queries)
for query in queries:
qid = query.query_id
last_round = self.current_rounds[qid] - 1
correct_count, error_count = 0, 0
final_true_answer, final_false_answer = None, None
for agent in self.agents:
if self.agent_responses[qid][agent.agent_id][f"round_{last_round}"]["is_correct"]:
correct_count += 1
final_true_answer = self.agent_responses[qid][agent.agent_id][f"round_{last_round}"]["pred_answer"]
else:
error_count += 1
final_false_answer = self.agent_responses[qid][agent.agent_id][f"round_{last_round}"]["pred_answer"]
is_final_correct = (correct_count > error_count)
final_answer = final_true_answer if is_final_correct else final_false_answer
self.debate_log[qid]["final_answer"] = final_answer
self.debate_log[qid]["is_final_correct"] = is_final_correct
self.debate_log[qid]["consensus"] = self.consensus_flags[qid]
if is_training:
self._save_memory_data(queries, batch_idx=batch_idx)
return [self.debate_log[q.query_id] for q in queries]
# ------------------------------------------------------------------
# Round execution
# ------------------------------------------------------------------
def _run_debate_round(self, queries: List[Query]) -> None:
"""
Run one debate round for the given active queries.
For round 0, each agent receives no prior responses (initial answer).
For round > 0, each agent receives the other agents' responses from the previous round, read from self.agent_responses.
"""
# Build batched messages: [q0_a0, q0_a1, ..., q1_a0, q1_a1, ...]
batch_messages = []
flat_meta = [] # (query, agent_idx)
# Precompute per-query context maps for batch memory retrieval
prev_responses_map: Dict[int, Any] = {}
prev_round_summaries: Dict[int, Any] = {}
prev_consensus_ratios: Dict[int, Any] = {}
for query in queries:
qid = query.query_id
round_idx = self.current_rounds[qid]
rounds_log = self.debate_log[qid]["rounds"]
if round_idx == 0:
prev_responses_map[qid] = None
prev_round_summaries[qid] = None
prev_consensus_ratios[qid] = None
else:
emb_cache = self._prev_resp_embeddings.get(qid, {}) if self.if_confidence_weight else {}
prev_responses_map[qid] = {
agent.agent_id: (
self.agent_responses[qid][agent.agent_id][f"round_{round_idx - 1}"].get("response", ""),
emb_cache.get(agent.agent_id, None),
) for agent in self.agents
}
prev_round_summaries[qid] = rounds_log[-1].get("summary", None)
prev_consensus_ratios[qid] = rounds_log[-1].get("consensus_ratio", None)
# Batch memory retrieval: one DB round-trip per agent instead of per (agent, query)
agent_prefetched = {}
for agent in self.agents:
agent_prefetched[agent.agent_id] = agent.batch_retrieve_all(
queries=queries,
prev_responses_map={
qid: resp for qid, resp in prev_responses_map.items() if resp is not None
},
prev_round_summaries=prev_round_summaries,
prev_consensus_ratios=prev_consensus_ratios,
)
for query in queries:
qid = query.query_id
prev_responses = prev_responses_map[qid]
prev_round_summary = prev_round_summaries[qid]
prev_consensus_ratio = prev_consensus_ratios[qid]
for a_idx, agent in enumerate(self.agents):
prefetched = agent_prefetched[agent.agent_id].get(qid)
messages = agent.build_messages(
query=query,
prev_responses=prev_responses,
prev_round_summary=prev_round_summary,
prev_consensus_ratio=prev_consensus_ratio,
prefetched=prefetched,
)
batch_messages.append(messages)
flat_meta.append((query, a_idx))
model_output = self.model(batch_messages, task_name="debate")
responses = [remove_think_tags(r).strip() for r in model_output["responses"]]
# Batch-encode all responses once for confidence weighting in the next round
resp_embs = None
if self.if_confidence_weight:
embed_fn = next(
(a.memory_db.embedding_model.encode for a in self.agents if a.memory_db is not None), None,
)
if embed_fn is not None:
resp_embs = np.array(embed_fn(responses), dtype=np.float32)
# Process and store per-agent results into agent_responses
for i, (query, a_idx) in enumerate(flat_meta):
qid = query.query_id
round_idx = self.current_rounds[qid]
pred_answer = self._checker.parse_answer(responses[i])
is_correct = self._checker.check_answer(pred_answer, query.answer)
agent = self.agents[a_idx]
agent.record_response(query, responses[i])
self.agent_responses[qid][agent.agent_id][f"round_{round_idx}"] = {
"response": responses[i],
"pred_answer": pred_answer,
"is_correct": is_correct,
}
if resp_embs is not None:
self._prev_resp_embeddings[qid][agent.agent_id] = resp_embs[i]
# Check consensus and log each active query
for query in queries:
qid = query.query_id
round_idx = self.current_rounds[qid]
pred_answers = [
self.agent_responses[qid][agent.agent_id][f"round_{round_idx}"]["pred_answer"]
for agent in self.agents
]
is_consensus, consensus_ratio, common_answer = self._check_consensus(query, pred_answers)
agents_log = []
for agent in self.agents:
round_data = self.agent_responses[qid][agent.agent_id][f"round_{round_idx}"]
entry = {
"agent_id": str(agent.agent_id),
"response": round_data["response"],
"pred_answer": round_data["pred_answer"],
"is_correct": round_data["is_correct"],
}
# prefetched is (retrieved_memories, other_confidences), or None at 0
prefetched = agent_prefetched.get(agent.agent_id, {}).get(qid)
retrieved, confidences = prefetched if prefetched else (None, None)
# Memories this agent retrieved to produce this round's response.
if agent.if_use_memory:
entry["retrieved_memories"] = [{
"content": rm.content,
"is_correct": rm.is_correct,
"similarity": rm.similarity,
} for rm in (retrieved or [])]
# Confidence this agent assigned to each peer's previous response.
if self.if_confidence_weight:
entry["peer_confidences"] = {
str(peer_id): float(conf) for peer_id, conf in (confidences or {}).items()
}
agents_log.append(entry)
self.debate_log[qid]["rounds"].append({
"round": round_idx,
"agents": agents_log,
"common_answer": common_answer,
"consensus": is_consensus,
"consensus_ratio": consensus_ratio,
})
if is_consensus:
self.consensus_flags[qid] = True
self.current_rounds[qid] += 1
if self.if_summarize_state:
print(f"\n>>>>>> Summarize Debate State\n")
self._summarize_debate_state(queries)
def _summarize_debate_state(self, queries: List[Query]) -> None:
"""
Generate debate summaries for the just-completed round across all queries
in a single batched model call, then write results back to debate_log.
"""
# Build one summary prompt per query, tracking which round each refers to
batch_messages = []
meta = [] # (qid, round_log_index)
for query in queries:
rounds = self.debate_log[query.query_id]["rounds"]
round_log = rounds[-1]
round_idx = len(rounds) - 1
prev_summary = rounds[-2]["summary"] if round_idx > 0 else "None"
agent_responses = []
for i, entry in enumerate(round_log["agents"]):
if round_idx > 0:
prev_entry = rounds[-2]["agents"][i]
stance_changed = str(entry["pred_answer"] != prev_entry["pred_answer"]).lower()
agent_responses.append(
f'\n'
f'\n{entry["response"]}\n\n'
f'\n{entry["pred_answer"]}\n\n'
f'\n{prev_entry["pred_answer"]}\n\n'
f'\n{stance_changed}\n\n'
f''
)
else:
agent_responses.append(
f'\n'
f'\n{entry["response"]}\n\n'
f'\n{entry["pred_answer"]}\n\n'
f''
)
agent_responses_text = "\n".join(agent_responses)
prompt = DEBATE_SUMMARY_PROMPT.format(
QUESTION=query.content,
PREV_SUMMARY=prev_summary,
AGENT_RESPONSES=agent_responses_text,
CONSENSUS_RATIO=f"{round_log['consensus_ratio']:.2f}",
)
batch_messages.append([{"role": "user", "content": prompt}])
meta.append((query.query_id, round_idx))
if not batch_messages:
return
model_output = self.model(batch_messages, task_name="summary")
for (qid, round_idx), summary in zip(meta, model_output["responses"]):
self.debate_log[qid]["rounds"][round_idx]["summary"] = remove_think_tags(summary).strip()
def _check_consensus(self, query: Query, pred_answers: List[str]) -> tuple[bool, float, str]:
"""
Check if all agents have reached consensus on the same answer.
Returns:
is_consensus: bool
consensus_ratio: float (fraction of agents that agreed on the most common answer)
common_answer: str (the answer that the majority agreed on, or None if no consensus)
"""
if not pred_answers:
return False, 0.0, ""
qid = query.query_id
round_idx = self.current_rounds[qid]
normalized_answers = [answer.lower().strip() for answer in pred_answers]
correct_count = 0
for agent in self.agents:
if self.agent_responses[qid][agent.agent_id][f"round_{round_idx}"]["is_correct"]:
correct_count += 1
if correct_count == len(pred_answers):
return True, 1.0, normalized_answers[0]
answer_counts = Counter(normalized_answers)
common_answer, max_count = answer_counts.most_common(1)[0]
consensus_ratio = max_count / len(pred_answers)
is_consensus = consensus_ratio >= self.consensus_threshold
return is_consensus, consensus_ratio, common_answer
# ------------------------------------------------------------------
# Debate lifecycle
# ------------------------------------------------------------------
def _initialize_debate(self, queries: List[Query]) -> None:
for query in queries:
self.current_rounds[query.query_id] = 0
self.debate_log[query.query_id] = {
"query": query.content,
"answer": query.answer,
"rounds": []
}
self.consensus_flags[query.query_id] = False
for agent in self.agents:
agent.initialize_conversation(query)
def _clear_debate(self) -> None:
self.current_rounds.clear()
self.agent_responses.clear()
self.debate_log.clear()
self.consensus_flags.clear()
self._prev_resp_embeddings.clear()
for agent in self.agents:
agent.clear_all_conversations()
# ------------------------------------------------------------------
# data saving utilities
# ------------------------------------------------------------------
def _save_memory_data(self, queries: List[Query], batch_idx: int = 0) -> None:
"""
After a completed debate, write one JSON line per (query, agent, round)
to MEMORY_DATA_DIR//agent{n}_batch{idx:03d}_{timestamp}.jsonl.
"""
task_dir = os.path.join(MEMORY_DATA_DIR, self.task_type, self.model.model_name)
os.makedirs(task_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"agent{len(self.agents)}_batch{batch_idx:03d}_{timestamp}.jsonl"
filepath = os.path.join(task_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
for query in queries:
qid = query.query_id
log = self.debate_log[qid]
is_final_correct = log["is_final_correct"]
rounds_log = log["rounds"]
num_rounds = len(rounds_log)
for agent in self.agents:
aid = agent.agent_id
for round_idx in range(num_rounds):
round_data = self.agent_responses[qid][aid][f"round_{round_idx}"]
current_response = round_data["response"]
if not current_response:
continue
is_current_correct = round_data["is_correct"]
if round_idx > 0:
prev_response = self.agent_responses[qid][aid][f"round_{round_idx - 1}"]["response"]
prev_pred = self.agent_responses[qid][aid][f"round_{round_idx - 1}"]["pred_answer"]
stance_changed = (prev_pred != round_data["pred_answer"])
prev_consensus_ratio = rounds_log[round_idx - 1]["consensus_ratio"]
prev_round_summary = rounds_log[round_idx - 1].get("summary", None)
else:
prev_response = None
stance_changed = None
prev_consensus_ratio = None
prev_round_summary = None
memory_case = {
"db_id": f"Q{qid}_Round{round_idx}_{aid}",
"query_id": qid,
"agent_id": str(aid),
"round": round_idx,
"query": query.content,
"answer": query.answer,
"solution": query.solution,
"category": query.category,
"prev_response": prev_response,
"prev_round_summary": prev_round_summary,
"prev_consensus_ratio": prev_consensus_ratio,
"current_response": current_response,
"is_current_correct": is_current_correct,
"stance_changed": stance_changed,
"is_final_correct": is_final_correct,
}
f.write(json.dumps(memory_case, ensure_ascii=False) + "\n")
def save_debate_log(self, filename: str) -> None:
os.makedirs(os.path.dirname(filename), exist_ok=True)
with open(filename, "w", encoding="utf-8") as f:
json.dump(self.debate_log, f, ensure_ascii=False, indent=2)
def get_token_usage_summary(self):
return self.model.get_token_usage_summary()