DEV Community

Cover image for I Built an AI Debate Arena on Bedrock — 59 Models Claimed to Work, Only 54 Actually Did
Yuuki Yamashita
Yuuki Yamashita

Posted on

I Built an AI Debate Arena on Bedrock — 59 Models Claimed to Work, Only 54 Actually Did

I Built an AI Debate Arena on Bedrock — 59 Models Claimed to Work, Only 54 Actually Did

I wanted two AI models to argue with each other — pick a side, take turns, land actual counterpunches — and have a third model judge the winner. Not a chatbot demo, an actual arena: pick two fighters from whatever's available on Amazon Bedrock, pick a topic, watch them go. It turned into AI Debate Battle, a Next.js app on Vercel backed by Amazon Bedrock AgentCore Runtime, and it taught me two things I didn't expect: list-foundation-models will happily list models you can't call, and getting an LLM to sound like a person arguing on a stage — instead of a student padding out a five-paragraph essay — needs a completely different kind of prompt than getting it to sound "helpful."

Gotcha #1: "listed" isn't "invokable"

Bedrock's list-foundation-models API in us-east-1 returned 59 text-generation, streaming-capable candidates across 14 providers — Anthropic, Amazon, Meta, Mistral, DeepSeek, Qwen, Z.AI, MiniMax, Moonshot AI, NVIDIA, Google, Writer, OpenAI's open-weight releases, TwelveLabs. That list is not the same thing as "models this account can actually call." I found that out by writing a script that calls Converse on every single candidate with a 5-token "hi" and records what comes back:

def probe(test_id):
    r = subprocess.run(
        ["aws", "bedrock-runtime", "converse", "--region", "us-east-1",
         "--model-id", test_id,
         "--messages", '[{"role":"user","content":[{"text":"hi"}]}]',
         "--inference-config", '{"maxTokens":5}'],
        capture_output=True, text=True, timeout=60)
    return test_id, r.returncode == 0
Enter fullscreen mode Exit fullscreen mode

5 of the 59 came back AccessDeniedException or ValidationException — newer flagship variants that are listed but not yet provisioned for this account, and one multimodal-only model that doesn't actually implement the text Converse contract Bedrock's own catalog says it does. The other 54 responded cleanly and became the roster: everything from Claude Opus 4.6 and Claude Haiku 4.5 down to Llama 3 8B Instruct, DeepSeek V3.2, Qwen3 Coder Next, GLM 5, MiniMax M2.5, Nova Micro. For most single-model apps this doesn't bite you — you pick one model, test it, ship it. It bites you the moment you try to build anything that lets a user choose from a catalog: the catalog and the capability list are two different things, and the only source of truth is an actual Converse call.

Architecture: Strands on AgentCore, with a Bedrock fallback

The debate logic runs as Strands Agents deployed to Amazon Bedrock AgentCore Runtime, invoked from Next.js API routes on Vercel:

Two entrypoints in one Strands agent handle the whole app: action: "turn" streams one debater's argument token-by-token back over SSE, action: "judge" returns the scoring verdict as JSON. The prompt itself — persona, stance, round number, the opponent's last line — is built in TypeScript and passed into the agent as a payload, not hardcoded in the agent's own system prompt. That split matters: I iterated on the prompt (see the next section) a dozen times without ever redeploying the Runtime.

@app.entrypoint
async def invoke(payload):
    if payload["action"] == "turn":
        agent = Agent(
            model=BedrockModel(model_id=payload["modelId"], region_name="us-east-1"),
            system_prompt=payload["system"],
            messages=payload.get("messages", []),
        )
        async for event in agent.stream_async(prompt):
            if "data" in event:
                yield event["data"]
Enter fullscreen mode Exit fullscreen mode

Deployment used direct_code_deploy — no Dockerfile, no ECR push, just agentcore configure and agentcore deploy against the raw Python file. If the Runtime call fails for any reason (cold start, transient error, AGENTCORE_RUNTIME_ARN unset), the Next.js layer catches it and calls bedrock-runtime Converse directly instead — same prompt, no user-visible difference. Wiring a fallback path for the very code path you're demoing felt like overkill until I actually needed it once during testing, at which point it silently did its job and I only found out from the CloudWatch logs.

Gotcha #2: every model defaults to five-paragraph-essay mode

