DEV Community

Cover image for I Made 4 AI Agents Argue With Each Other (Hexagonal Architecture Edition)
Ashish Waghode
Ashish Waghode

Posted on

I Made 4 AI Agents Argue With Each Other (Hexagonal Architecture Edition)

Live demo:https://council-of-agents-pi.vercel.app
Source: https://github.com/ashishbot120/Council_of_Agents

The Problem
The problem with asking an AI for architectural advice
Ask a standard LLM "Should we adopt microservices?" and it will hand you a shockingly confident answer. No visible trade-offs, no edge cases considered, and zero insight into why it reached that conclusion.
But that’s not how real engineering decisions are made.
Real architectural choices require debate. I wanted to build something closer to actual team dynamics: multiple distinct AI perspectives arguing their side of the trade-off matrix, followed by a synthesizer that actually weighs the arguments instead of blindly picking a side.

The idea
Council of Agents: four personas Optimist, Critic, Analyst, Devil's Advocate independently answer the same question. A fifth agent, the Chairman, reads all four and synthesizes one final decision.
Real run, question: "Monorepo or multiple repos for a startup team?"

Optimist (85%): monorepo unified tooling, faster onboarding
Critic (87%): multiple repos build complexity at scale
Analyst (80%): monorepo initially, revisit as team grows
Devil's Advocate (85%): multiple repos avoid single point of failure

demo-agents.png

Chairman's synthesis: adopt a monorepo, but enforce strict modular boundaries and access controls to address the concerns the dissenters raised.
That's more useful than any single agent's answer it absorbed the pushback into a caveat instead of ignoring it.

Why Hexagonal (Ports & Adapters)
The hypothesis: if I design around interfaces instead of concrete SDKs, how cheap does it become to swap providers and survive real-world chaos?

domain/ <- pure business logic, zero framework/SDK imports
entities.py Agent, Proposal, Council, Decision
services/
deliberation_engine.py fires all 4 agents concurrently, tolerates
partial failure
consensus_strategy.py swappable algorithms (Chairman, MajorityVote)

application/
ports/ LLMProviderPort — contracts, not implementations
use_cases/ orchestrates domain + ports

adapters/
outbound/llm/ OpenRouterAdapter, GroqAdapter
inbound/rest/ FastAPI routes

infrastructure/
container.py the ONE file that wires ports to adapters

The rule that makes this actually hexagonal: domain/ and application/ never import a concrete adapter. DeliberationEngine calls llm_provider.get_response(role, query) with no idea which provider is behind it.

demo-deliberating.png

Proof it's not just theory: switching the whole system from OpenRouter to Groq took one environment variable — zero lines changed outside container.py. You can even override the provider per-request:

json
POST /council/query
{ "text": "Should we adopt microservices?", "provider": "groq" }

Adding a third provider: ~50 lines
Write OllamaAdapter implementing get_response(role, query) -> Proposal
Register it in container.py
Done — nothing in domain/, application/, or the REST layer gets touched

demo-decision-openrouter.pngdemo-decision-groq.png

Four real production incidents

  1. A model got deprecated mid-project. deepseek/deepseek-r1:free vanished between picking it and testing it. Fix: model names live in one dictionary, so swapping is a one-line change, not a hunt.
  2. Two "different" free models shared one rate limit. Different slugs, same underlying host running them concurrently meant they collided on one shared bucket. Fix: pin each agent to a genuinely different upstream provider; model-name diversity ≠ provider diversity.
  3. A reasoning model returned nothing. It spent its whole token budget "thinking" and never wrote an answer json.loads("") blew up. Overcorrecting with a tight token cap caused truncated JSON instead. Fix: budget for both invisible reasoning tokens and the visible answer (max_tokens=1200), and prefer plain instruct models for structured output.
  4. A trailing slash broke CORS in production. CORS_ORIGINS=https://my-app.vercel.app/ browsers send Origin without the trailing slash, and CORSMiddleware does an exact match. One character, total failure, no hint in the code.

None of these took the system down every agent call is independently caught, and the engine returns a decision from whoever did respond. That's _get_proposal_safely(), written on day one because "an external call can fail" was a first-class assumption, not an afterthought.

What this is useful for
A live, non-toy example of Ports & Adapters
A decision-support pattern for tradeoff-heavy questions (architecture, hiring, build-vs-buy)
A resilience case study for anyone building on unreliable third-party APIs

What's next
Postgres adapter for persistence (same port, zero domain changes)
More consensus strategies (weighted voting, rebuttal rounds)
Streaming individual agent responses
A local Ollama adapter for a fully free path
An eval harness comparing consensus strategies over time

Contributions welcome

This is very much a living project, not a finished one. If you want to add a consensus strategy, wire up a new provider adapter, or just poke holes in the architecture — PRs and issues are welcome. Good first ones to try: a local Ollama adapter, a Postgres decision repository, or a new persona.

Top comments (0)