Coding Style¶
This document defines the coding conventions for the Agents Discussion project.
Table of Contents¶
- Language & Tooling
- License Headers
- Project Structure
- Imports
- Type Hints
- Naming Conventions
- Docstrings
- Error Handling
- Logging
- Constants & Magic Values
- Async & Threading
- LangGraph Patterns
- Frontend (JavaScript)
- Pre-commit Rules
- Versioning & Releases
Language & Tooling¶
- Python: 3.11+
- Formatter: Ruff (replaces Black + isort)
- Linter: Ruff. The project does not pin an explicit rule set, so Ruff's default lint rules apply. Keep new code warning-free under
ruff check .. - Type checker: relies on IDE / editor type checking (mypy optional); Pydantic provides runtime validation.
- Frontend: Vanilla JavaScript, no build step.
markedfor Markdown,DOMPurifyfor sanitization.
Ruff Configuration (actual — see pyproject.toml)¶
[tool.ruff]
target-version = "py311"
line-length = 120
Only
target-versionandline-lengthare configured — there is no explicitselect/ignore, so Ruff applies its default lint set. If you want stricter linting (e.g.select = ["E", "W", "F", "I", "N", "UP", "B"]), declare it inpyproject.tomlfirst; do not assume rules that are not present there.
License Headers¶
Every first-party source file (*.py, and the project's own *.js under static/js/)
starts with a two-line SPDX header. Vendored libraries under static/vendor/ keep
their upstream headers and are left untouched.
# Copyright (C) 2025 Luis González Fernández
# SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 Luis González Fernández
// SPDX-License-Identifier: GPL-3.0-or-later
New files must include this header. The project is licensed under
GPL-3.0-or-later; see LICENSE for the full text.
Project Structure¶
src/agents_discussion/
├── __init__.py
├── graph.py # LangGraph definition — ONLY graph topology and nodes
├── state.py # TypedDict + Pydantic models — shared state schema
├── prompts.py # Prompt building functions — pure, no side effects
├── prompt_store.py # Template loading from YAML files
├── models.py # LLM factory — ChatOpenAI configuration
├── tools.py # @tool decorated functions — diagnostic tools
├── config.py # Pydantic-settings + env var mapping
├── runtime.py # RunControl — synchronization primitives
├── web.py # FastAPI routes + SSE
├── cli.py # argparse + Rich console UI
├── report.py # Markdown report generation from stored runs
├── audit.py # audit.jsonl append
├── auth_copilot.py # GitHub Copilot OAuth device flow
├── context_files.py # File reading with secret redaction
├── project_context.py # Directory globbing for --project
└── static/ # HTML, CSS, JS — no build pipeline
├── index.html
├── css/
└── js/
└── app.js
Rule: Domain logic belongs in the graph module. Web concerns belong in web.py. Never import FastAPI or HTTP concepts into graph.py.
Imports¶
Order¶
- Standard library (
import json,from typing import ...) - Third-party (
from langgraph.graph import ...,from pydantic import ...) - First-party (
from agents_discussion.state import ...)
Style¶
# ✅ Preferred — explicit imports
from typing import Annotated, Literal
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel, Field
from agents_discussion.state import DebateState
# ✅ Acceptable for many imports
from agents_discussion.prompts import (
diagnostic_prompt,
moderator_prompt,
rebuttal_prompt,
skeptic_prompt,
)
# ❌ Avoid wildcard or deeply nested relative imports
from .state import * # NO
from ..utils.helpers import thing # NO — use absolute
Forward References¶
Use from __future__ import annotations when needed for forward reference resolution. This is present in files that define mutually recursive types.
Type Hints¶
Required¶
All function signatures must have type hints for parameters and return types.
# ✅
def _extract_hypotheses(text: str, proposer: str, round_number: int) -> list[Hypothesis]:
...
# ❌
def _extract_hypotheses(text, proposer, round_number):
...
TypedDict¶
Use TypedDict for LangGraph state to get structural typing + readonly semantics.
class DebateState(TypedDict):
topic: str
round: int
history: Annotated[list[DebateMessage], append_messages]
Pydantic Models¶
Use BaseModel for structured outputs from LLMs and for data validation at boundaries.
class ModeratorDecision(BaseModel):
status: Literal["continue", "final_diagnosis", "needs_more_data", "propose_fix", "structured_uncertainty"]
confidence: float = Field(ge=0.0, le=1.0)
None Safety¶
# ✅ Use | None (Python 3.10+ syntax)
value: str | None = None
# ❌ Avoid Optional[str] in new code
Naming Conventions¶
| Entity | Convention | Example |
|---|---|---|
| Modules | snake_case.py |
graph.py, prompt_store.py |
| Classes | PascalCase |
DebateState, ModeratorDecision |
| Functions | snake_case |
build_graph(), run_debate() |
| Constants | UPPER_SNAKE_CASE |
MAX_OUT, _CONTINUE_STATUSES |
| Private helpers | _leading_underscore |
_message_content(), _template_for() |
| LangGraph nodes | snake_case_agent |
diagnostic_agent, moderator_agent |
| TypedDict keys | snake_case |
diagnostic_response, tool_calls_log |
Special Prefixes¶
_(single underscore): Module-private helper.__(double underscore): Rarely used; prefer module-level privacy over name mangling.
Docstrings¶
Style¶
Use Google-style docstrings for consistency.
def _run_with_tools(
model_factory: Callable[[], BaseChatModel],
agent_node: str,
system_prompt: str,
user_message: str,
run_id: str = "",
) -> tuple[str, list[ToolCallEntry]]:
"""Run a model in a ReAct loop: LLM → tool call(s) → LLM → … → final text.
Returns the agent's final text response and a list of ToolCallEntry records.
Tools are only used when TOOLS_ENABLED=true in settings.
Args:
model_factory: Callable that returns a fresh model instance.
agent_node: Node name for logging and event emission.
system_prompt: System message content.
user_message: User message content (the prompt).
run_id: Optional run ID for RunControl lookup.
Returns:
Tuple of (final_content, tool_invocation_log).
"""
Rules¶
- All public functions must have docstrings.
- Private helpers should have docstrings if the logic is non-obvious.
- Keep docstrings under 120 characters per line.
Error Handling¶
Philosophy¶
Fail fast at boundaries, recover gracefully in loops.
LLM Call Failures¶
# ✅ Retry only for transient errors; let structural errors propagate
try:
result = model.invoke(messages)
except Exception as exc: # noqa: BLE001
_log.warning("Model call failed (%s), attempting fallback", exc)
result = fallback_model.invoke(messages)
Tool Execution¶
# ✅ Always catch tool errors to prevent the ReAct loop from crashing
try:
return str(tool_fn.invoke(tool_args)), False
except Exception as exc: # noqa: BLE001
return f"Tool error: {exc}", True
Graceful Degradation¶
# ✅ If a feature fails, fall back to the safe path
try:
structured = model.with_structured_output(ModeratorDecision)
decision = structured.invoke(messages)
except Exception:
_log.warning("Structured output failed, falling back to plain text")
decision = _parse_moderator_response(model.invoke(messages).content)
Exception Naming¶
# ✅ Use standard exceptions; custom only when catchable
class RunCancelled(Exception):
"""Raised inside the debate when the operator cancels the run."""
Logging¶
Logger Pattern¶
import logging
_log = logging.getLogger(__name__)
Levels¶
| Level | When to use |
|---|---|
DEBUG |
Token counts, prompt sizes, internal routing decisions |
INFO |
Run start/finish, round transitions, tool approvals |
WARNING |
Model fallback, tool errors, skipped nodes, compression failures |
ERROR |
Unhandled exceptions, persistent model failures, graph crashes |
Format¶
# ✅ Structured messages with context
_log.warning("moderator structured output failed (%s), falling back", exc)
# ❌ Avoid f-strings in logging calls (lazy evaluation)
_log.warning(f"Failed with {exc}") # Wastes cycles if level is filtered
Constants & Magic Values¶
Inline Constants¶
# ✅ Module-level constants with explanation
_MAX_OUT = 4_000 # Maximum characters kept from any tool output
_CONTINUE_STATUSES = {"continue"}
_MODELS_TTL = 300.0 # 5 minutes cache for model catalog
Configuration Defaults¶
All user-visible defaults live in config.py via Pydantic settings, never hardcoded in business logic.
# ✅ In config.py
max_rounds: int = Field(4, alias="MAX_ROUNDS", ge=1, le=10)
# ❌ In graph.py
default_rounds = 4 # NO — read from settings
Async & Threading¶
Rule: Graph is sync, web is async¶
# ✅ web.py: async route delegates to thread
async def _drive_run(session: RunSession) -> None:
await asyncio.to_thread(_run_debate_sync, session)
# ✅ graph.py: blocking I/O (LLM calls) in worker thread
for chunk in model.stream(messages):
...
Thread Safety¶
# ✅ Lock-protected shared state
class RunSession:
def __init__(self):
self._lock = threading.Lock()
def publish(self, event: dict) -> None:
with self._lock:
self.events.append(event)
Never use asyncio inside graph nodes¶
LangGraph nodes must not contain await or asyncio calls. If an LLM provider offers only async APIs, wrap with asyncio.run() at the integration layer in models.py.
LangGraph Patterns¶
Node Return Values¶
Nodes return partial dictionaries that are merged into state.
# ✅ Only return fields you modify
def diagnostic_agent(state: DebateState) -> dict[str, object]:
return {
"diagnostic_response": content,
"history": [DebateMessage(role="diagnostic_agent", content=content)],
"tool_calls_log": tool_log,
"hypotheses": hypotheses,
}
Conditional Edges¶
# ✅ Explicit mapping of return values to target nodes
builder.add_conditional_edges(
"moderator_agent",
route_after_moderator,
{
"summarize_history": "summarize_history",
"finalize": "finalize",
},
)
State Reducers¶
Use Annotated with custom reducers for list fields that append rather than overwrite.
def _append_list(current: list | None, new: list | None) -> list:
return (current or []) + (new or [])
class DebateState(TypedDict):
history: Annotated[list[DebateMessage], append_messages]
tool_calls_log: Annotated[list[ToolCallEntry], _append_list]
Graph Configuration¶
Always set recursion_limit based on max_rounds to prevent LangGraph's default (25) from truncating long debates.
def _graph_config(state: DebateState) -> dict:
return {"recursion_limit": state["max_rounds"] * 8 + 10}
Frontend (JavaScript)¶
Style¶
- No build system; vanilla JavaScript in
static/js/app.js. - Use
constandlet; nevervar. - Functions are declarations, not arrow functions for top-level definitions.
Event Handling¶
// ✅ Guard unknown events gracefully
if (ev.type === 'agent_skipped') {
closeToolGroup();
push(buildSkippedCard(ev));
return;
}
// ✅ Handle missing fields defensively
const content = ev.content || '';
HTML Injection¶
Always sanitize user-facing content. Use esc() for plain text, md() for markdown, and DOMPurify.sanitize() for parsed HTML.
// ✅ Escape everything that could contain user input
card.innerHTML = '<span>' + esc(ev.tool_name) + '</span>';
// ✅ Markdown is parsed then sanitized
const html = md(agentContent);
body.innerHTML = DOMPurify.sanitize(html);
Pre-commit Rules¶
Run these locally before every commit. They are also enforced in CI
(.github/workflows/ci.yml) on every push to main and on
pull requests, so a green local run should mean a green CI run. There is no
.pre-commit-config.yaml; CI is the gate.
Run Before Commit¶
ruff check .— no lint errorsruff format --check .— no formatting violationspython -m py_compile src/agents_discussion/*.py— syntax valid.venv/bin/python -m pytest— pure-logic test suite stays green
Forbidden Patterns¶
| Pattern | Why |
|---|---|
print() statements |
Use _log instead |
Bare except: |
Always catch Exception, never swallow KeyboardInterrupt silently |
eval() or exec() |
Security risk; never process model output with eval |
subprocess.run(shell=True) unless escaped |
Use argv form where possible |
| Hardcoded secrets in source | Read from env or .env file via config.py |
time.sleep() in graph nodes |
Prevents cancellation responsiveness |
Versioning & Releases¶
The project follows Semantic Versioning (MAJOR.MINOR.PATCH),
driven by the Conventional Commits prefixes the
history already uses.
Single source of truth¶
__version__ in src/agents_discussion/__init__.py is
the only place the version is declared. pyproject.toml derives it via
[tool.hatch.version], so never edit the version in two places.
Commit → bump mapping¶
Look at the commits since the last tag and pick the highest bump they imply:
| Commit prefix | Version bump |
|---|---|
fix:, perf:, refactor: |
PATCH (0.1.0 → 0.1.1) |
feat: |
MINOR (0.1.0 → 0.2.0) |
feat!: / fix!: / a BREAKING CHANGE: footer |
MAJOR (0.1.0 → 1.0.0) |
docs:, style:, test:, chore:, ci: |
none on their own |
Cutting a release¶
- Bump
__version__in__init__.pyto the newX.Y.Z. - Commit it:
git commit -am "chore(release): vX.Y.Z". - Tag and push:
git tag vX.Y.Z && git push && git push --tags. - Pushing the
v*tag triggers.github/workflows/release.yml, which verifies the tag matches__version__, builds the sdist + wheel, and publishes a GitHub Release with auto-generated notes.
The release workflow fails fast if the tag does not match __version__, so the tag and
the package version can never diverge.
Testing Conventions¶
Unit Tests¶
# ✅ Test pure helpers without LLM calls
def test_last_round_messages():
msgs = [
DebateMessage(role="moderator", content="m1"),
DebateMessage(role="diagnostic_agent", content="d1"),
]
result = _last_round_messages(msgs, 2)
assert len(result) == 1
assert result[0].role == "diagnostic_agent"
Integration Tests¶
# ✅ Mock the model factory for graph tests
from unittest.mock import MagicMock
def test_graph_topology():
graph = build_graph()
# Verify edges exist without invoking LLMs
Documentation Conventions¶
Code Comments¶
# ✅ Section divider with descriptive label
# ── ReAct loop ───────────────────────────────────────────────────────────────
# ✅ Inline comment explaining *why*, not *what*
# The recovery message goes OUTSIDE the for loop so that every tool_call_id
# in the batch already has its ToolMessage.
# ❌ Comments that restate the code
# Initialize count to zero # NO — obvious from the code
Architecture Decision Records¶
Significant design decisions are recorded in ARCHITECTURE.md. If a change reverses a previous decision, update the document and add a Decision Log entry at the end.