An interactive AI code review agent. The repo is a two-project monorepo:
argus/
├── server/ FastAPI + UV — agent, LLM clients, persistence, REST + SSE API
└── web/ Vite + React SPA — the intended client
The server is the source of truth for the API; the web app is the only intended client.
FastAPI server that runs a tool-using LLM agent over an uploaded codebase to produce structured code reviews, plus CRUD for user-defined review prompts and a chat surface over the same agent loop.
- Python 3.11+, FastAPI, Uvicorn (dev) / Gunicorn + Uvicorn workers (prod)
- Pydantic v2 + pydantic-settings (config from
server/.env) - UV for dependency management — uses legacy
[tool.uv] dev-dependenciesform (uv 0.4.16 here predates PEP 735[dependency-groups]) - SQLModel + SQLAlchemy on SQLite (
server/argus.dbby default) - Anthropic + OpenAI SDKs behind a provider-neutral
LLMClientprotocol (OpenAI uses the Responses API; reasoning-effort plumbed through) loguru(with stdlib intercept) as the single logging sink- Static-analysis pre-pass:
ruff+bandit(Python) andeslint(opt-in if a config exists in the uploaded codebase). Symbol indexing viauniversal-ctags
server/
├── Makefile help, install, dev, prod, test, lint, fmt, typecheck, clean
├── prompts/ versioned review-prompt seeds (v1 → v6); v6 seeded on boot
└── app/
├── main.py uvicorn entry; runs `app.api.app:create_app`
├── settings.py pydantic-settings; reads .env (incl. LLM_TEMPERATURE, LLM_MAX_TOKENS)
├── constants.py built-in tool definitions & shared literals
├── dependencies.py *Dep aliases for routes (Settings, stores, services, worker, LLM)
├── exceptions.py domain exceptions (ProjectNotFoundError, IngestError, …)
├── api/app.py create_app(): CORS, lifespan (init_db + seed prompts), routers, error handlers
├── core/
│ ├── logging.py loguru config + InterceptHandler
│ ├── errors.py AppError + handler registration
│ ├── forms.py multipart-form helpers (paths JSON decode, etc.)
│ └── sse.py sse_pack / sse_stream helpers
├── db/
│ ├── engine.py make_engine, init_db (registers all model modules)
│ ├── base.py BaseRow (id, uid, created_at) shared by row tables
│ ├── timestamps.py as_utc helper (SQLite drops tz on persist)
│ ├── projects/{models,store}.py ProjectRow + DBProjectStore
│ ├── prompts/{models,store}.py PromptRow + DBPromptStore
│ ├── reviews/{models,store}.py ReviewRow + DBReviewStore
│ └── review_files/{models,store}.py ReviewFileRow + DBReviewFileStore (sparse per-file rollup)
├── models/ Pydantic domain models (project, prompt, review, review_file, chat, events, settings)
├── routes/
│ ├── health.py GET /health
│ ├── projects.py CRUD on /projects (multipart create + JSON git); /chat lives here as a sub-route
│ ├── prompts.py CRUD on /prompts
│ ├── reviews.py /reviews/{uid}, /reviews/{uid}/files, /reviews/{uid}/log, /reviews/{uid}/run
│ └── settings.py GET /settings/llm-defaults, GET /settings/llm-models
├── services/
│ ├── projects.py ProjectService (lifecycle, static analysis, ctags index)
│ ├── prompts.py PromptService (CRUD; enforces single default)
│ ├── reviews.py ReviewService (CRUD over persisted review rows; delegates running to ReviewWorker)
│ ├── chat.py ChatService (agent loop in chat mode, SSE stream out)
│ ├── seeds.py Idempotent built-in seed prompts (uuid5 keyed; safe on every boot)
│ ├── codebase/
│ │ ├── filters.py DEFAULT_IGNORES, build_ignore_spec, language detection
│ │ ├── walker.py walk + module_index (ignore-aware, max_files cap)
│ │ ├── ingest.py zip / single / multi-file / directory / git ingest (zip-slip + traversal safe)
│ │ ├── analysis.py ruff / bandit / eslint subprocess pre-pass (best-effort)
│ │ └── ctags.py universal-ctags symbol index (per-project)
│ ├── llm/ base protocol, anthropic, openai (Responses API), factory
│ └── agent/ sandbox, tools, loop
│ ├── sandbox.py Sandbox(root) — resolves & validates every path the LLM supplies
│ ├── tools.py TOOL_DEFINITIONS + Tools.execute: list_dir, read_file, grep,
│ │ list_symbols, find_definition, find_references, find_module,
│ │ get_file_summary, get_static_analysis, get_diff, get_git_log
│ └── loop.py tool-use loop, REPORT extraction, self-critique pass, forced final pass
├── workers/
│ └── review.py ReviewWorker (background runner; pending → running → completed/failed)
└── tests/ pytest (203 tests) + pytest-asyncio (auto mode)
server/argus.db SQLite (gitignored)
server/tmp/ project working directories (gitignored except .gitkeep)
server/app/services/agent/loop.py is a tool-use loop:
- Seed the LLM with the system prompt (current default is v6) + a user message carrying scope, file count, language mix, and a one-line static-analysis pre-pass summary (ruff / bandit / eslint counts).
- Stream events from
LLMClient.stream(text_delta,tool_use,stop). - On
tool_use, execute viaTools.execute(name, arguments)against the project'sSandbox(root)and append the result as the next user message. - Repeat until the model emits no tool calls (or
MAX_AGENT_ITERATIONS). - For review mode (
expect_report=True):- Parse the trailing
REPORT: { ... }JSON intoReviewReport. - If parsing or validation fails, log the exact Pydantic error.
- Self-critique pass (3–5 iterations): re-show the report to the model so it can revise / downgrade / consolidate before persisting.
- Mid-thought truncation recovery: when an iteration ends with
stop_reason="max_tokens"and no tool calls, nudge the model to emitREPORT:immediately rather than bailing. - Forced final pass: if the loop exhausts the iteration budget without a REPORT, run one no-tools turn to extract it.
- Parse the trailing
- Chat mode (
expect_report=False): same loop, no REPORT contract; ends when the model stops calling tools. Has its own forced-final-answer pass so the chat never truncates silently.
The agent emits provider-neutral events (StatusEvent, TokenEvent,
ToolCallEvent, ToolResultEvent, ReportEvent, DoneEvent, ErrorEvent).
Chat exposes them as SSE (data: {...}\n\n); reviews don't stream to the
client — the worker writes terminal state to the DB and the web polls.
POST /projects/{uid}/reviewscreates apendingrow (snapshot of prompt + LLM).POST /reviews/{uid}/runschedulesReviewWorker.run_to_completionas a FastAPI BackgroundTask. The row goespending → runningimmediately, thenrunning → completed | failedwhen the loop finishes.- On
completedthe worker also writes a sparse per-file rollup intoreview_files(one row per file that had at least one finding). - The web polls
GET /reviews/{uid}(Refresh button is only visible while status ispendingorrunning). - Cascade rules: deleting a review drops its
review_files; deleting a project drops review_files → reviews → working dir. POST /projects/{uid}/reviewsis blocked from deletion while any review on that project isrunning(409).pendingdoes not block.
- Each project, prompt, and review has both a server-internal
id(opaque hex, doubles as the on-disk dir name for projects) and a client-supplieduid(UUID, unique-indexed, the URL key). The web generates the uid viacrypto.randomUUID()and sends it on create; the server returns it on every read. - Routes are keyed by
uid:/projects/{uid},/prompts/{uid},/reviews/{uid},/reviews/{uid}/files, etc.
LLM_PROVIDERselectsanthropicoropenai;LLM_MODELis the default model id, overrideable per-request via the review's snapshotted provider/model.- Anthropic default is
claude-opus-4-7; catalog also exposesclaude-sonnet-4-6andclaude-haiku-4-5-20251001. Prompt caching is enabled on the system prompt + tool definitions so iter-N cost stays low. - OpenAI uses the Responses API; catalog exposes
gpt-5-codex,gpt-5.5,gpt-5.4. Reasoning-effort is plumbed through (minimalon the forced final pass so reasoning-capable models don't burn the output budget on internal thinking and emit nothing). services/llm/openai.pytranslates Anthropic-style messages (string content or list oftext/tool_use/tool_resultblocks) to Responses API format. This conversion is the trickiest piece — preserve it carefully when modifying.- Output is bounded by
LLM_MAX_TOKENS(default 16384);LLM_TEMPERATUREdefaults to0.0for reproducible reviews.MAX_AGENT_ITERATIONScaps loop budget (default 100).
- Routes are thin: parse → call service → translate domain errors to
HTTPException. No business logic in route handlers. - Domain errors live in
app/exceptions.py; routes catch them by type and map to status codes viafastapi.status.*constants. - Tools are FS operations confined to
Sandbox(project.root). Every path the LLM supplies is resolved through it; never bypass the sandbox. - New tools: define schema in
TOOL_DEFINITIONSand add a branch inTools.execute. Keep tool output as plain text (it's serialized straight back into the model's input). - Streaming responses (chat) use
StreamingResponse(..., media_type="text/event-stream"); each event isdata: <json>\n\n. Don't add an SSEevent:field unless a client needs it — thetypediscriminator is inside the JSON. services/codebase/analysis.pyruns subprocesses with timeouts; treat it as best-effort and never fail ingest because of it.services/codebase/ctags.pyis also best-effort; the symbol index is empty ifctagsisn't on the host.git cloneruns withGIT_TERMINAL_PROMPT=0and stub askpass helpers so private repos / bad URLs fail fast (clean stderr, no interactive auth).- Prompts enforce "at most one default" at the service layer:
PromptService.create/updatecallstore.unset_defaults()before persisting whenis_default=True. Deleting the default is blocked (CannotDeleteDefaultPrompt→ 409). - Built-in prompt v6 is seeded on app startup (
services/seeds.py), keyed by a deterministicuuid5so the insert is a no-op on subsequent boots.
services/codebase/filters.py:DEFAULT_IGNORES is the union ignore spec used by
both the walker and every agent tool (pathspec-compiled). It covers:
- vendored deps / virtualenvs (
node_modules/,.venv/,__pycache__/,vendor/) - build outputs (
dist/,build/,target/,out/,.next/,.nuxt/, …) - test/coverage artifacts (
coverage/,__snapshots__/,.pytest_cache/, …) - editor/VCS (
.git/,.idea/,.vscode/,.DS_Store) - lockfiles (named explicitly:
package-lock.json,pnpm-lock.yaml, …) - secrets (
.env,.env.*) - minified/sourcemap noise and binary assets (fonts, images, archives, audio/video)
The project's own uploaded .gitignore is layered on top.
uv sync # installs runtime + dev deps
make help # list all targets
make dev # uvicorn with reload
make prod WORKERS=2 # gunicorn + uvicorn workers (override HOST/PORT/WORKERS)
make test # pytest (203 tests)
make lint # ruff check
make typecheck # mypy (non-strict)The project uses uv 0.4.16; uv sync --group dev is not supported here.
Dev deps are under [tool.uv] dev-dependencies and install by default with
plain uv sync.
- No auth on the API.
- No multi-user / tenant model — single-instance demo.
- No private-repo support; reviews target public repos for now.
- No rate limiting.
- No
semgrep. Static-analysis pre-pass currently covers Python (ruff, bandit) and JS/TS (eslint, opt-in). Other languages get no static-analysis hints — the agent still reviews them, just without a pre-flagged file list. - No CLI — the web app is the only intended client.
- No history/trend view across runs (we persist the data; the UI is not built).
Vite + React SPA. The intended client for the server.
- Vite 8, React 19, TypeScript (Node 22+ pinned via
.nvmrc+engines) - pnpm (
packageManager: pnpm@10.32.1) - Tailwind CSS v4 via
@tailwindcss/vite; class-based dark mode (@custom-variant dark) - Roboto via Google Fonts; lucide-react for icons; react-content-loader for skeletons
- react-router-dom v7 for routing
- No global state library; state is lifted into
App.tsxand passed via props
web/src/
├── main.tsx mounts <BrowserRouter><App/></BrowserRouter>
├── App.tsx owns global state (projects, prompts, theme, dialogs); declares routes
├── api/
│ ├── client.ts ApiError, API_BASE, readError
│ ├── projects.ts list / create (git, upload, folder) / delete
│ ├── prompts.ts list / create / update / delete
│ ├── reviews.ts list / get / delete / run / create
│ ├── reviewFiles.ts list per review / per project
│ ├── chat.ts SSE chat stream
│ └── settings.ts LLM defaults, model catalog
├── adapters/ server DTO ↔ camelCase domain (project, prompt, review, reviewFile)
├── models/ camelCase domain types + server DTO shapes
├── components/
│ ├── Sidebar.tsx Argus brand + Projects/Prompts sections (5-item preview + show-all)
│ ├── InitialSplash.tsx first-load splash shown until shell mounts
│ ├── ConfirmDialog.tsx shared destructive-action dialog (danger variant)
│ ├── CopyButton.tsx "Copy to clipboard" with success state
│ ├── Checkbox.tsx custom violet/white checkbox
│ ├── ThemeToggle.tsx 3-state segmented light/system/dark
│ ├── chat/ChatDrawer.tsx slide-in chat panel (SSE stream)
│ ├── projects/NewProjectDialog.tsx git / files / folder source picker
│ ├── prompts/PromptDialog.tsx create + edit
│ ├── reviews/
│ │ ├── StartReviewDialog.tsx scope, prompt, provider/model picker
│ │ ├── ReviewListRow.tsx dashboard / project row cell
│ │ ├── FileScoreboard.tsx per-file score pill + severity counts
│ │ ├── FileFindingsDrawer.tsx slide-in panel; per-file findings
│ │ └── SeverityBadge.tsx shared badge for critical → info
│ └── skeletons/ Skeleton primitive + page-specific layouts
├── pages/
│ ├── DashboardPage.tsx / — recent reviews + project preview
│ ├── ProjectsListPage.tsx /projects — all projects
│ ├── ProjectPage.tsx /projects/:uid — reviews list + Files at risk + chat button
│ ├── PromptsPage.tsx /prompts — all prompts
│ ├── PromptPage.tsx /prompts/:uid — content view, edit, delete, default toggle
│ ├── ReviewsPage.tsx /reviews — paginated, all projects
│ ├── ReviewPage.tsx /reviews/:uid — Summary, Scores, Findings, Files at risk
│ └── DocsPage.tsx /docs — embedded README/help
├── hooks/
│ ├── useTheme.ts localStorage-backed light/dark/system
│ ├── useDocumentMeta.ts per-route <title> + <meta description>
│ └── useDelayedFlag.ts skeleton gate — only flips true after N ms
├── lib/
│ └── format.ts formatDateTime, formatDuration, formatTime (24h)
└── style/index.css Tailwind import + theme tokens + global rules
- The Vite dev server proxies
/api/*tohttp://127.0.0.1:8000. All API calls go through/api/...so cross-origin/CORS isn't an issue in dev. - DTO / domain split:
api/modules return camelCase domain types built byadapters/from the server's snake_case DTOs. Date/time fields stay as ISO strings;lib/format.tsrenders them in 24h. - AbortControllers on every list/get fetch so React StrictMode's double effect doesn't leave a phantom request in flight.
- Skeleton gating (
useDelayedFlag(800)) prevents skeleton flash on fast loads — the placeholder only appears after 800ms of pending state. - Per-route SEO: pages call
useDocumentMeta({ title, description }). - Theme: a small inline
<script>inindex.htmlapplies the.darkclass before React mounts, preventing flash.useThemekeeps state inlocalStorageunderargus.theme. is_defaultinvariant: whenPromptDialogsaves withisDefault=true(orPromptPagetoggles it on), App'shandlePromptSavedmirrors the server's "at most one default" by flipping every other prompt'sisDefaulttofalselocally.- Primary action buttons are solid violet (
bg-violet-600); destructive actions are solid rose (bg-rose-600); secondary actions are outlined. - Slide-in drawers (chat, file findings) animate with
setTimeout(..., 20)setTimeout(..., 300)rather than rAF — more reliable across React 19 commits thanrequestAnimationFrame.
pnpm install
pnpm dev # http://localhost:5173
pnpm build # tsc -b && vite build
pnpm lint # eslintA web/Makefile mirrors common targets for parity with server/.
- Auth UI.
- Private-repo token input.
- Trend / history view across runs (data is persisted; the chart isn't built).
- Markdown / PDF export of a review report (the JSON is ready; the renderer isn't wired).
argus/
├── Makefile help, zip (bundle repo incl. .git, excludes deps + .env)
├── README.md public docs
├── CLAUDE.md this file
├── server/ see above
└── web/ see above
make zip from the repo root produces ../argus.zip with .git/ included
and standard deps / caches / build outputs / .env files excluded.
.env.example is intentionally kept.