Building a Multi-Agent Debate Simulation
Introduction
This post demonstrates how to simulate a political debate using large language models and R. The example builds a hypothetical debate for the 2025 Albuquerque mayoral race, with each candidate represented by an LLM agent that responds based on their official questionnaire submissions. A moderator agent manages the debate flow, poses questions, and facilitates candidate exchanges.
The approach is straightforward and offers a practical template for simulating multi-party dialogues where each participant maintains a distinct perspective grounded in source material.
The Candidates
- Daniel Chavez - Small business owner
- Darren White - Former public safety director
- Mayling Armijo - Community advocate
- Eddie Varela - Business owner
- Louie Sanchez - City Councilor
Note: Not all candidates in the race completed questionnaires, including incumbent Mayor Tim Keller, who is not included in this simulation.
Setup and Dependencies
Required R libraries are loaded using pacman. Candidate questionnaire file locations are mapped to their GitHub URLs.
if (!requireNamespace("pacman", quietly = TRUE)) {
install.packages("pacman")
}
suppressPackageStartupMessages(
pacman::p_load(
ellmer,
glue,
purrr,
jsonlite
)
)
# Base URL for candidate questionnaires on GitHub
QUESTIONNAIRE_BASE_URL <- "https://raw.githubusercontent.com/jaytimm/newmexico-political-data/main/data/abq_mayor_2025_questionnaires"
# Candidate file mapping with GitHub URLs
CANDIDATE_FILES <- list(
"Daniel Chavez" = paste0(QUESTIONNAIRE_BASE_URL, "/Official.Daniel.Chavez.Response_250609.md"),
"Darren White" = paste0(QUESTIONNAIRE_BASE_URL, "/Official.White_.Darren.Response._250609.md"),
"Mayling Armijo" = paste0(QUESTIONNAIRE_BASE_URL, "/Armijo.Mayling.Response_250609.md"),
"Eddie Varela" = paste0(QUESTIONNAIRE_BASE_URL, "/Eddie-Varela-Mayoral-Candidate-Response.md"),
"Louie Sanchez" = paste0(QUESTIONNAIRE_BASE_URL, "/Sanchez.Louie_.Response_250609.md")
)Candidate Questionnaires
Questionnaire responses start as PDFs converted to markdown using docling. Markdown files are stored on GitHub, with original data from the New Mexico Business Coalition’s 2025 Albuquerque Mayoral Candidates page. The code reads each candidate’s questionnaire from GitHub and stores the content for agent system prompts.
# Load all candidate questionnaires from GitHub
candidates_data <- map(names(CANDIDATE_FILES), function(name) {
url <- CANDIDATE_FILES[[name]]
tryCatch({
questionnaire_text <- readLines(url, warn = FALSE) |> paste(collapse = "\n")
list(
name = name,
file = basename(url),
questionnaire = questionnaire_text
)
}, error = function(e) {
warning("Failed to load questionnaire for ", name, ": ", e$message)
return(NULL)
})
}) |>
purrr::compact()LLM Agents
Each candidate is represented by an LLM agent with system prompts including their questionnaire responses. An orchestrator agent serves as the debate moderator, managing flow and determining speaking order.
Candidate Agents
Candidate agents are created with system prompts that include questionnaire responses, other candidate names, and instructions to stay in character and highlight key policies with bold markdown. Agents use ChatGPT GPT-4o-mini. A lookup table provides quick access by candidate name. Note that for topics not fully covered in the questionnaire (like Revitalizing Nob Hill), candidates are free to respond based on their knowledge and perspective as a mayoral candidate, not constrained to only questionnaire content.
# Create candidate agents
candidate_agents <- map(candidates_data, function(cand) {
other_candidates <- Filter(function(n) n != cand$name, map_chr(candidates_data, ~ .x$name))
system_prompt <- glue(
"You are {cand$name}, running for Mayor of Albuquerque, New Mexico.
Here are your positions from your official candidate questionnaire:
{cand$questionnaire}
The other candidates in this debate are: {paste(other_candidates, collapse=', ')}
NOTE: Some debate topics may not be fully covered in your questionnaire. For those topics, you are free to respond based on your knowledge and perspective as a mayoral candidate for Albuquerque - don't feel constrained to only questionnaire content.
CRITICAL INSTRUCTIONS:
- You ONLY know what other candidates have said from the debate transcript provided to you
- You do NOT know their questionnaire responses - you can only reference what they've actually said in this debate
- When responding, ONLY reference statements that have been made in the debate transcript you receive
- Stay in character and respond naturally based on YOUR stated positions
- Answer questions directly - do not dodge or avoid the question asked
- Keep responses brief and concise - 2-4 sentences maximum
- NEVER say 'Thank you for the question' - start directly with your answer
- Be confrontational and antagonistic - directly challenge and disagree with other candidates' proposals when you don't agree
- Push back on ideas you disagree with - don't be overly polite or diplomatic
- Show emotion - get upset, frustrated, or passionate when you disagree strongly or feel misrepresented
- Engage with relevant things other candidates have said - reference their statements, challenge their ideas, build on or disagree with their proposals when relevant
- Be conversational and debate-like, not formal speeches
- Jump straight into your answer without pleasantries
- IMPORTANT: When you mention key ideas, proposals, or specific policies from YOUR questionnaire, format them in **bold** using markdown (e.g., **Home Basis Centers**, **streamline permits**, **decriminalize sleep**). This helps highlight your core positions.
- Do NOT include your name or 'I am [name]' in your response - just respond directly as the character."
)
agent <- chat("openai/gpt-4o-mini", system_prompt = system_prompt)
return(list(name = cand$name, agent = agent))
})
# Create lookup by name for easy access
candidate_lookup <- setNames(candidate_agents, map_chr(candidate_agents, ~ .x$name))Orchestrator Agent
The orchestrator agent uses ChatGPT GPT-4o-mini and a JSON schema to output structured commands. The schema defines actions (ask_one, request_rebuttal, end_debate), candidate names, and moderator statements. It ensures all candidates speak on each topic before moving to the next.
candidate_names <- map_chr(candidate_agents, ~ .x$name)
debate_topics <- list(
"Homelessness" = "homelessness",
"Economic Development" = "economic_development"
)
orchestrator_schema <- ellmer::type_object(
"Orchestrator command structure",
action = ellmer::type_string("Action: 'ask_one' (moderator asks), 'request_rebuttal' (candidate responds directly to another), 'end_debate'"),
candidate = ellmer::type_string("Exact name of the candidate to speak next"),
moderator_statement = ellmer::type_string("If ask_one: moderator statement. If request_rebuttal: brief context/prompt for direct response."),
responding_to = ellmer::type_string("If request_rebuttal: name of candidate being responded to. Otherwise empty.")
)
topic_list <- paste(seq_along(debate_topics), names(debate_topics), sep = ". ", collapse = " (3-4 rounds each), ")
orchestrator_prompt <- glue(
"You are orchestrating a mayoral debate between: {paste(candidate_names, collapse=', ')}. Use a mix of first names and full names when referencing candidates - don't always use first names.",
"",
"Topics: {topic_list}",
"",
"Moderate naturally. Keep it casual and conversational. Ensure all candidates speak on each topic.",
"",
"CRITICAL: Check the transcript carefully. If NO candidates have spoken yet on the current topic, ask a fresh opening question about the topic. Do NOT reference things candidates said if they haven't spoken yet on this topic. Only reference candidate statements that actually appear in the transcript for the current topic. Keep questions brief - one sentence only.",
"",
"Actions: 'ask_one' = moderator asks a question. 'request_rebuttal' = candidate responds directly to another candidate (no moderator statement).",
"",
"candidate = exact name. If request_rebuttal, set responding_to to the candidate being responded to.",
sep = "\n"
)
orchestrator <- chat("openai/gpt-4o-mini", system_prompt = orchestrator_prompt)Helper Functions
Utility functions handle transcript formatting and parsing orchestrator commands into structured data.
Format Transcript for Display
Transcript entries are formatted as markdown strings with speaker labels, with each entry showing the speaker name in bold and concatenated with newlines.
Format Transcript for HTML Output
Transcript entries are formatted as HTML with distinct background colors for each speaker, creating colored boxes with darker left borders. Whitespace is cleaned and each statement is wrapped in styled div elements.
format_transcript_html <- function(transcript) {
speaker_colors <- list(
"MODERATOR" = "#f0f0f0",
"Daniel Chavez" = "#ffe6e6",
"Darren White" = "#cce6ff",
"Mayling Armijo" = "#fff4e6",
"Eddie Varela" = "#f0e6ff",
"Louie Sanchez" = "#e6ffe6"
)
darken_color <- function(color) {
rgb_col <- col2rgb(color)
darkened <- rgb_col * 0.7
rgb(darkened[1], darkened[2], darkened[3], maxColorValue = 255)
}
map_chr(transcript, function(entry) {
clean_text <- gsub("\n\n+", "\n\n", entry$text)
bg_color <- if(is.null(speaker_colors[[entry$speaker]])) "#f5f5f5" else speaker_colors[[entry$speaker]]
speaker_label <- if (entry$speaker == "MODERATOR") {
"MODERATOR"
} else {
entry$speaker
}
glue(
'<div style="background-color: {bg_color}; padding: 15px; border-radius: 5px; margin-bottom: 15px; border-left: 4px solid {darken_color(bg_color)};">',
'<strong>{speaker_label}:</strong><br>',
'{clean_text}',
'</div>'
)
}) |> paste(collapse = "\n")
}Get Last N Transcript Entries
The most recent N entries from the transcript are formatted to provide context to the orchestrator during the debate flow.
Parse Orchestrator Command
Orchestrator responses are parsed into structured data using the JSON schema, extracting actions, candidate names, and moderator statements. Default values are used for optional fields.
parse_orchestrator_command <- function(orchestrator, input_text) {
command <- orchestrator$chat_structured(input_text, type = orchestrator_schema)
list(
action = if(is.null(command$action)) "ask_one" else command$action,
candidate = command$candidate,
moderator_statement = if(is.null(command$moderator_statement)) "Please share your perspective." else command$moderator_statement,
responding_to = if(is.null(command$responding_to)) "" else command$responding_to
)
}The Debate Function
The debate simulation runs through topics, ensuring all candidates speak on each topic before moving to the next. It tracks speaking status and automatically transitions between topics. The function initializes the transcript, enters a loop providing context to the orchestrator, executes commands (asking questions or requesting rebuttals), and records responses. The orchestrator generates a closing statement when the debate ends.
run_debate <- function(topics = debate_topics, max_rounds = 15) {
topic_keys <- unlist(unname(topics))
topic_names <- names(topics)
transcript <- list()
round <- 1
current_topic_idx <- 1
current_topic_key <- topic_keys[current_topic_idx]
candidates_spoken <- map(topic_keys, ~ setNames(rep(FALSE, length(candidate_names)), candidate_names))
names(candidates_spoken) <- topic_keys
topic_list_text <- paste(topic_names, collapse = ", ")
intro_text <- paste(
"Good evening. Welcome to the Albuquerque Mayoral Debate.",
"",
paste("Tonight we have", length(candidate_names), "candidates:",
paste(candidate_names, collapse = ", ")),
"",
paste("This debate will focus on", length(topics), "critical issues:"),
topic_list_text,
"",
"Let's begin.",
sep = "\n"
)
transcript <- append(transcript, list(list(speaker = "MODERATOR", text = intro_text)))
while (round <= max_rounds) {
if (current_topic_idx <= length(topic_keys) && all(candidates_spoken[[current_topic_key]])) {
current_topic_idx <- current_topic_idx + 1
if (current_topic_idx > length(topic_keys)) break
current_topic_key <- topic_keys[current_topic_idx]
}
current_transcript <- tail_transcript(transcript, n = 5)
remaining <- names(candidates_spoken[[current_topic_key]])[!candidates_spoken[[current_topic_key]]]
topic_status <- glue(
"CURRENT TOPIC: {toupper(topic_names[current_topic_idx])}. ",
"Remaining: {if(length(remaining)>0) paste(remaining, collapse=', ') else 'All have spoken'}"
)
# Check if anyone has spoken on current topic
current_topic_entries <- transcript[which(sapply(transcript, function(e) {
# Find when current topic started (after previous topic ended or debate started)
topic_start_idx <- if (current_topic_idx == 1) 1 else {
prev_topic_key <- topic_keys[current_topic_idx - 1]
# Find last entry where all candidates spoke on previous topic
last_prev_idx <- 0
for (i in seq_along(transcript)) {
if (transcript[[i]]$speaker != "MODERATOR" &&
any(sapply(candidate_names, function(n) transcript[[i]]$speaker == n))) {
last_prev_idx <- i
}
}
last_prev_idx + 1
}
# Check if this entry is after topic started
entry_idx <- which(sapply(seq_along(transcript), function(i) identical(transcript[[i]], e)))
entry_idx >= topic_start_idx
}))]
has_spoken_on_topic <- any(sapply(current_topic_entries, function(e)
e$speaker != "MODERATOR" && e$speaker %in% candidate_names))
if (has_spoken_on_topic) {
orchestrator_input <- glue(
"Current debate transcript:\n\n{current_transcript}\n\n",
"{topic_status}\n\n",
"Candidates have already spoken on this topic. Reference relevant things they said in the transcript. Build on their statements, ask follow-ups about their proposals, or challenge their ideas. Output your command in the required format."
)
} else {
orchestrator_input <- glue(
"Current debate transcript:\n\n{current_transcript}\n\n",
"{topic_status}\n\n",
"This is the FIRST question on this topic - no candidates have spoken yet. Ask a fresh opening question about {topic_names[current_topic_idx]}. Do NOT reference things candidates said (they haven't spoken on this topic yet). Output your command in the required format."
)
}
command <- parse_orchestrator_command(orchestrator, orchestrator_input)
if (command$action == "end_debate") break
target_name <- command$candidate
target_cand <- candidate_lookup[[target_name]]
if (is.null(target_cand)) {
remaining <- names(candidates_spoken[[current_topic_key]])[!candidates_spoken[[current_topic_key]]]
if (length(remaining) == 0) remaining <- candidate_names
target_name <- sample(remaining, 1)
target_cand <- candidate_lookup[[target_name]]
}
context <- format_transcript(transcript)
if (command$action == "request_rebuttal" && nchar(command$responding_to) > 0) {
responding_to_name <- command$responding_to
prompt_text <- command$moderator_statement
full_prompt <- glue(
"Here is the complete debate transcript so far:\n\n{context}\n\n",
"{prompt_text}\n\n",
"You are responding directly to {responding_to_name}. Be conversational and debate-like.",
"CRITICAL: You can ONLY reference what other candidates have said in the transcript above."
)
response <- target_cand$agent$chat(full_prompt)
transcript <- append(transcript, list(list(speaker = target_name, text = response)))
} else {
moderator_text <- command$moderator_statement
transcript <- append(transcript, list(list(speaker = "MODERATOR", text = moderator_text)))
full_prompt <- glue(
"Here is the complete debate transcript so far:\n\n{context}\n\n",
"MODERATOR: {moderator_text}\n\n",
"CRITICAL: You can ONLY reference what other candidates have said in the transcript above. ",
"Engage with what other candidates have said - challenge their ideas, disagree with their proposals, or build on their statements. ",
"Do not reference ideas or positions that haven't been mentioned in this debate yet. ",
"Respond naturally based on the transcript and your own positions."
)
response <- target_cand$agent$chat(full_prompt)
transcript <- append(transcript, list(list(speaker = target_name, text = response)))
}
candidates_spoken[[current_topic_key]][target_name] <- TRUE
if (all(map_lgl(candidates_spoken, ~ all(.x)))) break
round <- round + 1
Sys.sleep(0.5)
}
# Moderator closing statement
closing_text <- "Thank you to all the candidates for participating in tonight's debate. We appreciate your time and perspectives on these critical issues facing Albuquerque."
transcript <- append(transcript, list(list(speaker = "MODERATOR", text = closing_text)))
return(transcript)
}Running the Debate
With all the code above included, you can run the debate directly. Note: This requires an active OpenAI API key with billing enabled. The code checks if a transcript markdown file already exists. If it exists, the transcript is parsed from markdown; otherwise, the debate runs and saves the transcript to the post-specific folder.
Debate Transcript
Note: This is an LLM simulation, not real debate content. All responses are AI-generated based on candidate questionnaire data.
The complete debate transcript is displayed below, with each speaker’s statements shown in distinct colored boxes.
MODERATOR:
Good evening. Welcome to the
Albuquerque Mayoral Debate.
Tonight we have 5 candidates: Daniel Chavez, Darren White, Mayling Armijo, Eddie Varela, Louie Sanchez
This debate will focus on 2 critical issues: Homelessness, Economic Development
Let’s begin.To kick things off, how do you view the current state of homelessness in Albuquerque, and what specific measures do you propose to address this issue?
Daniel Chavez:
The current state of
homelessness in Albuquerque is dire, and it’s clear that the
previous attempts just haven’t been effective. Many candidates
seem to throw around ideas without understanding the root
issues. For instance, simply adding more temporary shelters
hasn’t addressed the underlying problem—many people don’t have
stable support systems.
Darren White, you’ve been listening to Daniel Chavez’s proposals regarding Home Basis Centers and decriminalizing sleep. What are your thoughts on his approach and how would you address the systemic issues surrounding homelessness in Albuquerque?
Daniel, your approach of Home Basis Centers and decriminalizing sleep demonstrates a fundamental misunderstanding of the urgency of the situation. We’ve poured nearly $300 million into homeless programs with little to show for it. Your idea to add more temporary shelters and provide a safety net without enforcing the laws is reckless. Compassion doesn’t mean allowing chaos on our streets. We need to enforce existing laws against illegal encampments and ensure that people experiencing homelessness who refuse help aren’t allowed to set up camp in public spaces. It’s about restoring order, not enabling a cycle of dependency.
Mayling Armijo, you’ve heard the perspectives from both Daniel Chavez and Darren White. What’s your view on the effectiveness of their strategies, and what solutions do you think are crucial for addressing homelessness in Albuquerque?
Mayling Armijo:
Darren, your approach
is misguided. Simply enforcing laws without addressing the
root causes of homelessness will only push people deeper into
crisis. We need to support expanding legal safe
outdoor spaces and focus on the unique needs of those
experiencing homelessness rather than treating them as a
nuisance.
Eddie Varela, you’ve heard the differing views from Daniel, Darren, and Mayling. What solutions do you propose to effectively address the homelessness crisis in Albuquerque, considering the effectiveness of temporary shelters and legal safe outdoor spaces?
Eddie Varela:
The proposals put forth
by Daniel and Mayling, such as Home Basis
Centers and expanding legal safe outdoor
spaces, seem to sidestep the urgent reality we’re
facing. While they focus on providing options, they ignore the
immediate need to address illegal encampments that are
creating chaos on our streets. Darren’s point about enforcing
existing laws resonates with my approach; we need a firm
stance against illegal encampments.
Louie Sanchez, you’ve heard the arguments around enforcement from Darren and Eddie, along with the proposed solutions from Daniel and Mayling. What is your take on how Albuquerque should tackle the homelessness issue?
Louie Sanchez:
The idea of
decriminalizing sleep and adding more legal
safe outdoor spaces, as touted by Daniel and Mayling,
completely ignores the pressing reality we have on our
streets. We cannot normalize living in public spaces; it’s not
compassionate or sustainable. We have to act with urgency to
provide real, structured pathways to stability, not just
band-aid solutions.
Moving on to our second topic, Economic Development. What are your top priorities for boosting economic growth in Albuquerque, and how do you plan to create jobs for our residents?
Daniel Chavez:
The focus on enforcement
by candidates like Darren and Eddie misses the mark when it
comes to real economic development, especially as it connects
to homelessness. Treating homelessness as a nuisance isn’t a
viable solution and only drives away potential business
development because it creates a negative perception of our
city’s safety and livability.
What specific strategies do you propose to foster economic growth in Albuquerque, and how will they directly create jobs for our residents?
Darren White:
Daniel, your belief that
streamlining permits and focusing on
homelessness solutions will automatically lead to economic
growth is naive. While it’s essential to make it easier for
businesses to operate, what good is that when our city is
drowning in chaos from illegal encampments and rampant
crime?
Mayling Armijo, with your perspective on economic growth, how do you plan to create jobs for residents in Albuquerque, and what specific strategies will you implement to foster that growth?
Eddie Varela:
Darren’s assertion that
enforcing existing laws against illegal encampments alone will
restore order and attract businesses is flawed. While safety
is crucial, it’s not enough to simply crack down on the
visible symptoms of homelessness; we need effective,
compassionate solutions as well.
What innovative strategies do you propose to stimulate economic growth and job creation in Albuquerque, particularly in light of current challenges?
Mayling Armijo:
Darren, your insistence
that simply enforcing laws will restore order is
short-sighted. Yes, public safety is critical, but it has to
go hand-in-hand with real economic strategies.
Streamlining permits and creating a
supportive environment for businesses are essential steps, but
without addressing homelessness compassionately, we risk
pushing businesses away.
Louie Sanchez, what are your top priorities for stimulating economic growth in Albuquerque, and how do you plan to create jobs for our residents?
Louie Sanchez:
Addressing the economic
growth in Albuquerque cannot be separated from the current
homelessness crisis. Darren’s approach
insisting solely on enforcement as a solution is overly
simplistic and will not attract businesses if our streets
remain chaotic. We must tackle the root issues with strategies
like streamlining permits to ease the burden
on small businesses and create a thriving economic
climate.
Thank you to all the candidates for participating in tonight’s debate. We appreciate your time and perspectives on these critical issues facing Albuquerque.
Challenges
Building this simulation revealed several technical challenges common to multi-agent LLM systems:
- Agents reference information their character shouldn’t know without explicit, repeated constraints
- Temporal violations occur without external state tracking (e.g., referencing statements before they’re made)
- LLMs default to formal politeness, requiring negative instructions to maintain debate tone
- Context windows must be tailored by role—moderators need recent turns, participants need full history
- All state tracking (turn order, topics, speakers) must be managed in code, not by the LLM
Note: All responses in this simulation are AI-generated based on candidate questionnaire data. This is a technical demonstration, not actual debate content or verified candidate positions.