Claude Skill

agent-council

Run a structured multi-agent debate by spawning a panel of expert agents on any question, with convergence-aware iteration and typed synthesis output via the `agent-council` CLI. Use when a decision has genuine tradeoffs, high stakes, or hidden assumptions worth adversarial colla

LLM Mart · 0 points · 12 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download magnus919-agent-skills-agent-council-addad86.zip · 40 KB
Part of magnus919/agent-skills — 145 skills

Install

skills CLI npx skills add https://github.com/magnus919/agent-skills/tree/main/agent-council
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
Git git clone https://github.com/magnus919/agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.

README

Agent Council

Multi-agent structured debate system — spawn a panel of expert agents to debate any question with convergence-aware iteration.

pip install agent-council
agent-council "Should we migrate from SQLite to Postgres?"

Quick Start

export AGENT_COUNCIL_API_KEY="sk-..."
export AGENT_COUNCIL_MODEL="openai:gpt-5.6-luna"

agent-council "Should we use WebSockets or SSE for real-time notifications?"

Features

  • Structured debate protocol — compose, premortem, position, cross-examine (iterative), synthesis
  • Convergence-aware iteration — the protocol measures confidence dispersion and stops when diminishing returns set in, not at a hardcoded round count
  • Typed outputs — every phase produces validated Pydantic models, consumable as JSON or human-readable markdown
  • Custom personas — supply your own agent definitions, or let the compose phase generate them from the question
  • Convergence diagnostics — confidence dispersion, position overlap, argument novelty — surfaced in every synthesis report
  • Cross-platform — works with any AI harness that supports agentskills.io skills (Claude Code, Cursor, Hermes Agent, OpenHands, etc.)

Installation

pip install pydantic-ai
pip install agent-council

Or install from the skill directory:

pip install -e /path/to/agent-council/

Pip and wheel installs include generated and user-supplied personas, but not the hermes-profiles library. To use real bundled profiles, start from a recursive source checkout.

Usage

# Quick debate (3 agents, 1 cross-examine round)
agent-council --mode quick "Should we use Postgres or SQLite?"

# Standard debate (5 agents, iterative cross-examination)
agent-council "What architecture should we choose for this service?"

# Deep debate (7 agents, full protocol with assumption mapping)
agent-council --mode deep --agents 7 "Should we migrate to microservices?"

# With custom personas
agent-council --persona-file personas.json "Evaluate our cloud strategy"

# JSON output for programmatic consumption
agent-council --json "Which cloud provider should we choose?"

Output

The synthesis report includes:

  • Confidence dispersion table — agent-by-agent confidence before and after debate
  • Shared risks — failure modes identified in the pre-mortem (before positional commitment)
  • Shared concerns — what survived cross-examination as genuine shared risk
  • Genuine disagreements — positions that remained unresolved after debate
  • Assumptions per position — what would need to be true for each position to be correct
  • Principal's path — narrative synthesis of the decision landscape

Configuration

