Skip to content

Latest commit

 

History

History
364 lines (317 loc) · 19.6 KB

File metadata and controls

364 lines (317 loc) · 19.6 KB

Argus

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.


server/

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.

Stack

  • 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-dependencies form (uv 0.4.16 here predates PEP 735 [dependency-groups])
  • SQLModel + SQLAlchemy on SQLite (server/argus.db by default)
  • Anthropic + OpenAI SDKs behind a provider-neutral LLMClient protocol (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) and eslint (opt-in if a config exists in the uploaded codebase). Symbol indexing via universal-ctags

Layout

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)

How the agent works

server/app/services/agent/loop.py is a tool-use loop:

  1. 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).
  2. Stream events from LLMClient.stream (text_delta, tool_use, stop).
  3. On tool_use, execute via Tools.execute(name, arguments) against the project's Sandbox(root) and append the result as the next user message.
  4. Repeat until the model emits no tool calls (or MAX_AGENT_ITERATIONS).
  5. For review mode (expect_report=True):
    • Parse the trailing REPORT: { ... } JSON into ReviewReport.
    • 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 emit REPORT: 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.
  6. 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.

Reviews lifecycle

  1. POST /projects/{uid}/reviews creates a pending row (snapshot of prompt + LLM).
  2. POST /reviews/{uid}/run schedules ReviewWorker.run_to_completion as a FastAPI BackgroundTask. The row goes pending → running immediately, then running → completed | failed when the loop finishes.
  3. On completed the worker also writes a sparse per-file rollup into review_files (one row per file that had at least one finding).
  4. The web polls GET /reviews/{uid} (Refresh button is only visible while status is pending or running).
  5. Cascade rules: deleting a review drops its review_files; deleting a project drops review_files → reviews → working dir.
  6. POST /projects/{uid}/reviews is blocked from deletion while any review on that project is running (409). pending does not block.

Identity model

  • 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-supplied uid (UUID, unique-indexed, the URL key). The web generates the uid via crypto.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 provider abstraction

  • LLM_PROVIDER selects anthropic or openai; LLM_MODEL is the default model id, overrideable per-request via the review's snapshotted provider/model.
  • Anthropic default is claude-opus-4-7; catalog also exposes claude-sonnet-4-6 and claude-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 (minimal on the forced final pass so reasoning-capable models don't burn the output budget on internal thinking and emit nothing).
  • services/llm/openai.py translates Anthropic-style messages (string content or list of text / tool_use / tool_result blocks) 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_TEMPERATURE defaults to 0.0 for reproducible reviews. MAX_AGENT_ITERATIONS caps loop budget (default 100).

Conventions

  • 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 via fastapi.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_DEFINITIONS and add a branch in Tools.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 is data: <json>\n\n. Don't add an SSE event: field unless a client needs it — the type discriminator is inside the JSON.
  • services/codebase/analysis.py runs subprocesses with timeouts; treat it as best-effort and never fail ingest because of it.
  • services/codebase/ctags.py is also best-effort; the symbol index is empty if ctags isn't on the host.
  • git clone runs with GIT_TERMINAL_PROMPT=0 and 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/update call store.unset_defaults() before persisting when is_default=True. Deleting the default is blocked (CannotDeleteDefaultPrompt → 409).
  • Built-in prompt v6 is seeded on app startup (services/seeds.py), keyed by a deterministic uuid5 so the insert is a no-op on subsequent boots.

Ingest filters

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.

Run / test (from server/)

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.

Things deliberately NOT in scope (yet)

  • 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).

web/

Vite + React SPA. The intended client for the server.

Stack

  • 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.tsx and passed via props

Layout

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

Conventions

  • The Vite dev server proxies /api/* to http://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 by adapters/ from the server's snake_case DTOs. Date/time fields stay as ISO strings; lib/format.ts renders 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> in index.html applies the .dark class before React mounts, preventing flash. useTheme keeps state in localStorage under argus.theme.
  • is_default invariant: when PromptDialog saves with isDefault=true (or PromptPage toggles it on), App's handlePromptSaved mirrors the server's "at most one default" by flipping every other prompt's isDefault to false locally.
  • 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 than requestAnimationFrame.

Run / build (from web/)

pnpm install
pnpm dev                # http://localhost:5173
pnpm build              # tsc -b && vite build
pnpm lint               # eslint

A web/Makefile mirrors common targets for parity with server/.

Things deliberately NOT in scope (yet)

  • 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).

root/

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.