The first version of the debate prompt was reasonable-sounding: "argue this side, rebut the opponent's weakest point, be assertive." Every single model — Claude, Llama, Nova, Mistral, didn't matter — produced the same shape of answer: "X is superior. First, structural advantage. Second, practical benefit. Third, experiential quality. In conclusion, X is objectively better." Grammatically flawless, structurally identical, and it read like nobody was actually in the room.

The fix wasn't more instructions about tone — it was replacing "write an argument" with "you are a specific person, mid-sentence, on a stage." I gave each fighter a persona tied to its display name (hot-blooded, dry-and-surgical, contrarian-and-sarcastic, etc.) and banned the essay scaffolding outright:

- React to what {opponent} just said before anything else — quote or
  paraphrase their weakest claim and go after it. Address them by name.
- Conceding a small point before flipping it is a power move
  ("fine, that part is true — and that's exactly why you lose").
- Vary your rhythm. A one-line jab that lands beats a paragraph.
- Banned: "firstly", "secondly", "in conclusion", bullet points,
  both-sides hedging. Commit fully to your side.
Enter fullscreen mode Exit fullscreen mode

Same models, same topic style, genuinely different output. Here's Claude Haiku 4.5, playing a persona called "Cold Analyst," arguing against giving AI agents payment authority — a real response pulled from the production API, not edited:

Right, so Blazing Debater wants us to believe a spending cap solves this. Let me ask you something — who sets the cap? A human, presumably. And what happens when an agent encounters a situation that's just slightly outside it? Does it wait? Then you've gained nothing. Does it spend anyway? Then your cap was theater.

But here's the real problem you're glossing over: speed isn't the point of automation — correct automation is. [...] An agent with payment authority is a system optimized for speed over judgment. And the moment it miscalculates, misreads a market signal, or gets fed bad data, it doesn't ask for forgiveness — it's already spent the money.

No "firstly." No hedging. It picks up the opponent's actual argument, restates it back at them, and drives a wedge into it. That's a prompt-engineering lesson that generalized well beyond debate apps for me: if you want an LLM to sound like a person instead of an essay generator, describe the person, not the essay.

The judge, scoring the same match

A third Strands agent (Claude Sonnet 4.6, fixed regardless of who's fighting) reads the full transcript and returns strict JSON — winner, three sub-scores per side out of 10 (logic, persuasion, rebuttal), a reasoning blurb, and a verbatim best-quote pull. Scoring the exchange above:

{
  "winner": "con",
  "scores": {
    "pro": {"logic": 4, "persuasion": 5, "rebuttal": 0},
    "con": {"logic": 8, "persuasion": 8, "rebuttal": 8}
  },
  "reasoning": "Pro opened with a breezy one-liner that never engaged
    with risk, oversight, or failure modes... Con came out swinging with
    the spending-cap paradox... That 'theater' line is where the match
    turned.",
  "bestQuote": "An agent with payment authority is a system optimized
    for speed over judgment."
}
Enter fullscreen mode Exit fullscreen mode

The judge's own instruction had to go through the same fix as the debaters: "write a formal evaluation" produced report language, "narrate the verdict like a ringside commentator naming the moment the match turned" produced the sentence above. Same underlying pattern — role beats register.

Stack it into a tournament (4 fighters, two semifinals, a final) and you get a full bracket run end to end with no human in the loop except picking the fighters and the topic — a mundane multi-agent orchestration problem I'd normally reach for a framework to solve, but the fan-out here is small and static enough that a plain for loop over Strands invocations did the job.

Takeaways

  • Probe, don't trust the catalog. list-foundation-models and list-inference-profiles tell you what exists; only a real Converse call tells you what your account can invoke. 5 of 59 candidates failed silently until tested.
  • Keep the prompt out of the agent. Building the system prompt in the calling application and passing it as a payload to a generic AgentCore/Strands entrypoint meant dozens of prompt-engineering iterations with zero Runtime redeploys.
  • A fallback path pays for itself the first time it's needed, even in a side project — wire AgentCore as primary and direct Bedrock Converse as the safety net from day one, not after the first outage.
  • "Argue persuasively" is a worse instruction than "you are this specific person." Every model defaults to essay-shape output unless you give it a character and explicitly ban the scaffolding.

Full source — the model-probing script, the Strands/AgentCore agent, the Next.js app, and the persona prompts — is up on GitHub: yama3133/ai-debate-battle. The app itself is live at ai-debate-battle-six.vercel.app if you want to pick two models and watch them fight.

Top comments (0)