Env var Required Default Description
AGENT_COUNCIL_API_KEY Yes — API key for your LLM provider
AGENT_COUNCIL_MODEL No openai:gpt-5.6-luna Model string in provider:model format (e.g. deepseek:deepseek-v4-flash)
AGENT_COUNCIL_BASE_URL No Provider default Custom API endpoint (e.g. https://api.deepseek.com/v1)

License

MIT

Why Install This Skill

This skill packages practical, reusable guidance for this domain so you can move from a real task to a dependable result without rebuilding the workflow each time.

What You Get

A focused workflow in SKILL.md, with the referenced scripts, templates, and supporting material available when the task needs them.

Triggers

Use this skill for the task types and keywords described in its SKILL.md description.

Requirements

Check the compatibility requirements in SKILL.md before using commands or integrations from this skill.

Skill manifest

Agent Council

Spawn a panel of expert agents to debate any question. The council runs a structured protocol — compose, premortem, position, cross-examination (iterative), synthesis — and produces a decision landscape with convergence diagnostics.

When to Use

Invoke the council when any of these apply:

  • The question has genuine tradeoffs with no clear correct answer
  • You want multi-perspective analysis to surface hidden assumptions
  • A decision would benefit from adversarial collaboration
  • You want confidence diagnostics (not just a recommendation)
  • The question has high stakes or irreversible consequences

Signal phrases: "Let's get multiple perspectives on this" / "Debate this: X" / "What would experts say about X" / "What are we missing?"

Quick Start

1. Install

# One-time setup
pip install pydantic-ai
pip install agent-council

# Or install from this skill directory:
python3 scripts/bootstrap.py

2. Configure

export AGENT_COUNCIL_API_KEY="sk-..."
export AGENT_COUNCIL_MODEL="openai:gpt-5.6-luna"

3. Run

agent-council "Should we use Postgres or SQLite for this service?"

Command Reference

agent-council [OPTIONS] <question>

Options:
  --agents, -n {3,4,5,6,7}  Number of agents (default: 5)
  --mode, -m {quick,medium,deep}  Debate depth (default: medium)
  --profiles TEXT          Comma-separated profile names from the hermes-profiles
                           library (e.g. "debugger,researcher,product-manager")
  --persona-file PATH      JSON file with custom agent personas
  --json                   Output structured JSON instead of markdown
  --verbose, -v            Show phase-by-phase progress
  --max-rounds INTEGER     Max cross-examination rounds (default: 4)
  --convergence FLOAT      Convergence threshold (default: 0.10)

Mode Selection

Mode Agents Rounds When to use
quick 3 1 cross-examine round Low-stakes check, fast answer needed
medium (default) 5 Eval-driven, up to 4 rounds Standard decisions
deep 7 Eval-driven, up to 4 rounds High-stakes, hidden assumptions

Profile Selection

In a recursive source checkout, the council auto-selects relevant real professional profiles from the included hermes-profiles library. Each profile has a SOUL.md — an identity document with real methodology, values, and operating principles — rather than a fabricated persona. Pip and wheel installs do not bundle that library; use generated or user-supplied personas instead.

Auto-selection

When no --profiles flag is given, the council scores each profile's description against your question using keyword overlap. The top N most relevant profiles are selected. This works best for focused, single-domain questions.

Explicit selection

agent-council --profiles debugger,data-scientist,product-manager "What architecture should we choose?"

Comma-separated profile names. Available profiles include: ceo, cfo, cmo, coo, cpo, cto, curator, data-architect, data-engineer, data-scientist, debugger, editor, frontend-engineer, ml-engineer, orchestrator, product-manager, researcher, reviewer, security-engineer, site-reliability-engineer, technical-architect, technical-writer, ux-designer, verifier, wonderer, writer, and more.

Choosing a Profile Source

Three ways to populate the council, with different tradeoffs:

Method Best for Diversity Setup
--profiles (auto-select) Single-domain questions with clear keywords High — profiles have real SOUL.md methodology Recursive source checkout required
--profiles name1,name2 Targeted debates where you know the stakeholders Highest — you pick specific methodological voices Recursive source checkout and profile names
--persona-file file.json Full control over agent identities, custom domains Variable — depends on how you design them Create a JSON file
Auto (no flag) Default — uses profiles if available, falls back to generated Good — varies with available profiles No setup for generated personas; recursive source checkout for real profiles

For most cases, let it auto-select or use --profiles with 3-5 names. Only use --persona-file when you need specific invented expertise that doesn't map to any existing profile.

Custom personas (fallback)

If the profile library is unavailable or you want full control, use --persona-file to supply your own persona definitions. If neither --profiles nor --persona-file is provided, the council auto-selects profiles from the library; if the library is missing, it falls back to LLM-generated personas.

How It Works

Pipeline

Compose ──► Premortem ──► Position ──► Cross-examine ──► [eval] ──► Synthesis
  (1)        (parallel)    (parallel)    (iterative loop)    ↑        (1)

                    ┌── converged ──────┐
                    ├── diminishing_ret │
    eval ───────────┼── genuine_disagr──┼──► Synthesis
                    └── continue ───────┘
                          ↓
                   Cross-examine (next round)

Phases

Phase What happens Method
Compose A single LLM call generates N expert personas tuned to the question 1 call
Premortem Each agent independently imagines how the decision already failed — bypasses positional commitment bias N parallel calls
Position Each agent forms an independent position, referencing their own premortem N parallel calls
Cross-examine Each agent reads all other positions and responds — concedes, disagrees, updates confidence N parallel calls per round
Eval Convergence detection: measures dispersion, argument novelty, concession rate. Decides whether to loop or stop Algorithmic
Synthesis Collates all phases into a structured decision landscape with LLM-generated narrative 1 call

Convergence Detection

The council doesn't use a fixed number of rounds. After each cross-examination round, it measures:

  • Confidence dispersion — standard deviation of agent confidence scores. Below threshold = converged.
  • Argument novelty — new arguments not seen in prior rounds. Near zero = diminishing returns.
  • Concession rate — points where agents shifted position. Zero + no new arguments = stalled.

Stopping conditions:

Condition Meaning
converged Dispersion below threshold, confidence stable. Genuine agreement.
diminishing_returns No new arguments or concessions. Nothing more to surface.
genuine_disagreement Dispersion widened, positions hardened. Summary of irreducible tension.
max_rounds Hard cap reached. Inconclusive — principal must decide.

Bootstrapping

If agent-council is not available on PATH, the invoking agent should run:

python3 scripts/bootstrap.py

This installs the package from the skill directory using the current Python's pip, falling back to pipx. No PyPI dependency for the bootstrap path — the package ships inside the skill directory.

If bootstrap fails: Run one of these manually:

pip install pydantic-ai
pip install agent-council

# Or from this directory:
python3 -m pip install -e /path/to/agent-council/

Available Scripts

This skill bundles one script; there are no others to discover.

Script Purpose Invocation
scripts/bootstrap.py First-run installer: checks whether agent-council is already on PATH and, if not, installs the package from the skill directory using the current Python's pip, falling back to pipx. Run it whenever agent-council is not found on PATH (an invoking agent should run it automatically in that case); it exits 0 when the CLI is available and 1 with manual-install instructions when it could not install. If bootstrap fails, follow the manual steps above. python3 scripts/bootstrap.py

Configuration

Env var Required Default Description
AGENT_COUNCIL_API_KEY Yes — API key for your LLM provider
AGENT_COUNCIL_MODEL No openai:gpt-5.6-luna Model string (provider/model)
AGENT_COUNCIL_BASE_URL No Provider default Custom API endpoint (OpenRouter, LiteLLM, etc.)

You can set these as environment variables or create a .env file in the directory you run agent-council from:

# .env file
AGENT_COUNCIL_API_KEY=sk-...
AGENT_COUNCIL_MODEL=openai:gpt-5.6-luna

Environment variables take precedence over .env file values.

Model strings follow PydanticAI convention: openai:gpt-5.6-luna, anthropic:claude-sonnet-4-20250514, deepseek:deepseek-v4-flash, google:gemini-2.0-flash.

Output

The synthesis report is a structured decision landscape. In markdown mode it includes:

  1. Confidence dispersion table — per-round confidence metrics with diagnostic
  2. Shared risks — failure modes from the pre-mortem (pre-positional, uncontaminated)
  3. Shared concerns — what survived cross-examination as genuine shared risk
  4. Remaining disagreements — positions that did not resolve
  5. Assumptions per position — what must hold for each position to be valid
  6. Principal's path — narrative synthesis of the decision landscape

Use --json for programmatic consumption. The JSON output follows this structure:

{
  "question": "string",
  "mode": "quick|medium|deep",
  "num_agents": 3,
  "rounds_completed": 2,
  "stopped_reason": "converged|max_rounds|diminishing_returns|genuine_disagreement",
  "confidence_history": [
    {"round": 1, "mean_confidence": 0.74, "dispersion": 0.061, "new_arguments": 20, "concessions_made": 17}
  ],
  "shared_risks": [{"description": "...", "severity": "low|medium|high", "phase_discovered": "premortem"}],
  "shared_concerns": ["..."],
  "disagreements": [{"topic": "...", "positions": {"agent_a": "position_a", "agent_b": "position_b"}}],
  "assumptions_per_position": {"agent_name": ["assumption1", "assumption2"]},
  "principal_path": "narrative text"
}

Claims Verification

Every synthesis output includes a post-debate verification scan. A separate LLM call reads the narrative synthesis and identifies any claims about verifiable external facts (domain availability, package namespace status, pricing, statistics) that the debate could not have verified from its own reasoning. Flagged claims are appended as a ⚠️ Claims Not Verified section:

⚠️  Claims Not Verified
The following assertions in this synthesis could not be verified
by the council's own reasoning and should be checked before acting:
  • "Dialekt passes all five checks..." — domain availability:
    No evidence the council checked domain registries

This is not a rejection of the synthesis — it is a quality signal. Claims in this section should be treated as hypotheses to verify, not as facts.

Reading the Convergence Diagnostic

The confidence dispersion table tells you whether the debate was productive:

Pattern Meaning What to do
Mean confidence DROPPED, dispersion WIDENED Council surfaced genuine doubt — healthy debate Trust the shared concerns; investigate the newly surfaced risks
Mean confidence ROSE, dispersion NARROWED Genuine convergence — agents convinced each other The strongest signal; highest-confidence path forward
Mean confidence STABLE, dispersion NARROWED Possible false consensus — agents agreed before debating Probe the assumptions section for shared blind spots
Mean confidence ROSE, dispersion WIDENED Polarization — agents became more entrenched The question may be genuinely irresolvable by argument alone; look for an experimental path
stopped_reason: converged Dispersion fell below threshold Good — run with the recommendation
stopped_reason: max_rounds Hit hard cap before converging The debate was cut off; consider a second run with --max-rounds higher or --mode quick for faster convergence
stopped_reason: diminishing_returns No new arguments surfaced The council exhausted what it could discover — make a call
stopped_reason: genuine_disagreement Positions hardened, dispersion widened The council could not resolve the tension. The output is valuable precisely because it maps irreconcilable disagreement — read the disagreements section carefully

Pitfalls

Symptom Cause Fix
Debate fails with "Exceeded maximum output retries" Model couldn't produce valid structured output for a phase Retry the debate. If persistent, try a different model or add --verbose to see which agent failed.
Debate runs for 5+ minutes with no output DeepSeek or slow model with many agents Use --mode quick --agents 3 for fast turnarounds, or use --verbose to see progress in real time.
All agents agree immediately with high confidence False consensus — same model shares blind spots Check the dispersion diagnostic. Try --profiles with diverse identities to force methodological diversity.
"Profile X not found" warning Typo in profile name Run agent-council --profiles list (or check the profiles list above) for valid names.
Synthesis contains obvious factual errors Agents fabricated claims during debate Check the ⚠️ Claims Not Verified section. The guardrail reduces fabrication but cannot eliminate it. Verify any statistics, pricing, or availability claims before acting.

Architecture Decision

Single-model debate: All agents share one LLM configuration. Diversity comes from persona definitions (system prompts with distinct backgrounds, analytical approaches, biases), not from different model instances. This minimizes setup friction — one API key, one endpoint, predictable cost.

Limitation: All agents share the model's knowledge cutoff and blind spots. The convergence diagnostics include a "possible false consensus" flag when confidence starts high and never shifts.

Reference Files

File Load when
references/convergence.md Understanding the convergence detection algorithm
references/debate-protocol.md Deep dive into phase structure and round design
references/configuration.md Provider setup, troubleshooting, model strings

Directory Structure

agent-council/
├── SKILL.md                      # This file — skill entry point
├── pyproject.toml                # Pip package definition
├── README.md
├── LICENSE                       # MIT
├── agent_council/                # Python package
│   ├── cli.py                    # CLI entry point
│   ├── config.py                 # Env var loading
│   ├── state.py                  # Typed state + Pydantic models
│   ├── convergence.py            # Convergence detection
│   ├── graph.py                  # Debate graph orchestration
│   └── phases/
│       ├── compose.py            # Persona generation
│       ├── premortem.py          # Failure pre-mortem
│       ├── position.py           # Initial positions
│       ├── cross_examine.py      # Iterative cross-examination
│       └── synthesis.py          # Decision landscape
├── scripts/
│   └── bootstrap.py              # First-run installation
├── templates/
│   └── personas.json             # Example custom personas
└── references/
    ├── convergence.md
    ├── debate-protocol.md
    └── configuration.md

Prerequisites

  • Python 3.10+ with the pydantic-ai package; install via pip, pipx, or python3 scripts/bootstrap.py (the package ships inside this skill directory, so bootstrap needs no PyPI access).
  • An LLM provider API key exported as AGENT_COUNCIL_API_KEY (or set in a .env file); optionally AGENT_COUNCIL_MODEL and AGENT_COUNCIL_BASE_URL.
  • The real professional profiles from the hermes-profiles library are available only in a recursive source checkout; pip and wheel installs use generated or user-supplied personas.

Limitations

  • Single-model debate: all agents share one LLM configuration and therefore its knowledge cutoff and blind spots; diversity comes from persona definitions, not model instances (see Architecture Decision).
  • Debates consume many parallel LLM calls per round — expect minutes on slow models or deep mode, and check ⚠️ Claims Not Verified in every synthesis before acting on verifiable external facts.
  • Convergence diagnostics reduce fabrication and false consensus but cannot eliminate them; max_rounds stops mean an inconclusive debate that the principal must resolve.
  • This is not a general agent-orchestration framework: it runs debates only — for state-machine orchestration beyond the debate protocol, route to langgraph.

Related Skills

  • ai-frameworks — umbrella bundle for all AI framework skills. Load this when comparing agent-council against other multi-agent approaches (LangGraph, AutoGen, CrewAI).
  • langgraph — for complex state-machine multi-agent orchestration beyond the debate protocol
  • pydanticai — the underlying framework for type-safe agent definitions
  • spec-driven-development — for building specs that agent-council can help you evaluate
  • hermes-profiles — the 39-profile library that powers the profile selection system
Files (agent-skills)
  • agent_council
    • phases
      • compose.py 2 KB
        """Compose phase — generates agent personas from the question."""
        
        from pydantic_ai import Agent
        from agent_council.state import AgentPersona
        from agent_council.config import load_config
        from agent_council.guardrails import FACTUAL_CLAIM_GUARDRAIL
        
        
        async def compose_personas(question: str, num_agents: int = 5) -> list[AgentPersona]:
            """Generate debate agent personas tailored to the question.
        
            Uses an LLM to compose personas with diverse backgrounds, analytical
            approaches, and biases. The compose agent is a single LLM call that
            outputs a structured list of AgentPersona definitions.
            """
            cfg = load_config()
        
            compose_agent = Agent(
                cfg["model"],
                output_type=list[AgentPersona],
                retries=3,
                system_prompt=(
                    "You are a council composition specialist. Your job is to design "
                    "expert debating agents for a structured multi-perspective debate.\n\n"
                    "Critical directive: Prioritize DIVERSITY OF INITIAL POSITION over "
                    "diversity of expertise. Research shows that a group with four distinct "
                    "approaches to a problem — none individually correct — outperforms a "
                    "group with more expertise but shared framing.\n\n"
                    "For each agent provide: name, one-paragraph career background, specific "
                    "expertise, analytical approach, and what bias or experience they bring "
                    f"to THIS question. Design exactly {num_agents} agents.\n\n"
                    "At least one agent should be structurally skeptical (a light red-team "
                    "role). At least one agent should approach the problem from a fundamentally "
                    "different cognitive frame than the others. Design them to create productive "
                    "friction — real disagreement grounded in real experience, not caricatures."
                    f"{FACTUAL_CLAIM_GUARDRAIL}"
                ),
            )
        
            result = await compose_agent.run(
                f"Design {num_agents} expert debating agents for the question: {question}"
            )
        
            return result.output
        
      • cross_examine.py 4 KB
        """Cross-examination phase — agents probe each other's positions."""
        
        import asyncio
        from pydantic_ai import Agent
        from agent_council.state import (
            CouncilState,
            Position,
            CrossExamination,
        )
        from agent_council.config import load_config
        from agent_council.guardrails import FACTUAL_CLAIM_GUARDRAIL
        
        
        def _format_other_positions(
            my_name: str,
            positions: dict[str, Position],
        ) -> str:
            """Format other agents' positions for the prompt."""
            lines = []
            for name, pos in positions.items():
                if name == my_name:
                    continue
                lines.append(f"--- {name} ---")
                lines.append(f"Stance: {pos.stance}")
                lines.append(f"Reasoning: {'; '.join(pos.reasoning)}")
                lines.append(f"Confidence: {pos.confidence}")
                lines.append(f"Assumptions: {'; '.join(pos.key_assumptions)}")
                lines.append("")
            return "\n".join(lines)
        
        
        async def run_cross_examination(
            question: str,
            state: CouncilState,
            verbose: bool = False,
        ) -> dict[str, CrossExamination]:
            """Each agent reads all other positions and responds.
        
            Uses real profiles if available, falls back to fabricated personas.
            """
            cfg = load_config()
        
            async def _cross(
                agent_id: str,
                identity_block: str,
                other_positions: str,
                round_context: str,
            ) -> tuple[str, CrossExamination]:
                system = (
                    f"{identity_block}\n\n"
                    "You are in a structured debate. You have read every other agent's "
                    "position on the question.\n\n"
                    "Other agents' positions:\n"
                    f"{other_positions}\n"
                    f"{round_context}\n\n"
                    "Respond to what you've read. For each point: concede where the "
                    "other agent's reasoning is stronger, identify where you still "
                    "disagree and why, and update your position if warranted. Be "
                    "specific — do not hedge. If your confidence has changed, say so."
                    f"{FACTUAL_CLAIM_GUARDRAIL}"
                )
        
                agent = Agent(cfg["model"], output_type=CrossExamination, system_prompt=system, retries=3)
                result = await agent.run(question)
                output = result.output
                output.agent_name = agent_id
                return agent_id, output
        
            # Build list of agent identities
            agents = []
            if state.profiles:
                for p in state.profiles:
                    identity = (
                        f"You are {p.name}.\n\n"
                        f"Your identity and operating principles:\n"
                        f"{p.soul_content}\n\n"
                        f"Description: {p.description}"
                    )
                    agents.append((p.name, identity))
            else:
                for p in state.personas:
                    identity = (
                        f"You are {p.name}.\n"
                        f"Background: {p.background}\n"
                        f"Expertise: {p.expertise}\n"
                        f"Approach: {p.approach}\n"
                        f"Bias: {p.bias}"
                    )
                    agents.append((p.name, identity))
        
            prior_rounds = state.cross_examination_rounds
        
            tasks = []
            for agent_id, identity_block in agents:
                other_positions = _format_other_positions(agent_id, state.positions)
        
                round_context = ""
                if prior_rounds:
                    round_context = "\n\nPrevious round context:\n"
                    for i, rnd in enumerate(prior_rounds):
                        if agent_id in rnd:
                            prev = rnd[agent_id]
                            round_context += f"Round {i + 1} — your reflection: {prev.reflection}\n"
                            if prev.concessions:
                                round_context += f"  You conceded: {'; '.join(prev.concessions)}\n"
                            if prev.remaining_disagreements:
                                round_context += (
                                    f"  Still in dispute: "
                                    f"{'; '.join(prev.remaining_disagreements)}\n"
                                )
        
                tasks.append(_cross(agent_id, identity_block, other_positions, round_context))
        
            results = await asyncio.gather(*tasks)
            return dict(results)
        
      • position.py 2.8 KB
        """Position phase — each agent forms an independent initial position."""
        
        import asyncio
        from pydantic_ai import Agent
        from agent_council.state import CouncilState, Position, Premortem
        from agent_council.config import load_config
        from agent_council.guardrails import FACTUAL_CLAIM_GUARDRAIL
        
        
        async def run_positions(
            question: str,
            state: CouncilState,
            verbose: bool = False,
        ) -> dict[str, Position]:
            """Each agent forms an independent initial position.
        
            Agents see their own premortem (to maintain continuity) but NOT other
            agents' positions or premortems. Ensures independent thought.
            Uses real profiles if available, falls back to fabricated personas.
            """
            cfg = load_config()
        
            async def _position(
                agent_id: str,
                identity_block: str,
                premortem: Premortem | None,
            ) -> tuple[str, Position]:
                system = (
                    f"{identity_block}\n\n"
                    "You are in a structured debate. Your task: form your initial "
                    "position on the question. Be specific about your stance, your "
                    "reasoning, and what assumptions you're making."
                )
                if premortem:
                    system += (
                        f"\n\nYour pre-mortem identified these failure modes:\n"
                        f"  - Failure scenario: {premortem.failure_scenario}\n"
                        f"  - Root causes: {'; '.join(premortem.root_causes)}\n"
                        f"  - Warning signals: {'; '.join(premortem.early_warning_signals)}\n\n"
                        "Your position should account for these risks."
                    )
        
                system += (
                    "\n\nReturn your position with a confidence score (0-1) and "
                    "the key assumptions that must hold for your position to be correct."
                    f"{FACTUAL_CLAIM_GUARDRAIL}"
                )
        
                agent = Agent(cfg["model"], output_type=Position, system_prompt=system, retries=3)
                result = await agent.run(question)
                output = result.output
                output.agent_name = agent_id
                return agent_id, output
        
            tasks = []
            if state.profiles:
                for p in state.profiles:
                    identity = (
                        f"You are {p.name}.\n\n"
                        f"Your identity and operating principles:\n"
                        f"{p.soul_content}\n\n"
                        f"Description: {p.description}"
                    )
                    pm = state.premortems.get(p.name)
                    tasks.append(_position(p.name, identity, pm))
            else:
                for p in state.personas:
                    identity = (
                        f"You are {p.name}.\n"
                        f"Background: {p.background}\n"
                        f"Expertise: {p.expertise}\n"
                        f"Approach: {p.approach}\n"
                        f"Bias: {p.bias}"
                    )
                    pm = state.premortems.get(p.name)
                    tasks.append(_position(p.name, identity, pm))
        
            results = await asyncio.gather(*tasks)
            return dict(results)
        
      • premortem.py 2.3 KB
        """Premortem phase — each agent envisions how the decision already failed."""
        
        import asyncio
        from pydantic_ai import Agent
        from agent_council.state import CouncilState, Premortem
        from agent_council.config import load_config
        from agent_council.guardrails import FACTUAL_CLAIM_GUARDRAIL
        
        
        async def run_premortems(
            question: str,
            state: CouncilState,
            verbose: bool = False,
        ) -> dict[str, Premortem]:
            """Each agent independently writes a failure scenario.
        
            Agents do NOT see each other's premortems — this runs before any
            positions are formed, bypassing positional commitment bias.
            Uses real profiles if available, falls back to fabricated personas.
            """
            cfg = load_config()
        
            async def _premortem(agent_id: str, identity_block: str) -> tuple[str, Premortem]:
                agent = Agent(
                    cfg["model"],
                    output_type=Premortem,
                    retries=3,
                    system_prompt=(
                        f"{identity_block}\n\n"
                        "You are in a structured debate. Your first task: write a "
                        "pre-mortem — imagine it is 6 months in the future and the "
                        "decision about to be discussed has ALREADY FAILED. Write "
                        "the history of how it failed. What went wrong? What were the "
                        "early warning signals nobody heeded? Be specific and draw on "
                        "your expertise."
                        f"{FACTUAL_CLAIM_GUARDRAIL}"
                    ),
                )
                result = await agent.run(question)
                return agent_id, result.output
        
            # Build agent identities
            tasks = []
            if state.profiles:
                for p in state.profiles:
                    identity = (
                        f"You are {p.name}.\n\n"
                        f"Your identity and operating principles:\n"
                        f"{p.soul_content}\n\n"
                        f"Description: {p.description}"
                    )
                    tasks.append(_premortem(p.name, identity))
            else:
                for p in state.personas:
                    identity = (
                        f"You are {p.name}.\n"
                        f"Background: {p.background}\n"
                        f"Expertise: {p.expertise}\n"
                        f"Approach: {p.approach}\n"
                        f"Bias: {p.bias}"
                    )
                    tasks.append(_premortem(p.name, identity))
        
            results = await asyncio.gather(*tasks)
            return dict(results)
        
      • select.py 3.4 KB
        """Select phase — picks real profiles from the hermes-profiles library."""
        
        import json
        import os
        import sys
        from pathlib import Path
        
        import yaml
        
        from agent_council.state import ProfileInfo
        
        
        # Path to the locally available profiles library within the skill directory.
        PROFILES_DIR = Path(__file__).resolve().parent.parent.parent / "profiles" / "profiles"
        
        
        def _list_available() -> list[str]:
            """List all available profile names."""
            if not PROFILES_DIR.exists():
                return []
            return sorted(
                d.name for d in PROFILES_DIR.iterdir()
                if d.is_dir() and not d.name.startswith(".")
            )
        
        
        def _load_profile(name: str) -> ProfileInfo | None:
            """Load a single profile's SOUL.md and profile.yaml."""
            profile_dir = PROFILES_DIR / name
            soul_path = profile_dir / "SOUL.md"
            yaml_path = profile_dir / "profile.yaml"
        
            if not soul_path.exists():
                return None
        
            soul_content = soul_path.read_text(encoding="utf-8")
        
            description = ""
            if yaml_path.exists():
                try:
                    with open(yaml_path) as f:
                        data = yaml.safe_load(f)
                    description = data.get("description", "") or ""
                except Exception:
                    pass
        
            return ProfileInfo(name=name, description=description, soul_content=soul_content)
        
        
        def load_all() -> list[ProfileInfo]:
            """Load all locally available profiles."""
            profiles = []
            for name in _list_available():
                p = _load_profile(name)
                if p:
                    profiles.append(p)
            return profiles
        
        
        def select_by_names(names: list[str]) -> list[ProfileInfo]:
            """Load specific profiles by name."""
            profiles = []
            for name in names:
                name = name.strip().lower()
                p = _load_profile(name)
                if p:
                    profiles.append(p)
                else:
                    print(
                        f"Warning: profile '{name}' not found. "
                        f"Available: {', '.join(_list_available())}",
                        file=sys.stderr,
                    )
            return profiles
        
        
        def select_by_question(question: str, count: int = 5) -> list[ProfileInfo]:
            """Auto-select the most relevant profiles for a question.
        
            Scores each profile by keyword overlap between the question
            and the profile's description. Returns the top N profiles.
            """
            all_profiles = load_all()
            if not all_profiles:
                return []
        
            question_lower = question.lower()
            question_words = set(question_lower.split())
        
            scored = []
            for p in all_profiles:
                desc_words = set(p.description.lower().split())
                # Also score on profile name
                name_words = set(p.name.lower().replace("-", " ").split())
        
                # Count overlapping words
                overlap = len(question_words & desc_words) + len(question_words & name_words)
        
                # Bonus for exact phrase matches
                if p.name.lower().replace("-", " ") in question_lower:
                    overlap += 3
        
                scored.append((overlap, p))
        
            scored.sort(key=lambda x: -x[0])
        
            # Pick top N, ensure diversity (skip if too similar description)
            selected = []
            seen_descriptions = set()
            for _, p in scored:
                desc_key = p.description[:80]
                if desc_key not in seen_descriptions or len(selected) < 3:
                    selected.append(p)
                    seen_descriptions.add(desc_key)
                if len(selected) >= count:
                    break
        
            # Fallback: if somehow empty, grab first N
            if not selected and all_profiles:
                selected = all_profiles[:count]
        
            return selected
        
      • synthesis.py 8.8 KB
        """Synthesis phase — produces the final decision landscape."""
        
        from typing import Literal
        
        from pydantic_ai import Agent
        from agent_council.state import (
            CouncilState,
            Position,
            Premortem,
            CrossExamination,
            Synthesis,
            RiskVector,
            Disagreement,
            RoundMetrics,
        )
        from agent_council.convergence import compute_round_metrics
        from agent_council.config import load_config
        from agent_council.guardrails import verify_synthesis
        
        
        def _collect_risks(
            premortems: dict[str, Premortem],
            positions: dict[str, Position],
            cross_rounds: list[dict[str, CrossExamination]],
        ) -> list[RiskVector]:
            """Collect all risks flagged across phases."""
            risks: list[RiskVector] = []
        
            # Heuristic severity: if 3+ agents flagged it independently, it's high.
            # Risks seen by 2 agents are medium, single-agent risks are low.
            def _severity(agent_count: int) -> Literal["low", "medium", "high"]:
                if agent_count >= 3:
                    return "high"
                elif agent_count == 2:
                    return "medium"
                return "low"
        
            # Track which risks were flagged by how many agents (dedup by description prefix)
            risk_counts: dict[str, set[str]] = {}
        
            def _record(description: str, agent: str, phase: str):
                key = description[:60]  # group similar descriptions
                if key not in risk_counts:
                    risk_counts[key] = set()
                risk_counts[key].add(agent)
        
            # From premortems (pre-positional)
            for p in premortems.values():
                if p.root_causes:
                    cause = "; ".join(p.root_causes[:3])
                    _record(cause, p.agent_name, "premortem")
        
            # From cross-examinations (post-positional)
            for rnd in cross_rounds:
                for ce in rnd.values():
                    if ce.remaining_disagreements:
                        for d in ce.remaining_disagreements[:2]:
                            _record(d, ce.agent_name, "cross_examine")
        
            # Build final risk list with computed severity
            premortem_agents = {p.agent_name for p in premortems.values()}
            for key, agents in risk_counts.items():
                # Determine phase: if any premortem agent flagged it, origin is premortem
                phase = "premortem" if any(a in premortem_agents for a in agents) else "cross_examine"
                risks.append(
                    RiskVector(
                        description=key,
                        agents_who_flagged=list(agents),
                        severity=_severity(len(agents)),
                        phase_discovered=phase,
                    )
                )
        
            return risks
        
        
        async def synthesize(state: CouncilState) -> Synthesis:
            """Produce the final synthesis from all phase outputs.
        
            Combines algorithmic convergence metrics with an LLM-generated
            narrative synthesis of the decision landscape.
            """
            cfg = load_config()
        
            # Collect all risks
            risks = _collect_risks(
                state.premortems, state.positions, state.cross_examination_rounds
            )
        
            # Build convergence history
            history: list[RoundMetrics] = []
            for i in range(len(state.cross_examination_rounds)):
                # Temporarily set round_number to replay metrics
                state.round_number = i + 1
                metrics = compute_round_metrics(state)
                history.append(metrics)
        
            # Compute final metrics
            final_metrics = history[-1] if history else None
            first_metrics = history[0] if len(history) > 1 else final_metrics
        
            # Identify shared concerns from cross-examination
            shared_concerns = _extract_shared_concerns(state.cross_examination_rounds)
        
            # Identify disagreements
            disagreements = _extract_disagreements(state)
        
            # Build assumptions per position
            assumptions_per_position = {
                name: pos.key_assumptions for name, pos in state.positions.items()
            }
        
            # Generate narrative synthesis via LLM
            narrative = await _generate_synthesis_narrative(state, cfg)
        
            # Post-synthesis verification: scan for unsubstantiated factual claims
            verification = await verify_synthesis(narrative)
            verification_note = ""
            if verification.has_issues:
                items = "\n".join(
                    f"  • \"{c.quote[:120]}\" — {c.claim_type}: {c.explanation[:150]}"
                    for c in verification.claims[:5]
                )
                verification_note = (
                    f"\n\n---\n"
                    f"⚠️  Claims Not Verified\n"
                    f"The following assertions in this synthesis could not be verified "
                    f"by the council's own reasoning and should be checked before acting:\n"
                    f"{items}\n"
                )
                if len(verification.claims) > 5:
                    verification_note += (
                        f"  ...and {len(verification.claims) - 5} more unsubstantiated "
                        f"claim(s)."
                    )
            narrative += verification_note
        
            stopped_reason = "max_rounds"  # will be overwritten by graph.py
        
            return Synthesis(
                question=state.question,
                mode=state.mode,
                num_agents=len(state.profiles) or len(state.personas),
                rounds_completed=len(state.cross_examination_rounds),
                stopped_reason=stopped_reason,  # type: ignore
                confidence_history=history,
                final_dispersion=final_metrics.dispersion if final_metrics else 0.0,
                mean_confidence_delta=(
                    (final_metrics.mean_confidence - first_metrics.mean_confidence)
                    if final_metrics and first_metrics
                    else 0.0
                ),
                shared_risks=[r for r in risks if r.phase_discovered == "premortem"],
                shared_concerns=shared_concerns,
                disagreements=disagreements,
                assumptions_per_position=assumptions_per_position,
                risk_vectors=risks,
                principal_path=narrative,
            )
        
        
        def _extract_shared_concerns(
            cross_rounds: list[dict[str, CrossExamination]],
        ) -> list[str]:
            """Find concerns raised by multiple agents across rounds."""
            concern_counts: dict[str, int] = {}
            for rnd in cross_rounds:
                for ce in rnd.values():
                    for d in ce.remaining_disagreements:
                        concern_counts[d] = concern_counts.get(d, 0) + 1
                    for c in ce.concessions:
                        concern_counts[c] = concern_counts.get(c, 0) + 1
        
            # Return concerns raised by more than one agent
            return [
                concern
                for concern, count in sorted(
                    concern_counts.items(), key=lambda x: -x[1]
                )
                if count > 1
            ][:10]
        
        
        def _extract_disagreements(state: CouncilState) -> list[Disagreement]:
            """Identify persistent disagreements from the last round."""
            if not state.cross_examination_rounds:
                return []
        
            last_round = state.cross_examination_rounds[-1]
            topic_positions: dict[str, dict[str, str]] = {}
        
            for ce in last_round.values():
                for d in ce.remaining_disagreements:
                    if d not in topic_positions:
                        topic_positions[d] = {}
                    topic_positions[d][ce.agent_name] = ce.updated_position or "maintains position"
        
            return [
                Disagreement(topic=topic, positions=positions)
                for topic, positions in topic_positions.items()
            ][:8]
        
        
        async def _generate_synthesis_narrative(
            state: CouncilState, cfg: dict
        ) -> str:
            """Generate a narrative principal's path via LLM."""
            # Build a summary of the debate for the LLM
            summary_parts = [f"# Debate: {state.question}\n"]
            summary_parts.append(f"Agents: {', '.join(p.name for p in state.personas)}\n")
        
            summary_parts.append("\n## Positions\n")
            for pos in state.positions.values():
                summary_parts.append(
                    f"- **{pos.agent_name}** (confidence {pos.confidence}): {pos.stance}\n"
                )
        
            summary_parts.append("\n## Premortem Failure Scenarios\n")
            for pm in state.premortems.values():
                summary_parts.append(f"- **{pm.agent_name}**: {pm.failure_scenario[:200]}\n")
        
            summary_parts.append("\n## Cross-Examination Rounds\n")
            for i, rnd in enumerate(state.cross_examination_rounds):
                summary_parts.append(f"\n### Round {i + 1}\n")
                for ce in rnd.values():
                    summary_parts.append(f"- **{ce.agent_name}**: {ce.reflection[:200]}\n")
        
            debate_summary = "".join(summary_parts)
        
            agent = Agent(
                cfg["model"],
                retries=3,
                system_prompt=(
                    "You are a senior decision analyst. You have overseen a structured "
                    "multi-agent debate on an important question. Your job: synthesize "
                    "the debate into a clear 'principal's path' — a narrative that "
                    "presents the decision landscape to someone who must make a call.\n\n"
                    "Do NOT describe the debate process (rounds, phases, agents). "
                    "Write as a single analyst presenting their findings. Structure: "
                    "what's at stake, where the evidence is strongest, where it's weakest, "
                    "what assumptions each path depends on, and your recommended path "
                    "forward with associated risks.\n\n"
                    "Keep it under 500 words. Be direct. No hedging."
                ),
            )
            result = await agent.run(debate_summary)
            return result.output or ""
        
      • __init__.py 18 B
        """Phase init."""
        
    • cli.py 7.4 KB
      """CLI entry point for agent-council."""
      
      import argparse
      import asyncio
      import json
      import sys
      
      
      def main():
          """Entry point for `agent-council` CLI."""
          parser = argparse.ArgumentParser(
              description="Multi-agent structured debate system",
              formatter_class=argparse.RawDescriptionHelpFormatter,
              epilog=(
                  "Examples:\n"
                  "  agent-council \"Should we use Postgres or SQLite?\"\n"
                  "  agent-council --mode quick --agents 3 \"Quick check on this idea\"\n"
                  "  agent-council --json \"Output as machine-readable JSON\"\n"
                  "  agent-council --persona-file personas.json \"Custom agent lineup\"\n\n"
                  "Environment:\n"
                  "  AGENT_COUNCIL_API_KEY   API key (required)\n"
                  "  AGENT_COUNCIL_MODEL     Model string (default: openai:gpt-5.6-luna)\n"
                  "  AGENT_COUNCIL_BASE_URL  Custom API endpoint\n"
              ),
          )
          parser.add_argument(
              "question",
              type=str,
              help="The question to debate",
          )
          parser.add_argument(
              "--agents", "-n",
              type=int,
              default=5,
              choices=range(3, 8),
              help="Number of debate agents (3-7, default: 5)",
          )
          parser.add_argument(
              "--mode", "-m",
              type=str,
              default="medium",
              choices=["quick", "medium", "deep"],
              help="Debate depth (default: medium)",
          )
          parser.add_argument(
              "--persona-file",
              type=str,
              default=None,
              help="JSON file with custom agent persona definitions",
          )
          parser.add_argument(
              "--json",
              action="store_true",
              help="Output as structured JSON instead of markdown",
          )
          parser.add_argument(
              "--verbose", "-v",
              action="store_true",
              help="Show phase-by-phase progress",
          )
          parser.add_argument(
              "--max-rounds",
              type=int,
              default=4,
              help="Maximum cross-examination rounds (default: 4)",
          )
          parser.add_argument(
              "--convergence",
              type=float,
              default=0.10,
              help="Convergence threshold for confidence dispersion (default: 0.10)",
          )
          parser.add_argument(
              "--profiles",
              type=str,
              default=None,
              help="Comma-separated profile names from the hermes-profiles library "
                   "(e.g. 'debugger,researcher,product-manager'). "
                   "Omit for auto-selection based on the question.",
          )
      
          args = parser.parse_args()
      
          if not args.question.strip():
              print("Error: Question cannot be empty.", file=sys.stderr)
              sys.exit(3)
      
          # Map mode to agent count
          agent_map = {"quick": 3, "medium": 5, "deep": 7}
          num_agents = args.agents or agent_map.get(args.mode, 5)
      
          # Import here so CLI help is fast even without pydantic-ai installed
          try:
              from agent_council.graph import run_debate
          except ImportError as e:
              print(
                  f"Error: Could not import agent_council: {e}",
                  file=sys.stderr,
              )
              print(
                  "Make sure pydantic-ai is installed: pip install pydantic-ai",
                  file=sys.stderr,
              )
              sys.exit(1)
      
          # Parse explicit profile list
          profile_names = None
          if args.profiles:
              profile_names = [n.strip() for n in args.profiles.split(",")]
      
          try:
              state = asyncio.run(
                  run_debate(
                      question=args.question,
                      num_agents=num_agents,
                      mode=args.mode,
                      max_rounds=args.max_rounds,
                      convergence_threshold=args.convergence,
                      verbose=args.verbose,
                      persona_file=args.persona_file,
                      profile_names=profile_names,
                  )
              )
          except ValueError as e:
              print(f"Configuration error: {e}", file=sys.stderr)
              sys.exit(1)
          except Exception as e:
              print(f"Debate failed: {e}", file=sys.stderr)
              sys.exit(2)
      
          synthesis = state.synthesis
          if not synthesis:
              print("Error: Debate completed but no synthesis was produced.", file=sys.stderr)
              sys.exit(1)
      
          if args.json:
              print(synthesis.model_dump_json(indent=2))
          else:
              print(format_synthesis_markdown(synthesis))
      
      
      def format_synthesis_markdown(synthesis) -> str:
          """Format synthesis as human-readable markdown."""
          from agent_council.state import Synthesis
      
          lines = []
          lines.append(f"# Council Synthesis")
          lines.append(f"")
          lines.append(f"**Question:** {synthesis.question}")
          lines.append(f"**Mode:** {synthesis.mode} ({synthesis.num_agents} agents, {synthesis.rounds_completed} rounds)")
          lines.append(f"**Stopped because:** {synthesis.stopped_reason}")
          lines.append(f"")
      
          # Confidence dispersion
          lines.append(f"## Confidence Dispersion")
          lines.append(f"")
          lines.append(f"| Round | Mean Confidence | Dispersion | New Args | Concessions |")
          lines.append(f"|-------|----------------|------------|----------|-------------|")
          for m in synthesis.confidence_history:
              lines.append(
                  f"| {m.round} | {m.mean_confidence:.3f} | {m.dispersion:.3f} | "
                  f"{m.new_arguments} | {m.concessions_made} |"
              )
          lines.append(f"")
          lines.append(f"**Final dispersion:** {synthesis.final_dispersion:.3f}")
          lines.append(f"**Mean confidence delta:** {synthesis.mean_confidence_delta:+.3f}")
          lines.append(f"")
      
          # Diagnostic
          if synthesis.final_dispersion < 0.08:
              diag = "Confidence converged — agents reached alignment."
          elif synthesis.final_dispersion > 0.15:
              diag = "Confidence remained dispersed — genuine disagreement persisted."
          else:
              diag = "Moderate agreement with meaningful remaining tension."
          lines.append(f"> **Diagnostic:** {diag}")
          lines.append(f"")
      
          # Shared risks (from premortem)
          if synthesis.shared_risks:
              lines.append(f"## Shared Risks (Pre-Mortem)")
              lines.append(f"")
              for risk in synthesis.shared_risks:
                  agents = ", ".join(risk.agents_who_flagged)
                  lines.append(f"- **{risk.severity.upper()}** — {risk.description}")
                  lines.append(f"  *Flagged by: {agents}*")
              lines.append(f"")
      
          # Shared concerns
          if synthesis.shared_concerns:
              lines.append(f"## Shared Concerns (Confirmed by Debate)")
              lines.append(f"")
              for concern in synthesis.shared_concerns:
                  lines.append(f"- {concern}")
              lines.append(f"")
      
          # Disagreements
          if synthesis.disagreements:
              lines.append(f"## Remaining Disagreements")
              lines.append(f"")
              for d in synthesis.disagreements:
                  lines.append(f"- **{d.topic}**")
                  for agent, pos in d.positions.items():
                      lines.append(f"  - {agent}: {pos[:120]}")
              lines.append(f"")
      
          # Assumptions
          if synthesis.assumptions_per_position:
              lines.append(f"## Assumptions per Position")
              lines.append(f"")
              for agent, assumptions in synthesis.assumptions_per_position.items():
                  lines.append(f"- **{agent}**")
                  for a in assumptions:
                      lines.append(f"  - {a}")
              lines.append(f"")
      
          # Principal's path
          if synthesis.principal_path:
              lines.append(f"## Principal's Path")
              lines.append(f"")
              lines.append(synthesis.principal_path)
              lines.append(f"")
      
          return "\n".join(lines)
      
      
      if __name__ == "__main__":
          main()
      
    • config.py 3.2 KB
      """Configuration — env var loading with sensible defaults and .env support."""
      
      import os
      from pathlib import Path
      
      
      def _load_dotenv(path: Path | None = None) -> None:
          """Load .env file using stdlib only. Looks for .env in cwd by default.
      
          Minimal implementation — no python-dotenv dependency. Handles:
            KEY=value
            KEY="quoted value"
            # comments
            export KEY=value  (strips export prefix)
          """
          dotenv_path = path or Path.cwd() / ".env"
          if not dotenv_path.exists():
              return
      
          for line in dotenv_path.read_text().splitlines():
              line = line.strip()
              if not line or line.startswith("#"):
                  continue
              if line.startswith("export "):
                  line = line[7:].strip()
              if "=" not in line:
                  continue
              key, _, value = line.partition("=")
              key = key.strip()
              value = value.strip().strip("\"'")
              if key and key not in os.environ:
                  os.environ[key] = value
      
      
      def load_config() -> dict:
          """Load configuration from environment variables and .env files.
      
          Checks for a .env file in the current working directory first,
          then falls back to environment variables. Env vars always take
          precedence over .env values.
      
          Also sets the provider-specific API key env var (e.g. OPENAI_API_KEY,
          DEEPSEEK_API_KEY, ANTHROPIC_API_KEY) from AGENT_COUNCIL_API_KEY so
          PydanticAI picks it up regardless of what's in the environment.
      
          Returns dict with keys: api_key, model, base_url.
          Raises ValueError if AGENT_COUNCIL_API_KEY is not set.
          """
          _load_dotenv()
      
          api_key = os.environ.get("AGENT_COUNCIL_API_KEY")
          model = os.environ.get("AGENT_COUNCIL_MODEL", "openai:gpt-5.6-luna")
          base_url = os.environ.get("AGENT_COUNCIL_BASE_URL")
      
          if not api_key:
              raise ValueError(
                  "AGENT_COUNCIL_API_KEY is not set. "
                  "Set it via environment variable or create a .env file:\n"
                  "  export AGENT_COUNCIL_API_KEY='sk-...'\n"
                  "  export AGENT_COUNCIL_MODEL='openai:gpt-5.6-luna'  # or your model\n\n"
                  "Or create a .env file in the current directory:\n"
                  "  AGENT_COUNCIL_API_KEY=sk-...\n"
                  "  AGENT_COUNCIL_MODEL=openai:gpt-5.6-luna"
              )
      
          # Map AGENT_COUNCIL_API_KEY to the provider-specific env var
          # that PydanticAI reads at Agent creation time.
          provider = model.split(":")[0] if ":" in model else "openai"
          provider_key_map = {
              "openai": "OPENAI_API_KEY",
              "deepseek": "DEEPSEEK_API_KEY",
              "anthropic": "ANTHROPIC_API_KEY",
              "google": "GOOGLE_API_KEY",
              "groq": "GROQ_API_KEY",
              "cohere": "COHERE_API_KEY",
              "mistral": "MISTRAL_API_KEY",
              "together": "TOGETHER_API_KEY",
              "xai": "XAI_API_KEY",
              "ollama": None,  # no API key needed
          }
          env_var = provider_key_map.get(provider, "OPENAI_API_KEY")
          if env_var and not os.environ.get(env_var):
              os.environ[env_var] = api_key
          # Also set base URL if provided
          if base_url and not os.environ.get("OPENAI_BASE_URL"):
              os.environ["OPENAI_BASE_URL"] = base_url
      
          config = {
              "api_key": api_key,
              "model": model,
          }
          if base_url:
              config["base_url"] = base_url
          return config
      
    • convergence.py 3.7 KB
      """Convergence detection — evaluates debate state to decide when to stop."""
      
      import math
      from agent_council.state import CouncilState, RoundMetrics
      
      
      def compute_round_metrics(state: CouncilState) -> RoundMetrics:
          """Compute convergence metrics from the current round's data."""
          if not state.cross_examination_rounds:
              return RoundMetrics(
                  round=state.round_number,
                  mean_confidence=0.0,
                  dispersion=0.0,
                  new_arguments=0,
                  concessions_made=0,
              )
      
          current_round = state.cross_examination_rounds[-1]
          confidences = []
          concessions = 0
          total_arguments_before = set()
      
          # Count arguments from all prior rounds for novelty detection
          for r in state.cross_examination_rounds[:-1]:
              for ce in r.values():
                  if ce.remaining_disagreements:
                      total_arguments_before.update(ce.remaining_disagreements)
                  if ce.new_evidence_needed:
                      total_arguments_before.update(ce.new_evidence_needed)
      
          new_arguments = 0
          for ce in current_round.values():
              if ce.updated_confidence is not None:
                  confidences.append(ce.updated_confidence)
              if ce.concessions:
                  concessions += len(ce.concessions)
              if ce.remaining_disagreements:
                  for arg in ce.remaining_disagreements:
                      if arg not in total_arguments_before:
                          new_arguments += 1
      
          mean_conf = sum(confidences) / len(confidences) if confidences else 0.0
          dispersion = (
              math.sqrt(sum((c - mean_conf) ** 2 for c in confidences) / len(confidences))
              if confidences
              else 0.0
          )
      
          return RoundMetrics(
              round=state.round_number,
              mean_confidence=round(mean_conf, 3),
              dispersion=round(dispersion, 3),
              new_arguments=new_arguments,
              concessions_made=concessions,
          )
      
      
      def should_stop(state: CouncilState, metrics: RoundMetrics) -> str:
          """Evaluate whether the debate should stop.
      
          Returns one of:
            - "converged" — dispersion below threshold, confidence stable
            - "diminishing_returns" — nothing new is surfacing
            - "genuine_disagreement" — dispersion widened, positions hardened
            - "continue" — run another round
          """
          # Hard cap
          if state.round_number >= state.max_rounds:
              return "max_rounds"
      
          # Need at least 2 rounds to compare
          if len(state.cross_examination_rounds) < 2:
              return "continue"
      
          prior = state.cross_examination_rounds[-2]
          prior_confs = [
              ce.updated_confidence
              for ce in prior.values()
              if ce.updated_confidence is not None
          ]
          current_confs = [
              ce.updated_confidence
              for ce in state.cross_examination_rounds[-1].values()
              if ce.updated_confidence is not None
          ]
      
          if not prior_confs or not current_confs:
              return "continue"
      
          prior_mean = sum(prior_confs) / len(prior_confs)
          current_mean = sum(current_confs) / len(current_confs)
      
          # Converged: dispersion below threshold
          if metrics.dispersion < state.convergence_threshold:
              # Still check if anything changed — settled means done
              if abs(current_mean - prior_mean) < 0.03:
                  return "converged"
              return "continue"
      
          # Diminishing returns: no new arguments, no concessions
          if metrics.new_arguments == 0 and metrics.concessions_made == 0:
              return "diminishing_returns"
      
          # Genuine disagreement: dispersion widened and no one moved
          if (
              metrics.dispersion > state.convergence_threshold * 1.5
              and metrics.concessions_made == 0
              and metrics.new_arguments == 0
          ):
              return "genuine_disagreement"
      
          return "continue"
      
    • graph.py 7.6 KB
      """Graph orchestration — runs the debate protocol as a state machine."""
      
      import json
      import sys
      import time
      from datetime import datetime, timezone
      from pathlib import Path
      
      from agent_council.state import CouncilState
      from agent_council.phases.compose import compose_personas
      from agent_council.phases.select import select_by_names, select_by_question
      from agent_council.phases.premortem import run_premortems
      from agent_council.phases.position import run_positions
      from agent_council.phases.cross_examine import run_cross_examination
      from agent_council.phases.synthesis import synthesize
      from agent_council.convergence import should_stop, compute_round_metrics
      
      
      def _stream(msg: str, end: str = "\n") -> None:
          """Print a progress message immediately to stdout."""
          print(msg, end=end, flush=True)
      
      
      def _run_dir() -> Path:
          """Create and return a timestamped run directory."""
          ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
          path = Path(f"/tmp/agent-council/{ts}")
          path.mkdir(parents=True, exist_ok=True)
          return path
      
      
      def _identity_for(
          state: CouncilState,
          name: str,
          fallback_persona=None,
      ) -> str:
          """Build an identity block for a debate agent.
      
          If real profiles are loaded, uses the SOUL.md content.
          Otherwise falls back to fabricated persona fields.
          """
          # Prefer real profiles
          for p in state.profiles:
              if p.name == name:
                  return (
                      f"You are {name}.\n\n"
                      f"Your identity and operating principles:\n"
                      f"{p.soul_content}\n\n"
                      f"Description: {p.description}"
                  )
      
          # Fallback to fabricated persona
          if fallback_persona:
              return (
                  f"You are {fallback_persona.name}.\n"
                  f"Background: {fallback_persona.background}\n"
                  f"Expertise: {fallback_persona.expertise}\n"
                  f"Approach: {fallback_persona.approach}\n"
                  f"Bias: {fallback_persona.bias}"
              )
      
          return f"You are {name}."
      
      
      async def run_debate(
          question: str,
          num_agents: int = 5,
          mode: str = "medium",
          max_rounds: int = 4,
          convergence_threshold: float = 0.10,
          verbose: bool = False,
          persona_file: str | None = None,
          profile_names: list[str] | None = None,
      ) -> CouncilState:
          """Run the full debate protocol with live progress output.
      
          Phases:
            1. Select/Compose — pick real profiles or generate personas
            2. Premortem — each agent envisions failure
            3. Position — each agent forms initial position
            4. Cross-examine — iterative, convergence-checked rounds
            5. Synthesis — produce decision landscape
          """
          rundir = _run_dir()
          state = CouncilState(
              question=question,
              mode=mode,
              max_rounds=max_rounds,
              convergence_threshold=convergence_threshold,
          )
      
          # Phase 1: Select or Compose agents
          _stream("🏛  Council assembling...")
      
          if profile_names:
              # Explicit profile selection
              state.profiles = select_by_names(profile_names)
              _stream(f"   📂 Loaded {len(state.profiles)} profiles (explicit)")
          else:
              # Try auto-selecting profiles from the library
              state.profiles = select_by_question(question, num_agents)
              if state.profiles:
                  _stream(f"   📂 Auto-selected {len(state.profiles)} profiles from library")
              else:
                  # Fallback: compose fabricated personas
                  _stream("   ⚡ No profile library found, composing personas...")
                  if persona_file:
                      from agent_council.state import AgentPersona
                      with open(persona_file) as f:
                          data = json.load(f)
                          state.personas = [AgentPersona(**p) for p in data]
                      _stream(f"   Loaded {len(state.personas)} personas from file")
                  else:
                      state.personas = await compose_personas(question, num_agents)
      
          if verbose:
              for p in state.profiles or state.personas:
                  name = p.name if hasattr(p, 'name') else p
                  _stream(f"   👤 {name}")
      
          # Write agent identities to run dir
          with open(rundir / "agents.json", "w") as f:
              agents = {
                  "profiles": [
                      {"name": p.name, "description": p.description}
                      for p in state.profiles
                  ],
                  "personas": [
                      {"name": p.name, "expertise": p.expertise}
                      for p in state.personas
                  ],
              }
              f.write(json.dumps(agents, indent=2, default=str))
              _stream(f"   ✅ {len(state.profiles or state.personas)} agents ready")
      
          # Phase 2: Premortem
          _stream("   🔮 Pre-mortem phase...")
          t0 = time.time()
          state.premortems = await run_premortems(question, state, verbose)
          _stream(f"   ✅ Pre-mortem complete ({len(state.premortems)} agents, {time.time()-t0:.0f}s)")
      
          with open(rundir / "premortems.json", "w") as f:
              f.write(json.dumps(
                  {k: v.model_dump() for k, v in state.premortems.items()},
                  indent=2,
                  default=str,
              ))
      
          # Phase 3: Position
          _stream("   📋 Position phase...")
          t0 = time.time()
          state.positions = await run_positions(question, state, verbose)
          confidences = [p.confidence for p in state.positions.values()]
          avg_conf = sum(confidences) / len(confidences) if confidences else 0
          _stream(f"   ✅ Positions formed ({len(state.positions)} agents, avg confidence {avg_conf:.2f}, {time.time()-t0:.0f}s)")
      
          if verbose:
              for pos in state.positions.values():
                  _stream(f"      {pos.agent_name}: {pos.stance[:80]}...")
      
          with open(rundir / "positions.json", "w") as f:
              f.write(json.dumps(
                  {k: v.model_dump() for k, v in state.positions.items()},
                  indent=2,
                  default=str,
              ))
      
          # Phase 4: Iterative cross-examination
          _stream("   💬 Cross-examination rounds...")
      
          round_num = 0
          while round_num < max_rounds:
              round_num += 1
              state.round_number = round_num
      
              _stream(f"      Round {round_num}... ", end="")
              t0 = time.time()
              cross_results = await run_cross_examination(question, state, verbose)
              state.cross_examination_rounds.append(cross_results)
      
              metrics = compute_round_metrics(state)
              stop_reason = should_stop(state, metrics)
      
              elapsed = time.time() - t0
              _stream(
                  f"dispersion={metrics.dispersion:.3f} "
                  f"concessions={metrics.concessions_made} "
                  f"new_args={metrics.new_arguments} "
                  f"({elapsed:.0f}s) → {stop_reason}"
              )
      
              with open(rundir / f"round_{round_num}.json", "w") as f:
                  f.write(json.dumps(
                      {k: v.model_dump() for k, v in cross_results.items()},
                      indent=2,
                      default=str,
                  ))
      
              if stop_reason != "continue":
                  object.__setattr__(state, "_stopped_reason", stop_reason)
                  break
      
          # Phase 5: Synthesis
          _stream("   📝 Synthesizing final report...")
          t0 = time.time()
          state.synthesis = await synthesize(state)
          _stream(f"   ✅ Synthesis complete ({time.time()-t0:.0f}s)")
      
          stop_reason = getattr(state, "_stopped_reason", "max_rounds")
          state.synthesis.stopped_reason = stop_reason  # type: ignore
      
          with open(rundir / "synthesis.json", "w") as f:
              f.write(state.synthesis.model_dump_json(indent=2))
      
          with open(rundir / "synthesis.md", "w") as f:
              from agent_council.cli import format_synthesis_markdown
              f.write(format_synthesis_markdown(state.synthesis))
      
          _stream(f"\n📁 Full debate output: {rundir}/\n")
      
          return state
      
    • guardrails.py 5.1 KB
      """Shared guardrails and eval utilities for the debate pipeline."""
      
      from pydantic import BaseModel, Field
      
      from agent_council.config import load_config
      
      
      # ── Prompt guardrail injected into every debate agent ──
      
      FACTUAL_CLAIM_GUARDRAIL = (
          "\n\n---\n"
          "CRITICAL RULE — Do NOT assert specific verifiable facts about the external "
          "world in your responses.\n"
          "This includes, but is not limited to:\n"
          "  • Domain availability (\"example.com is available\")\n"
          "  • Package namespace status (\"no collisions on PyPI\")\n"
          "  • GitHub/trademark/registry availability\n"
          "  • Pricing, statistics, dates, or third-party features\n"
          "  • Whether a specific tool, library, or API exists or works in a specific way\n\n"
          "You have no ability to verify any of these claims. If you would need to "
          "check an external source to know something, say it needs to be verified "
          "rather than asserting it as fact.\n\n"
          "RIGHT: \"Dialekt would need to be checked against package registries and "
          "domain availability before committing.\"\n"
          "WRONG: \"Dialekt passes all five checks — no domain collisions, clear "
          "GitHub namespace, no PyPI conflicts.\"\n\n"
          "The second example fabricates specific facts the agent could not know. "
          "This undermines the entire debate. A recommendation based on fabricated "
          "premises is worse than no recommendation at all.\n"
          "---"
      )
      
      
      # ── Post-synthesis verification eval ──
      
      
      class UnsubstantiatedClaim(BaseModel):
          """A claim in the synthesis that asserts a verifiable fact without evidence."""
      
          quote: str = Field(description="The exact text making the claim")
          claim_type: str = Field(
              description="Type of claim, e.g. 'domain availability', "
              "'package namespace', 'trademark status', 'pricing', "
              "'statistic', 'third-party feature', 'date'"
          )
          explanation: str = Field(
              description="Why this claim cannot be verified from the debate's own reasoning"
          )
      
      
      class SynthesisVerification(BaseModel):
          """Result of scanning a synthesis for unsubstantiated factual claims."""
      
          has_issues: bool = Field(
              description="True if any unsubstantiated claims were found"
          )
          claims: list[UnsubstantiatedClaim] = Field(default_factory=list)
          summary: str = Field(
              description="One-line summary of the verification result"
          )
      
      
      async def verify_synthesis(synthesis_text: str) -> SynthesisVerification:
          """Scan a council synthesis for unsupported factual claims.
      
          This is an LLM-based eval pass that identifies assertions the
          debate could not have verified from its own reasoning. Does not
          require search or external tools — just reads the output critically.
          """
          from pydantic_ai import Agent
      
          cfg = load_config()
      
          verifier = Agent(
              cfg["model"],
              output_type=SynthesisVerification,
              retries=3,
              system_prompt=(
                  "You are a verification quality-assurance agent. Your job is to "
                  "read a council debate synthesis and identify any claims that "
                  "assert specific, verifiable facts about the external world "
                  "that the debate could not have verified.\n\n"
                  "Flag claims about:\n"
                  "- Domain availability or registration status\n"
                  "- Package manager namespace collisions (PyPI, npm, crates.io, etc.)\n"
                  "- GitHub namespace, trademark, or registry availability\n"
                  "- Pricing, revenue, or cost figures\n"
                  "- Statistics, dates, or third-party features\n"
                  "- Whether a specific tool, library, or API exists or works "
                  "in a specific way\n"
                  "- Any other claim that would require external verification\n\n"
                  "Do NOT flag:\n"
                  "- Opinions, preferences, or qualitative assessments\n"
                  "- Claims explicitly marked as needing verification\n"
                  "- Reasoning chains and logical arguments\n"
                  "- General statements about a field or domain\n\n"
                  "Be precise — quote the exact text and explain why it's "
                  "unsubstantiated."
              ),
          )
      
          result = await verifier.run(synthesis_text)
          return result.output
      
      
      def substantiated_claim_patterns() -> list[str]:
          """Return regex patterns for the kinds of claims agents fabricate.
      
          Used by the post-synthesis eval to flag unsubstantiated assertions.
          """
          return [
              # Domain claims
              r"\b\w+\.(com|org|dev|io|net|app)\s+(is\s+)?(available|taken|registered)\b",
              r"\b(domain|url)\s+(check|availability|registration)\b",
              # Package registry claims
              r"\b(PyPI|npm|crates\.io|rubygems|maven)\s+(has|has no|is\s+)?(collision|available|taken|conflict)",
              r"\bno\s+(package\s+manager|namespace|registry)\s+(collision|conflict)",
              # GitHub / trademark claims
              r"\b(GitHub|namespace|trademark)\s+(is\s+)?(clear|available|not\s+conflicting|uncontested)",
              # Verification claims without evidence
              r"\b(passes|passing|passed|satisfies|meets)\s+(all\s+)?(checks?|gates?|requirements?|criteria)",
          ]
      
    • state.py 6.2 KB
      """Typed state model and phase output schemas."""
      
      from dataclasses import dataclass, field
      from typing import Literal
      
      from pydantic import BaseModel, Field
      
      
      # ── Profile info (from submoduled hermes-profiles) ──
      
      
      @dataclass
      class ProfileInfo:
          """A real profile drawn from the hermes-profiles library.
      
          Used in place of fabricated AgentPersona when profiles are available.
          The name is the profile directory name (e.g. 'debugger').
          The soul_content is the full identity document.
          """
          name: str
          description: str
          soul_content: str
      
      
      # ── Phase output schemas (validated Pydantic models) ──
      
      
      class AgentPersona(BaseModel):
          """Profile for a single debate agent."""
      
          name: str
          background: str = Field(description="One-paragraph career background")
          expertise: str = Field(description="Specific domain expertise")
          approach: str = Field(description="Analytical approach they bring")
          bias: str = Field(description="What experience or bias they bring to THIS question")
      
      
      class Premortem(BaseModel):
          """Pre-mortem: agent envisions how the decision already failed."""
      
          agent_name: str
          failure_scenario: str = Field(description="Narrative of how the decision failed")
          root_causes: list[str] = Field(description="What went wrong")
          early_warning_signals: list[str] = Field(description="What to watch for")
      
      
      class Position(BaseModel):
          """Agent's initial position on the question."""
      
          agent_name: str
          stance: str = Field(description="Position on the question")
          reasoning: list[str] = Field(description="Chain of reasoning")
          confidence: float = Field(ge=0, le=1, description="Confidence in this position")
          key_assumptions: list[str] = Field(description="Assumptions that must hold")
      
      
      class CrossExamination(BaseModel):
          """Agent's response after reading all other positions."""
      
          agent_name: str
          concessions: list[str] = Field(description="Points where the agent conceded or shifted")
          remaining_disagreements: list[str] = Field(description="Points still in dispute")
          updated_position: str | None = Field(
              default=None, description="Revised position, if changed"
          )
          updated_confidence: float | None = Field(
              default=None, ge=0, le=1, description="Updated confidence, if changed"
          )
          reflection: str = Field(
              description="What the agent learned from other perspectives"
          )
          new_evidence_needed: list[str] = Field(
              default_factory=list,
              description="What evidence would close remaining gaps",
          )
      
      
      class RiskVector(BaseModel):
          """A risk identified during the debate, with position-relative context."""
      
          description: str
          agents_who_flagged: list[str]
          severity: Literal["low", "medium", "high"]
          phase_discovered: Literal["premortem", "position", "cross_examine"] = Field(
              description="Which phase first surfaced this risk. "
              "Premortem risks are seen BEFORE positional commitment."
          )
      
      
      class RoundMetrics(BaseModel):
          """Convergence metrics for a single cross-examination round."""
      
          round: int
          mean_confidence: float
          dispersion: float = Field(description="Standard deviation of agent confidences")
          new_arguments: int = Field(description="Arguments not seen in prior rounds")
          concessions_made: int
          stopped_early: bool = Field(
              default=False,
              description="True if this round was cut short by convergence detection",
          )
      
      
      class Disagreement(BaseModel):
          """A point of genuine disagreement that survived cross-examination."""
      
          topic: str
          positions: dict[str, str] = Field(
              description="Agent name -> summary of their position on this topic"
          )
          unresolved: bool = Field(
              default=True,
              description="Whether this disagreement persisted after all rounds",
          )
      
      
      class Synthesis(BaseModel):
          """Structured output of a completed council debate."""
      
          # Metadata
          question: str
          mode: str
          num_agents: int
          rounds_completed: int
          stopped_reason: Literal[
              "converged",
              "max_rounds",
              "diminishing_returns",
              "genuine_disagreement",
          ] = Field(description="Why the debate stopped")
      
          # Convergence diagnostics
          confidence_history: list[RoundMetrics] = Field(
              description="One entry per cross-examination round"
          )
          final_dispersion: float
          mean_confidence_delta: float = Field(
              description="Change in mean confidence from first to last round"
          )
      
          # Content: premortem phase (pre-positional)
          shared_risks: list[RiskVector] = Field(
              description="Risks identified during pre-mortem before any agent "
              "formed a position. Compare with shared_concerns to see which "
              "worries survived cross-examination."
          )
      
          # Content: cross-examination phase (post-positional)
          shared_concerns: list[str] = Field(
              description="Concerns that survived cross-examination and are shared "
              "across agents. A risk in shared_risks that also appears here was "
              "confirmed by debate. A risk in shared_risks absent here is either "
              "resolved or buried by positional commitment."
          )
          disagreements: list[Disagreement]
          assumptions_per_position: dict[str, list[str]] = Field(
              description="Agent name -> assumptions that would need to hold "
              "for their position to be correct"
          )
          risk_vectors: list[RiskVector]
          principal_path: str = Field(description="Narrative synthesis of the decision landscape")
      
      
      # ── Orchestration state (mutable dataclass) ──
      
      
      @dataclass
      class CouncilState:
          """Mutable state that flows through the debate graph."""
      
          question: str
          mode: str = "medium"
          max_rounds: int = 4
          convergence_threshold: float = 0.10
      
          personas: list[AgentPersona] = field(default_factory=list)
          profiles: list[ProfileInfo] = field(default_factory=list)
          """Real profiles from the hermes-profiles library, if available.
          Takes priority over fabricated personas when present."""
          premortems: dict[str, Premortem] = field(default_factory=dict)
          positions: dict[str, Position] = field(default_factory=dict)
          cross_examination_rounds: list[dict[str, CrossExamination]] = field(
              default_factory=list
          )
          synthesis: Synthesis | None = None
          round_number: int = 0
      
    • __init__.py 85 B
      """Agent Council — Multi-agent structured debate system."""
      
      __version__ = "0.1.0"
      
    • __main__.py 132 B
      """__main__.py — enables `python -m agent_council`."""
      
      from agent_council.cli import main
      
      if __name__ == "__main__":
          main()
      
  • evals
    • evals.json 6.5 KB
      {
        "schema_version": 1,
        "skill_name": "agent-council",
        "evals": [
          {
            "id": "postgres-sqlite-tradeoff-debate",
            "case_set": "release",
            "prompt": "We're split on the database for a new internal service: Postgres feels heavy for what we need but SQLite might not survive our write pattern. Debate this properly and tell me what we'd be missing either way.",
            "expected_output": "The agent runs the agent-council CLI (bootstrapping it via scripts/bootstrap.py first if it is missing from PATH) with a medium or deep debate on the database question, then reports a structured decision landscape rather than a single flat recommendation.",
            "assertions": [
              "Invokes the agent-council CLI with the user's question, running python3 scripts/bootstrap.py if agent-council is not already available on PATH.",
              "Produces or relays a structured synthesis that includes shared risks from the premortem phase and remaining disagreements, not just a winner.",
              "Reports convergence diagnostics such as confidence dispersion and stopped_reason instead of only the final recommendation.",
              "Treats claims about verifiable external facts (benchmarks, pricing, limits) as hypotheses to check when they appear under Claims Not Verified."
            ]
          },
          {
            "id": "read-convergence-diagnostics",
            "case_set": "dev",
            "prompt": "I ran agent-council on our API-versioning question and got this JSON back: stopped_reason is max_rounds, mean confidence rose from 0.61 to 0.79 while dispersion widened from 0.05 to 0.14. What does that tell me and what should I do?",
            "expected_output": "A correct reading of the diagnostic table: rising confidence with widening dispersion indicates polarization/entrenchment, max_rounds means the debate hit its hard cap before resolving, and the response recommends concrete next steps such as rerunning with --max-rounds higher or --mode quick, or moving to an experimental path because argument alone may be irresolvable here.",
            "assertions": [
              "Identifies rising mean confidence combined with widening dispersion as polarization rather than genuine convergence.",
              "Explains that stopped_reason max_rounds means the hard cap was reached and the result is inconclusive, requiring a principal decision.",
              "Suggests at least one concrete remediation from the skill, such as raising --max-rounds, lowering to --mode quick, or designing an experiment to separate the positions.",
              "Does not treat the debate outcome as a converged recommendation despite the non-converged stopping condition."
            ]
          },
          {
            "id": "choose-quick-mode",
            "case_set": "dev",
            "prompt": "Before tomorrow's standup I want a fast sanity check on whether to name our staging cluster 'staging-eu' or 'eu-staging'. I don't need a whole ceremony.",
            "expected_output": "The agent runs the council in quick mode with the minimum agent count (agent-council --mode quick --agents 3 \"...\") since this is a low-stakes naming check, and frames expectations accordingly rather than launching a deep multi-round debate.",
            "assertions": [
              "Selects --mode quick with 3 agents as appropriate for a low-stakes question needing a fast answer.",
              "Passes the actual naming question to the CLI rather than answering it unilaterally without the requested multi-perspective check.",
              "Keeps cost and latency proportionate, avoiding deep mode or extra rounds for this decision size."
            ]
          },
          {
            "id": "verify-flagged-external-claims",
            "case_set": "release",
            "prompt": "Run the council on whether we should adopt this new vector database vendor. The synthesis came back positive but there's a 'Claims Not Verified' section flagging their throughput numbers. How do I use this result?",
            "expected_output": "The agent treats the synthesis as valuable but explicitly separates flagged claims from debate-supported reasoning: throughput and pricing assertions are hypotheses to verify against primary sources before any adoption decision, while the structural arguments survive cross-examination and can inform the decision landscape.",
            "assertions":
              [
                "Explains that Claims Not Verified entries must be checked against primary sources before acting, per the skill's guardrail.",
                "Distinguishes debate-derived structural findings from unverifiable factual claims about the vendor.",
                "Does not present the positive synthesis as an adoption green light while material claims remain unverified.",
                "Frames next steps as verification tasks (checking the vendor's published benchmarks or running a proof of concept)."
              ]
          },
          {
            "id": "profiles-unavailable-fallback",
            "case_set": "dev",
            "prompt": "I installed agent-council from pip inside a container. It runs, but I read that the hermes-profiles library isn't bundled — what happens to my panel of experts and what should I do?",
            "expected_output": "The agent explains that pip/wheel installs do not bundle the profile library, so auto-selection falls back to LLM-generated personas; it offers the two remedies from the skill — supply custom personas via --persona-file, or run from a recursive source checkout where profiles are available — and notes the diversity tradeoff.",
            "assertions": [
              "States that pip and wheel installs lack the hermes-profiles library and fall back to LLM-generated personas.",
              "Offers --persona-file with a JSON file of custom personas as the control path.",
              "Mentions that a recursive source checkout restores real professional profiles with SOUL.md methodology.",
              "Sets expectations that generated-persona diversity varies compared with real methodological voices."
            ]
          },
          {
            "id": "no-council-for-simple-fact",
            "case_set": "regression",
            "prompt": "What's the default port for PostgreSQL? Quick one.",
            "expected_output": "The agent answers the factual question directly (5432) without spawning a debate panel, recognizing that a single-fact lookup has a clear correct answer and no tradeoffs to adjudicate — this skill is not for simple factual queries or routine single-perspective work.",
            "assertions": [
              "Answers directly without invoking or bootstrapping the agent-council CLI.",
              "Does not propose a multi-agent debate, premortem, or council session for a settled factual lookup.",
              "Optionally notes when a debate would be warranted, without manufacturing artificial tradeoffs."
            ]
          }
        ]
      }
      
  • references
    • configuration.md 1.8 KB
      # Configuration Guide
      
      ## Environment Variables
      
      | Env var | Required | Default | Description |
      |---------|----------|---------|-------------|
      | `AGENT_COUNCIL_API_KEY` | Yes | — | API key for your LLM provider |
      | `AGENT_COUNCIL_MODEL` | No | `openai:gpt-5.6-luna` | Model string in `provider:model` format |
      | `AGENT_COUNCIL_BASE_URL` | No | Provider default | Custom API endpoint |
      
      ## Provider Setup
      
      ### OpenAI
      
      ```bash
      export AGENT_COUNCIL_API_KEY="sk-..."
      export AGENT_COUNCIL_MODEL="openai:gpt-5.6-luna"
      ```
      
      ### Anthropic
      
      ```bash
      export AGENT_COUNCIL_API_KEY="sk-ant-..."
      export AGENT_COUNCIL_MODEL="anthropic/claude-sonnet-4-20250514"
      ```
      
      ### DeepSeek
      
      ```bash
      export AGENT_COUNCIL_API_KEY="sk-..."
      export AGENT_COUNCIL_MODEL="deepseek/deepseek-v4-flash"
      export AGENT_COUNCIL_BASE_URL="https://api.deepseek.com/v1"
      ```
      
      ### OpenRouter
      
      ```bash
      export AGENT_COUNCIL_API_KEY="sk-or-..."
      export AGENT_COUNCIL_MODEL="openrouter/anthropic/claude-sonnet-4"
      export AGENT_COUNCIL_BASE_URL="https://openrouter.ai/api/v1"
      ```
      
      ### Local / Ollama
      
      ```bash
      export AGENT_COUNCIL_API_KEY="ollama"  # or any placeholder
      export AGENT_COUNCIL_MODEL="ollama/llama-3.2"
      export AGENT_COUNCIL_BASE_URL="http://localhost:11434/v1"
      ```
      
      ## Troubleshooting
      
      | Symptom | Cause | Fix |
      |---------|-------|-----|
      | `Configuration error: AGENT_COUNCIL_API_KEY is not set` | Missing API key | `export AGENT_COUNCIL_API_KEY="..."` |
      | `ImportError: No module named 'pydantic_ai'` | Missing dependency | `pip install pydantic-ai` |
      | Model not found | Wrong model string format | Check PydanticAI provider convention: `provider:model-name` |
      | Debate hangs or times out | Model too slow for N parallel calls | Reduce agents with `--agents 3`, or use a faster model |
      | All agents agree immediately | False consensus (same-model blind spots) | Check synthesis diagnostic; consider richer persona definitions |
      
    • convergence.md 1.9 KB
      # Convergence Detection
      
      The council uses algorithmic convergence detection to decide when to stop debating — not a fixed number of rounds.
      
      ## Metrics
      
      After each cross-examination round, four metrics are computed:
      
      | Metric | Calculation | Meaning |
      |--------|------------|---------|
      | **Mean confidence** | Average of all agents' `updated_confidence` values | Overall conviction level |
      | **Dispersion** | Standard deviation of confidence values | Agreement spread — how far apart agents are |
      | **New arguments** | `remaining_disagreements` + `new_evidence_needed` not seen in prior rounds | Whether the debate is still surfacing new material |
      | **Concessions** | Count of items in `concessions` across all agents | Whether positions are shifting |
      
      ## Decision Logic
      
      ```python
      if round >= max_rounds:
          stop_reason = "max_rounds"
      elif dispersion < threshold and confidence_delta < 0.03:
          stop_reason = "converged"
      elif new_arguments == 0 and concessions == 0 and rounds > 1:
          stop_reason = "diminishing_returns"
      elif dispersion > threshold * 1.5 and concessions == 0 and new_arguments == 0:
          stop_reason = "genuine_disagreement"
      else:
          stop_reason = "continue"  # run another round
      ```
      
      ## Default Thresholds
      
      | Mode | Default threshold | Max rounds |
      |------|------------------|------------|
      | quick | 0.15 | 2 |
      | medium | 0.10 | 4 |
      | deep | 0.08 | 4 |
      
      ## Diagnostic Interpretation
      
      | Pattern | Meaning |
      |---------|---------|
      | Mean confidence DROPPED, dispersion WIDENED | Council surfaced genuine doubt — healthy debate |
      | Mean confidence ROSE, dispersion NARROWED | Genuine convergence — agents convinced each other |
      | Mean confidence STABLE, dispersion NARROWED | False consensus — agents agreed before debating (possible shared blind spots) |
      | Mean confidence ROSE, dispersion WIDENED | Polarization — agents became more entrenched in their positions |
      
    • debate-protocol.md 2.7 KB
      # Debate Protocol
      
      ## Phase Structure
      
      ### Phase 1: Compose
      
      A single LLM call generates `N` expert personas. The prompt prioritizes **diversity of initial position** over diversity of expertise. Each persona includes:
      - Name
      - Career background (one paragraph)
      - Specific expertise
      - Analytical approach
      - Bias or experience they bring to the specific question
      
      At least one agent is structurally skeptical (light red-team). At least one approaches from a fundamentally different cognitive frame.
      
      ### Phase 2: Premortem
      
      Each agent independently writes how the decision **already failed** — before any positions are formed. This bypasses positional commitment bias. Agents do NOT see each other's premortems.
      
      The premortem output includes:
      - Failure scenario (narrative)
      - Root causes
      - Early warning signals
      
      ### Phase 3: Position
      
      Each agent forms an independent position. They see their own premortem (for continuity) but NOT other agents' positions or premortems.
      
      Position output includes:
      - Stance
      - Reasoning chain
      - Confidence score (0-1)
      - Key assumptions
      
      ### Phase 4: Cross-Examination (Iterative)
      
      Each agent reads all other agents' positions and responds. They see:
      - Every other agent's stance, reasoning, confidence, and assumptions
      - Their own previous round's reflection, concessions, and remaining disagreements
      
      Cross-examination output includes:
      - Concessions (where the other agent's reasoning was stronger)
      - Remaining disagreements (what's still in dispute)
      - Updated position (if changed)
      - Updated confidence (if changed)
      - Reflection on what they learned
      - New evidence needed to close gaps
      
      After each round, convergence detection runs:
      - If converged → proceed to synthesis
      - If diminishing returns → proceed to synthesis
      - If genuine disagreement → proceed to synthesis (with divergence report)
      - If more debate needed → run another round (up to max_rounds)
      
      ### Phase 5: Synthesis
      
      The synthesis combines algorithmic metrics (confidence dispersion, argument novelty) with an LLM-generated narrative. The output preserves the distinction between:
      - **Pre-positional risks** (from premortem — uncontaminated by positional commitment)
      - **Post-positional concerns** (survived cross-examination — tested against alternatives)
      
      ## Design Principles
      
      1. **Independent thought first** — agents form positions before seeing others'
      2. **Diversity over expertise** — different approaches beat more expertise with shared framing
      3. **Pre-mortem before position** — surface failure modes before committing to a stance
      4. **Convergence is measured, not assumed** — algorithmic stopping conditions prevent premature or interminable debate
      5. **Tension is the output** — the synthesis surfaces genuine disagreement, not forced consensus
      
  • scripts
    • bootstrap.py 2.1 KB
      #!/usr/bin/env python3
      """Bootstrap script — ensures agent-council CLI is available.
      
      The SKILL.md instructs agents to run this script if `agent-council`
      is not found on PATH. It installs the package from the skill directory
      using the current Python's pip, with pipx as a fallback.
      """
      
      import shutil
      import subprocess
      import sys
      import os
      
      
      def ensure_installed(skill_dir: str | None = None) -> str | None:
          """Ensure agent-council CLI is available. Returns path or None."""
          cli_path = shutil.which("agent-council")
          if cli_path:
              return cli_path
      
          if skill_dir is None:
              skill_dir = os.path.dirname(os.path.abspath(__file__))
      
          print("agent-council not found. Installing from skill directory...", file=sys.stderr)
      
          # Try sys.executable -m pip install (works with any Python + venv)
          try:
              subprocess.run(
                  [sys.executable, "-m", "pip", "install", "-e", skill_dir],
                  check=True,
                  capture_output=True,
                  timeout=60,
              )
              cli_path = shutil.which("agent-council")
              if cli_path:
                  print(f"Installed. CLI available at: {cli_path}", file=sys.stderr)
                  return cli_path
          except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
              pass
      
          # Fallback: pipx
          pipx = shutil.which("pipx")
          if pipx:
              print("pip install failed, trying pipx...", file=sys.stderr)
              try:
                  subprocess.run([pipx, "install", skill_dir], check=True, timeout=120)
                  cli_path = shutil.which("agent-council")
                  if cli_path:
                      print(f"Installed via pipx at: {cli_path}", file=sys.stderr)
                      return cli_path
              except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
                  pass
      
          print(
              f"Could not install agent-council automatically.\n"
              f"Run one of:\n"
              f"  {sys.executable} -m pip install -e {skill_dir}\n"
              f"  pipx install {skill_dir}\n"
              f"  pip install agent-council",
              file=sys.stderr,
          )
          return None
      
      
      if __name__ == "__main__":
          result = ensure_installed()
          sys.exit(0 if result else 1)
      
  • templates
    • personas.json 1.9 KB
      {
        "_comment": "Example custom persona file for agent-council. Each entry follows the AgentPersona schema.",
        "personas": [
          {
            "name": "Dr. Elena Vasquez",
            "background": "15 years as a distributed systems engineer at AWS and Google. Led the migration of Google Ads from a monolithic datastore to Spanner. Has seen three major migration projects fail and two succeed.",
            "expertise": "Distributed systems, database internals, cloud infrastructure",
            "approach": "Data-driven — asks for benchmarks, latency profiles, and failure mode analysis before forming opinions",
            "bias": "Strongly favors proven, battle-tested solutions over novel architectures. Skeptical of anything that sounds like premature optimization."
          },
          {
            "name": "Marcus Chen",
            "background": "YC-founder turned CTO. Bootstrapped a SaaS company to $10M ARR on a single Postgres instance. Recently migrated from Postgres to SQLite for their edge deployment and regrets the tooling gap.",
            "expertise": "Startup infrastructure, cost-optimization, pragmatic engineering",
            "approach": "Start with the simplest thing that could work, add complexity only when proven necessary",
            "bias": "Over-indexes on developer experience and operations simplicity. Under-weights long-term scaling needs."
          },
          {
            "name": "Priya Sharma",
            "background": "Database reliability engineer at a fintech unicorn. Manages 200+ Postgres clusters across three regions. Authored internal runbooks on migration rollback strategies.",
            "expertise": "Database operations, replication, disaster recovery, performance tuning",
            "approach": "Operational-readiness-first — evaluates every proposal by what happens at 3 AM when it breaks",
            "bias": "Assumes every system will fail in the most inopportune way. Skeptical of optimistic deployment timelines."
          }
        ]
      }
      
  • tests
    • test_select.py 1 KB
      import importlib.util
      import sys
      import tempfile
      import types
      import unittest
      from dataclasses import dataclass
      from pathlib import Path
      from unittest.mock import patch
      
      
      yaml = types.ModuleType("yaml")
      yaml.safe_load = lambda _: {}
      sys.modules["yaml"] = yaml
      
      state = types.ModuleType("agent_council.state")
      
      
      @dataclass
      class ProfileInfo:
          name: str
          description: str
          soul_content: str
      
      
      state.ProfileInfo = ProfileInfo
      sys.modules["agent_council.state"] = state
      
      select_spec = importlib.util.spec_from_file_location(
          "select", Path(__file__).parents[1] / "agent_council" / "phases" / "select.py"
      )
      select = importlib.util.module_from_spec(select_spec)
      select_spec.loader.exec_module(select)
      
      
      class ProfileSelectionTests(unittest.TestCase):
          def test_missing_profiles_are_empty_without_running_git(self):
              with tempfile.TemporaryDirectory() as directory:
                  with patch.object(select, "PROFILES_DIR", Path(directory) / "profiles"):
                      self.assertEqual(select.load_all(), [])
      
      
      if __name__ == "__main__":
          unittest.main()
      
  • LICENSE 1 KB · in bundle
  • pyproject.toml 560 B
    [project]
    name = "agent-council"
    version = "0.1.0"
    description = "Multi-agent structured debate system — spawn a panel of expert agents to debate any question with convergence-aware iteration"
    readme = "README.md"
    license = {text = "MIT"}
    requires-python = ">=3.10"
    dependencies = [
        "pydantic-ai>=1.0.0",
        "pyyaml>=6.0",
    ]
    
    [project.scripts]
    agent-council = "agent_council.cli:main"
    
    [tool.setuptools.packages.find]
    include = ["agent_council", "agent_council.*"]
    
    [build-system]
    requires = ["setuptools>=68.0"]
    build-backend = "setuptools.build_meta"
    
  • README.md 3.7 KB
    # Agent Council
    
    Multi-agent structured debate system — spawn a panel of expert agents to debate any question with convergence-aware iteration.
    
    ```bash
    pip install agent-council
    agent-council "Should we migrate from SQLite to Postgres?"
    ```
    
    ## Quick Start
    
    ```bash
    export AGENT_COUNCIL_API_KEY="sk-..."
    export AGENT_COUNCIL_MODEL="openai:gpt-5.6-luna"
    
    agent-council "Should we use WebSockets or SSE for real-time notifications?"
    ```
    
    ## Features
    
    - **Structured debate protocol** — compose, premortem, position, cross-examine (iterative), synthesis
    - **Convergence-aware iteration** — the protocol measures confidence dispersion and stops when diminishing returns set in, not at a hardcoded round count
    - **Typed outputs** — every phase produces validated Pydantic models, consumable as JSON or human-readable markdown
    - **Custom personas** — supply your own agent definitions, or let the compose phase generate them from the question
    - **Convergence diagnostics** — confidence dispersion, position overlap, argument novelty — surfaced in every synthesis report
    - **Cross-platform** — works with any AI harness that supports agentskills.io skills (Claude Code, Cursor, Hermes Agent, OpenHands, etc.)
    
    ## Installation
    
    ```bash
    pip install pydantic-ai
    pip install agent-council
    ```
    
    Or install from the skill directory:
    
    ```bash
    pip install -e /path/to/agent-council/
    ```
    
    Pip and wheel installs include generated and user-supplied personas, but not the `hermes-profiles` library. To use real bundled profiles, start from a recursive source checkout.
    
    ## Usage
    
    ```bash
    # Quick debate (3 agents, 1 cross-examine round)
    agent-council --mode quick "Should we use Postgres or SQLite?"
    
    # Standard debate (5 agents, iterative cross-examination)
    agent-council "What architecture should we choose for this service?"
    
    # Deep debate (7 agents, full protocol with assumption mapping)
    agent-council --mode deep --agents 7 "Should we migrate to microservices?"
    
    # With custom personas
    agent-council --persona-file personas.json "Evaluate our cloud strategy"
    
    # JSON output for programmatic consumption
    agent-council --json "Which cloud provider should we choose?"
    ```
    
    ## Output
    
    The synthesis report includes:
    - **Confidence dispersion table** — agent-by-agent confidence before and after debate
    - **Shared risks** — failure modes identified in the pre-mortem (before positional commitment)
    - **Shared concerns** — what survived cross-examination as genuine shared risk
    - **Genuine disagreements** — positions that remained unresolved after debate
    - **Assumptions per position** — what would need to be true for each position to be correct
    - **Principal's path** — narrative synthesis of the decision landscape
    
    ## Configuration
    
    | Env var | Required | Default | Description |
    |---------|----------|---------|-------------|
    | `AGENT_COUNCIL_API_KEY` | Yes | — | API key for your LLM provider |
    | `AGENT_COUNCIL_MODEL` | No | `openai:gpt-5.6-luna` | Model string in `provider:model` format (e.g. `deepseek:deepseek-v4-flash`) |
    | `AGENT_COUNCIL_BASE_URL` | No | Provider default | Custom API endpoint (e.g. `https://api.deepseek.com/v1`) |
    
    ## License
    
    MIT
    
    
    ## Why Install This Skill
    
    This skill packages practical, reusable guidance for this domain so you can move from a real task to a dependable result without rebuilding the workflow each time.
    
    
    ## What You Get
    
    A focused workflow in SKILL.md, with the referenced scripts, templates, and supporting material available when the task needs them.
    
    
    ## Triggers
    
    Use this skill for the task types and keywords described in its SKILL.md description.
    
    
    ## Requirements
    
    Check the compatibility requirements in SKILL.md before using commands or integrations from this skill.
    
  • SKILL.md 18.4 KB
    ---
    name: agent-council
    description: >-
      Run a structured multi-agent debate by spawning a panel of expert
      agents on any question, with convergence-aware iteration and typed
      synthesis output via the `agent-council` CLI. Use when a decision has
      genuine tradeoffs, high stakes, or hidden assumptions worth adversarial
      collaboration, or when confidence diagnostics matter more than a
      single recommendation. Compatible with any AI agent harness that
      supports agentskills.io skills (Claude Code, Cursor, Hermes Agent,
      OpenHands, etc.). Do not use for simple factual lookups, tasks with a
      clear correct answer, routine single-perspective work, or code
      execution and tool orchestration beyond debate.
    license: MIT
    compatibility: Requires Python 3.10+ and pydantic-ai. CLI tool installs via pip.
    metadata:
      source: https://github.com/magnus919/agent-skills/tree/main/agent-council
      spec-version: "1.1"
    ---
    
    # Agent Council
    
    Spawn a panel of expert agents to debate any question. The council runs a structured protocol — compose, premortem, position, cross-examination (iterative), synthesis — and produces a decision landscape with convergence diagnostics.
    
    ## When to Use
    
    Invoke the council when any of these apply:
    
    - The question has genuine tradeoffs with no clear correct answer
    - You want multi-perspective analysis to surface hidden assumptions
    - A decision would benefit from adversarial collaboration
    - You want confidence diagnostics (not just a recommendation)
    - The question has high stakes or irreversible consequences
    
    **Signal phrases:** "Let's get multiple perspectives on this" / "Debate this: X" / "What would experts say about X" / "What are we missing?"
    
    ## Quick Start
    
    ### 1. Install
    
    ```bash
    # One-time setup
    pip install pydantic-ai
    pip install agent-council
    
    # Or install from this skill directory:
    python3 scripts/bootstrap.py
    ```
    
    ### 2. Configure
    
    ```bash
    export AGENT_COUNCIL_API_KEY="sk-..."
    export AGENT_COUNCIL_MODEL="openai:gpt-5.6-luna"
    ```
    
    ### 3. Run
    
    ```bash
    agent-council "Should we use Postgres or SQLite for this service?"
    ```
    
    ## Command Reference
    
    ```
    agent-council [OPTIONS] <question>
    
    Options:
      --agents, -n {3,4,5,6,7}  Number of agents (default: 5)
      --mode, -m {quick,medium,deep}  Debate depth (default: medium)
      --profiles TEXT          Comma-separated profile names from the hermes-profiles
                               library (e.g. "debugger,researcher,product-manager")
      --persona-file PATH      JSON file with custom agent personas
      --json                   Output structured JSON instead of markdown
      --verbose, -v            Show phase-by-phase progress
      --max-rounds INTEGER     Max cross-examination rounds (default: 4)
      --convergence FLOAT      Convergence threshold (default: 0.10)
    ```
    
    ### Mode Selection
    
    | Mode | Agents | Rounds | When to use |
    |------|--------|--------|-------------|
    | `quick` | 3 | 1 cross-examine round | Low-stakes check, fast answer needed |
    | `medium` (default) | 5 | Eval-driven, up to 4 rounds | Standard decisions |
    | `deep` | 7 | Eval-driven, up to 4 rounds | High-stakes, hidden assumptions |
    
    ## Profile Selection
    
    In a recursive source checkout, the council auto-selects relevant real professional profiles from the included [hermes-profiles](https://github.com/magnus919/hermes-profiles) library. Each profile has a SOUL.md — an identity document with real methodology, values, and operating principles — rather than a fabricated persona. Pip and wheel installs do not bundle that library; use generated or user-supplied personas instead.
    
    ### Auto-selection
    
    When no `--profiles` flag is given, the council scores each profile's description against your question using keyword overlap. The top N most relevant profiles are selected. This works best for focused, single-domain questions.
    
    ### Explicit selection
    
    ```bash
    agent-council --profiles debugger,data-scientist,product-manager "What architecture should we choose?"
    ```
    
    Comma-separated profile names. Available profiles include: `ceo`, `cfo`, `cmo`, `coo`, `cpo`, `cto`, `curator`, `data-architect`, `data-engineer`, `data-scientist`, `debugger`, `editor`, `frontend-engineer`, `ml-engineer`, `orchestrator`, `product-manager`, `researcher`, `reviewer`, `security-engineer`, `site-reliability-engineer`, `technical-architect`, `technical-writer`, `ux-designer`, `verifier`, `wonderer`, `writer`, and more.
    
    ### Choosing a Profile Source
    
    Three ways to populate the council, with different tradeoffs:
    
    | Method | Best for | Diversity | Setup |
    |--------|----------|-----------|-------|
    | `--profiles` (auto-select) | Single-domain questions with clear keywords | High — profiles have real SOUL.md methodology | Recursive source checkout required |
    | `--profiles name1,name2` | Targeted debates where you know the stakeholders | Highest — you pick specific methodological voices | Recursive source checkout and profile names |
    | `--persona-file file.json` | Full control over agent identities, custom domains | Variable — depends on how you design them | Create a JSON file |
    | Auto (no flag) | Default — uses profiles if available, falls back to generated | Good — varies with available profiles | No setup for generated personas; recursive source checkout for real profiles |
    
    For most cases, let it auto-select or use `--profiles` with 3-5 names. Only use `--persona-file` when you need specific invented expertise that doesn't map to any existing profile.
    
    ### Custom personas (fallback)
    
    If the profile library is unavailable or you want full control, use `--persona-file` to supply your own persona definitions. If neither `--profiles` nor `--persona-file` is provided, the council auto-selects profiles from the library; if the library is missing, it falls back to LLM-generated personas.
    
    ## How It Works
    
    ### Pipeline
    
    ```
    Compose ──► Premortem ──► Position ──► Cross-examine ──► [eval] ──► Synthesis
      (1)        (parallel)    (parallel)    (iterative loop)    ↑        (1)
    
                        ┌── converged ──────┐
                        ├── diminishing_ret │
        eval ───────────┼── genuine_disagr──┼──► Synthesis
                        └── continue ───────┘
                              ↓
                       Cross-examine (next round)
    ```
    
    ### Phases
    
    | Phase | What happens | Method |
    |-------|-------------|--------|
    | **Compose** | A single LLM call generates N expert personas tuned to the question | 1 call |
    | **Premortem** | Each agent independently imagines how the decision already failed — bypasses positional commitment bias | N parallel calls |
    | **Position** | Each agent forms an independent position, referencing their own premortem | N parallel calls |
    | **Cross-examine** | Each agent reads all other positions and responds — concedes, disagrees, updates confidence | N parallel calls per round |
    | **Eval** | Convergence detection: measures dispersion, argument novelty, concession rate. Decides whether to loop or stop | Algorithmic |
    | **Synthesis** | Collates all phases into a structured decision landscape with LLM-generated narrative | 1 call |
    
    ### Convergence Detection
    
    The council doesn't use a fixed number of rounds. After each cross-examination round, it measures:
    
    - **Confidence dispersion** — standard deviation of agent confidence scores. Below threshold = converged.
    - **Argument novelty** — new arguments not seen in prior rounds. Near zero = diminishing returns.
    - **Concession rate** — points where agents shifted position. Zero + no new arguments = stalled.
    
    Stopping conditions:
    | Condition | Meaning |
    |-----------|---------|
    | `converged` | Dispersion below threshold, confidence stable. Genuine agreement. |
    | `diminishing_returns` | No new arguments or concessions. Nothing more to surface. |
    | `genuine_disagreement` | Dispersion widened, positions hardened. Summary of irreducible tension. |
    | `max_rounds` | Hard cap reached. Inconclusive — principal must decide. |
    
    ## Bootstrapping
    
    If `agent-council` is not available on PATH, the invoking agent should run:
    
    ```bash
    python3 scripts/bootstrap.py
    ```
    
    This installs the package from the skill directory using the current Python's pip, falling back to pipx. No PyPI dependency for the bootstrap path — the package ships inside the skill directory.
    
    **If bootstrap fails:** Run one of these manually:
    ```bash
    pip install pydantic-ai
    pip install agent-council
    
    # Or from this directory:
    python3 -m pip install -e /path/to/agent-council/
    ```
    
    ## Available Scripts
    
    This skill bundles one script; there are no others to discover.
    
    | Script | Purpose | Invocation |
    |---|---|---|
    | `scripts/bootstrap.py` | First-run installer: checks whether `agent-council` is already on PATH and, if not, installs the package from the skill directory using the current Python's pip, falling back to pipx. Run it whenever `agent-council` is not found on PATH (an invoking agent should run it automatically in that case); it exits 0 when the CLI is available and 1 with manual-install instructions when it could not install. If bootstrap fails, follow the manual steps above. | `python3 scripts/bootstrap.py` |
    
    ## Configuration
    
    | Env var | Required | Default | Description |
    |---------|----------|---------|-------------|
    | `AGENT_COUNCIL_API_KEY` | Yes | — | API key for your LLM provider |
    | `AGENT_COUNCIL_MODEL` | No | `openai:gpt-5.6-luna` | Model string (`provider/model`) |
    | `AGENT_COUNCIL_BASE_URL` | No | Provider default | Custom API endpoint (OpenRouter, LiteLLM, etc.) |
    
    You can set these as environment variables or create a `.env` file in the directory you run `agent-council` from:
    
    ```bash
    # .env file
    AGENT_COUNCIL_API_KEY=sk-...
    AGENT_COUNCIL_MODEL=openai:gpt-5.6-luna
    ```
    
    Environment variables take precedence over `.env` file values.
    
    Model strings follow PydanticAI convention: `openai:gpt-5.6-luna`, `anthropic:claude-sonnet-4-20250514`, `deepseek:deepseek-v4-flash`, `google:gemini-2.0-flash`.
    
    ## Output
    
    The synthesis report is a structured decision landscape. In markdown mode it includes:
    
    1. **Confidence dispersion table** — per-round confidence metrics with diagnostic
    2. **Shared risks** — failure modes from the pre-mortem (pre-positional, uncontaminated)
    3. **Shared concerns** — what survived cross-examination as genuine shared risk
    4. **Remaining disagreements** — positions that did not resolve
    5. **Assumptions per position** — what must hold for each position to be valid
    6. **Principal's path** — narrative synthesis of the decision landscape
    
    Use `--json` for programmatic consumption. The JSON output follows this structure:
    
    ```json
    {
      "question": "string",
      "mode": "quick|medium|deep",
      "num_agents": 3,
      "rounds_completed": 2,
      "stopped_reason": "converged|max_rounds|diminishing_returns|genuine_disagreement",
      "confidence_history": [
        {"round": 1, "mean_confidence": 0.74, "dispersion": 0.061, "new_arguments": 20, "concessions_made": 17}
      ],
      "shared_risks": [{"description": "...", "severity": "low|medium|high", "phase_discovered": "premortem"}],
      "shared_concerns": ["..."],
      "disagreements": [{"topic": "...", "positions": {"agent_a": "position_a", "agent_b": "position_b"}}],
      "assumptions_per_position": {"agent_name": ["assumption1", "assumption2"]},
      "principal_path": "narrative text"
    }
    ```
    
    ### Claims Verification
    
    Every synthesis output includes a post-debate verification scan. A separate LLM call reads the narrative synthesis and identifies any claims about verifiable external facts (domain availability, package namespace status, pricing, statistics) that the debate could not have verified from its own reasoning. Flagged claims are appended as a **⚠️ Claims Not Verified** section:
    
    ```
    ⚠️  Claims Not Verified
    The following assertions in this synthesis could not be verified
    by the council's own reasoning and should be checked before acting:
      • "Dialekt passes all five checks..." — domain availability:
        No evidence the council checked domain registries
    ```
    
    This is not a rejection of the synthesis — it is a quality signal. Claims in this section should be treated as hypotheses to verify, not as facts.
    
    ### Reading the Convergence Diagnostic
    
    The confidence dispersion table tells you whether the debate was productive:
    
    | Pattern | Meaning | What to do |
    |---------|---------|------------|
    | Mean confidence DROPPED, dispersion WIDENED | Council surfaced genuine doubt — healthy debate | Trust the shared concerns; investigate the newly surfaced risks |
    | Mean confidence ROSE, dispersion NARROWED | Genuine convergence — agents convinced each other | The strongest signal; highest-confidence path forward |
    | Mean confidence STABLE, dispersion NARROWED | Possible false consensus — agents agreed before debating | Probe the assumptions section for shared blind spots |
    | Mean confidence ROSE, dispersion WIDENED | Polarization — agents became more entrenched | The question may be genuinely irresolvable by argument alone; look for an experimental path |
    | `stopped_reason: converged` | Dispersion fell below threshold | Good — run with the recommendation |
    | `stopped_reason: max_rounds` | Hit hard cap before converging | The debate was cut off; consider a second run with `--max-rounds` higher or `--mode quick` for faster convergence |
    | `stopped_reason: diminishing_returns` | No new arguments surfaced | The council exhausted what it could discover — make a call |
    | `stopped_reason: genuine_disagreement` | Positions hardened, dispersion widened | The council could not resolve the tension. The output is valuable precisely because it maps irreconcilable disagreement — read the disagreements section carefully |
    
    ## Pitfalls
    
    | Symptom | Cause | Fix |
    |---------|-------|-----|
    | Debate fails with "Exceeded maximum output retries" | Model couldn't produce valid structured output for a phase | Retry the debate. If persistent, try a different model or add `--verbose` to see which agent failed. |
    | Debate runs for 5+ minutes with no output | DeepSeek or slow model with many agents | Use `--mode quick --agents 3` for fast turnarounds, or use `--verbose` to see progress in real time. |
    | All agents agree immediately with high confidence | False consensus — same model shares blind spots | Check the dispersion diagnostic. Try `--profiles` with diverse identities to force methodological diversity. |
    | "Profile X not found" warning | Typo in profile name | Run `agent-council --profiles list` (or check the profiles list above) for valid names. |
    | Synthesis contains obvious factual errors | Agents fabricated claims during debate | Check the ⚠️ Claims Not Verified section. The guardrail reduces fabrication but cannot eliminate it. Verify any statistics, pricing, or availability claims before acting. |
    
    ## Architecture Decision
    
    **Single-model debate:** All agents share one LLM configuration. Diversity comes from persona definitions (system prompts with distinct backgrounds, analytical approaches, biases), not from different model instances. This minimizes setup friction — one API key, one endpoint, predictable cost.
    
    **Limitation:** All agents share the model's knowledge cutoff and blind spots. The convergence diagnostics include a "possible false consensus" flag when confidence starts high and never shifts.
    
    ## Reference Files
    
    | File | Load when |
    |------|-----------|
    | `references/convergence.md` | Understanding the convergence detection algorithm |
    | `references/debate-protocol.md` | Deep dive into phase structure and round design |
    | `references/configuration.md` | Provider setup, troubleshooting, model strings |
    
    ## Directory Structure
    
    ```
    agent-council/
    ├── SKILL.md                      # This file — skill entry point
    ├── pyproject.toml                # Pip package definition
    ├── README.md
    ├── LICENSE                       # MIT
    ├── agent_council/                # Python package
    │   ├── cli.py                    # CLI entry point
    │   ├── config.py                 # Env var loading
    │   ├── state.py                  # Typed state + Pydantic models
    │   ├── convergence.py            # Convergence detection
    │   ├── graph.py                  # Debate graph orchestration
    │   └── phases/
    │       ├── compose.py            # Persona generation
    │       ├── premortem.py          # Failure pre-mortem
    │       ├── position.py           # Initial positions
    │       ├── cross_examine.py      # Iterative cross-examination
    │       └── synthesis.py          # Decision landscape
    ├── scripts/
    │   └── bootstrap.py              # First-run installation
    ├── templates/
    │   └── personas.json             # Example custom personas
    └── references/
        ├── convergence.md
        ├── debate-protocol.md
        └── configuration.md
    ```
    
    ## Prerequisites
    
    - Python 3.10+ with the `pydantic-ai` package; install via `pip`, `pipx`, or `python3 scripts/bootstrap.py` (the package ships inside this skill directory, so bootstrap needs no PyPI access).
    - An LLM provider API key exported as `AGENT_COUNCIL_API_KEY` (or set in a `.env` file); optionally `AGENT_COUNCIL_MODEL` and `AGENT_COUNCIL_BASE_URL`.
    - The real professional profiles from the hermes-profiles library are available only in a recursive source checkout; pip and wheel installs use generated or user-supplied personas.
    
    ## Limitations
    
    - Single-model debate: all agents share one LLM configuration and therefore its knowledge cutoff and blind spots; diversity comes from persona definitions, not model instances (see Architecture Decision).
    - Debates consume many parallel LLM calls per round — expect minutes on slow models or deep mode, and check ⚠️ Claims Not Verified in every synthesis before acting on verifiable external facts.
    - Convergence diagnostics reduce fabrication and false consensus but cannot eliminate them; `max_rounds` stops mean an inconclusive debate that the principal must resolve.
    - This is not a general agent-orchestration framework: it runs debates only — for state-machine orchestration beyond the debate protocol, route to langgraph.
    
    ## Related Skills
    
    - **ai-frameworks** — umbrella bundle for all AI framework skills. Load this when comparing agent-council against other multi-agent approaches (LangGraph, AutoGen, CrewAI).
    - **langgraph** — for complex state-machine multi-agent orchestration beyond the debate protocol
    - **pydanticai** — the underlying framework for type-safe agent definitions
    - **spec-driven-development** — for building specs that agent-council can help you evaluate
    - **hermes-profiles** — the 39-profile library that powers the profile selection system
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related