Compare commits
36 Commits
v2.7.17-or
...
v2.8.3-ana
| Author | SHA1 | Date | |
|---|---|---|---|
| a72f7954b4 | |||
| 31d8efe66a | |||
| c935687725 | |||
| 0d6e9a2413 | |||
| 6344105877 | |||
| 028c08b4cd | |||
| fb52eb3efa | |||
| 648a59a563 | |||
| 7f59f30f2d | |||
| f436021bf9 | |||
| bef6bef504 | |||
| 87923cb07b | |||
| c6ecd984c5 | |||
| 2a83f61070 | |||
| 44874f0097 | |||
| 1b70d41996 | |||
| b64941ad4b | |||
| cdc782e044 | |||
| 02bb355a09 | |||
| b8b2666fdc | |||
| ee749d8698 | |||
| bc83475a3d | |||
| 214cc32ac2 | |||
| 6b7c2bab1e | |||
| 373ba86e5d | |||
| 9106334e70 | |||
| cce685b1a7 | |||
| dbf1662982 | |||
| d6d246c15b | |||
| e04d0fdaa8 | |||
| da36344d0b | |||
| 875cae0843 | |||
| 4caa5f91ff | |||
| 1d416d0cf9 | |||
| bfda61e27e | |||
| a734615480 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
|
||||
.claude/
|
||||
@@ -18,3 +20,4 @@ data/*
|
||||
!data/mcp.example.json
|
||||
!data/coder-providers.example.json
|
||||
codecontext/fork.tar.gz
|
||||
/Arena
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,36 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.8.0-fork-lifts — 2026-06-07
|
||||
|
||||
Completes the eight fork-lift integrations from `/opt/forks` into BooCode: boocontext sidecar upgrade, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol enhancements, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards (truncation + dropped imports) and the TokenScope analyzer/persist module. Closes the fork-lifts-mit epic.
|
||||
|
||||
**boocontext sidecar (Phase 3):** Upgrades the `codecontext` container from the old Go MCP server to the boocontext Node.js MCP aggregator. Multi-stage Dockerfile builds boocontext from `/opt/forks/boocontext` alongside the HTTP shim. `shim.go` gains `CODECONTEXT_CHILD` env-var support and three new HTTP routes for symbols, callgraph, and blast radius. Three TypeScript tool wrappers (`get_symbol_details`, `get_call_graph`, `get_blast_radius`) registered on the server, with blast radius added to the synthesis pipeline. Docker-compose env vars configure child MCP paths (tree-sitter-analyzer, type-inject).
|
||||
|
||||
**LSP integration (Phase 4):** Six-file `lsp/` module in the coder with config, JSON-RPC stdio client, lazy server-manager (per-project pool, 5-min idle shutdown), and operations (diagnostics, goto-definition, find-references). Three read-only agent tools registered — `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`. TypeScript/JavaScript only in v1.
|
||||
|
||||
**DCP clean-room (Phase 5):** Seven-file `dcp/` module in the server inference pipeline. Consecutive identical tool_call+tool_result pairs are deduplicated; failed/empty tool results are purged via configurable window. Orchestrated by `transformMessages()` running before `buildMessagesPayload` in `turn.ts`. Clean-room reimplementation — AGPL source was referenced for behavior only. 10 unit tests.
|
||||
|
||||
**Institutional memory (Phase 6):** Eight-file `memory/` module with file-based recall. Hierarchical 4-scope scan (global → home → project → session) under `.boocode/memory/`. Keyword/tag relevance matching at prompt assembly. Injected as a `<boocode-memory>` block in the system prompt. v1 recall-only — extract/dream deferred.
|
||||
|
||||
**Subagent protocol (Phase 7):** `AgentCapabilitiesSchema` in contracts with `supportsStreaming`, `supportsReasoningStream`, `supportsBackgroundExecution` flags. `ProviderSnapshotEntry` gains the two streaming capability fields. `new_task` tool gets a `background` mode flag for non-blocking dispatch. Flow-runner already supported per-step model override.
|
||||
|
||||
**Plugin host (Phase 8):** Typed hook registry in `plugins/host.ts` with `registerHook`/`emitHook` for five lifecycle events: `tool.execute.before`, `tool.execute.after`, `turn.start`, `turn.end`, `task.terminal`. Patterns-only from oh-my-openagent (SUL — no code copy).
|
||||
|
||||
**Inference reliability (Phase 9):** `tool-shim.ts` recovers XML/JSON tool calls from plain-text model output (e.g. Qwen inline format). `loop-detectors.ts` catches content-repeat and tool-loop patterns. Existing doom-loop detection remains — detectors are additive.
|
||||
|
||||
**Edit safety guards (Wave 1):** `edit-guards.ts` rejects catastrophic truncation (>60% chars AND >50% lines). `edit-guards-imports.ts` detects dropped import statements. Both run in `pending_changes.ts` immediately before `writeFileAtomic`.
|
||||
|
||||
**TokenScope (Wave 2):** `TokenBreakdownSchema` in contracts with system/user/assistant/tools/reasoning categories. `token-analysis/` module with analyzer and DB persistence. `ContestantShape.token_breakdown` field and `token_breakdown` JSONB column on `contestants`/`tasks` tables. Arena `computeBenchmark` accepts and returns token breakdown.
|
||||
|
||||
**Build:** Server 649 ✅ Coder 471 ✅ Contracts ✅ — all green.
|
||||
|
||||
Adds the **Arena** pane for running the same prompt against 2–6 AI competitors simultaneously and picking the best result. A Battle is one Arena run: pick a battle type (Coding — backend+model with git worktrees producing diffs; or Q&A — BooChat persona+model producing text), write or generate a prompt, add contestants, and hit Start. Contestants are scheduled in two concurrent lanes — the local lane (llama-swap models, serial) and the cloud lane (Claude Code, OpenCode-on-cloud, parallel). The lane scheduler captures wall-clock duration for every contestant and tokens/sec for local models. When all contestants finish, a two-stage analysis (digest then judge) auto-runs on the DEFAULT_MODEL, writing `analysis.md` naming a winner; the user can override the winner per-row or trigger cross-examination. Results land in `/<project-root>/Arena/<dated-battle>/` with per-contestant `result.md`, diff patches for coding, and `manifest.json`. Replaces the old API-only `POST /api/arena` with dedicated `battles`/`contestants`/`cross_examinations` tables and full UI. Also adds a `DiffView` component with line-by-line colored unified diff and a per-row dropdown for winner override. Built on `v2.7.18-permission-modes`; pairs conceptually with the earlier `v2.7.17-orchestrator` multi-agent work (both share the pane kind pattern and `onTaskTerminal` hook).
|
||||
|
||||
## v2.7.18-permission-modes — 2026-06-05
|
||||
|
||||
Adds a unified **permission picker** to the BooCoder composer — Plan / Ask Permission / Bypass — replacing the old raw per-agent mode dropdown that exposed each agent's full native vocabulary with inconsistent labels. The three options map generically onto every provider's existing mode metadata: the `plan`-id mode → Plan, the default mode → Ask, the `isUnattended` mode → Bypass (claude `bypassPermissions`, qwen `yolo`, opencode `full-access`); goose has no modes so it shows no picker, exactly as before. `modeId` stays the single wire field — the active unified mode is derived from it, so no contracts change was needed. Native BooCode gains its own mode set (registered in the manifest and exposed by the snapshot): **Ask** stages edits to the pending-changes queue as today, **Bypass** auto-applies the queue to disk after the turn (both the interactive messages path and the task-based dispatcher path), and **Plan** falls back to Ask — the shared `apps/server` inference engine is deliberately left untouched. A supporting fix preserves the `isUnattended` flag on live-probed ACP modes (`acp-derive.ts`) so opencode's bypass mode is still detectable from the wire. Coder 373 tests green, coder + web typecheck clean. Built on `v2.7.17-orchestrator`.
|
||||
|
||||
## v2.7.17-orchestrator — 2026-06-03
|
||||
|
||||
Brings the deterministic multi-agent "conductor" into the app as the **Orchestrator**: launch any read-only Han flow (research, code-review, investigate, architectural-analysis, security-review, …) from BooChat or BooCoder and watch each specialist agent stream live in a Paseo-style run pane, ending with an evidence-disciplined, adversarially-validated report — all on free local Qwen, persisted and resumable. Built and audited end-to-end via `paseo-epic` in an isolated worktree, on top of the prior `/opt/boocode/conductor` standalone CLI: the conductor's 22 flow definitions, Spine factory, and Han evidence/YAGNI contracts were re-homed into `apps/coder/src/conductor`, and a new DB-backed flow-runner (`flow_runs`/`flow_steps`) dispatches each step as a real BooCoder task through the existing dispatcher — reusing its streaming→WS-frame pipeline and worktree-as-read-snapshot, with an `onTaskTerminal` hook that advances the wave and a startup resume that re-dispatches in-flight steps after a coder restart. Read-only is enforced hard: every step is dispatched `qwen --approval-mode plan`, an adversarial-security review caught and closed a bypass where a qwen-unavailable task silently fell through to write-capable native inference (now fails closed), and the ACP path's mode-set was made fail-closed too. The UI adds a fourth `orchestrator` pane kind (collapsed agent roster, expand-one live stream, report on top), a Workflow button + slash flows on the shared `ChatInput` for full BooChat/BooCoder parity, a "New Orchestrator" entry in the + and split menus, a category-grouped launcher dialog, runs history, and export (copy / save-to-file / send-to-chat) — fed by two new `flow_run_*` WS frames on a coder user channel. Qwen-only by design (Claude Code remains the Claude path); the existing model-competition Arena stays a separate feature. The flow launcher and the `/` slash menu both carry chevron-expandable per-item explanations (an always-on one-liner expands to a 1–2 sentence what-it-does / when-to-use blurb, condensed from each Han skill's own description), with a "read-only" pill pinned in the launcher and the fast/concise toggle wired through to the workers. Spec/plan in `openspec/changes/orchestrator`; coder 373 tests green (42 new scheduler/resume/read-only decision tests), contracts/coder/server builds + web tsc clean. Built on `v2.7.16-container-git-safedir`; pairs conceptually with the earlier `v2.7.12-audit-cleanup` multi-agent orchestration.
|
||||
|
||||
@@ -74,11 +74,11 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
|
||||
|
||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only).
|
||||
|
||||
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
|
||||
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Its env file `apps/coder/.env.host` is gitignored (`.env.*`, with `!.env.example`) — a fresh host recreates it from `.env.example` (incl. `CLAUDE_SDK_BACKEND=1` for the Claude Agent-SDK backend). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
|
||||
|
||||
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
|
||||
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch.
|
||||
- Arena: `POST /api/arena {project_id, input, contestants: [{agent?, model?}]}` dispatches the same task to N models/agents in parallel; each contestant gets its own task + worktree. `GET /api/arena/:id` for results; `POST /api/arena/:id/select/:task_id` picks a winner.
|
||||
- Arena: `POST /api/battles {project_id, battle_type, prompt, contestants}` starts a battle; `GET /api/battles/:id` returns battle + contestants + cross-examinations; `POST /api/battles/:id/stop` cancels; `POST /api/battles/:id/analyze` triggers/re-triggers two-stage digest→judge analysis; `GET /api/battles/:id/analysis` reads `analysis.md`; `POST /api/battles/:id/cross-examine {identity, model}` runs a cross-examination. All `/api/battles*` routes are served by `apps/coder` at port 9502 (proxied through `apps/server` as `/api/coder/battles*`).
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
67
CONTEXT.md
Normal file
67
CONTEXT.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Context: BooCode
|
||||
|
||||
Glossary of the domain language. Terms only — no implementation detail.
|
||||
|
||||
## Workspace
|
||||
|
||||
- **Pane** — one tile in the multi-pane workspace. Each pane has a *kind*:
|
||||
Chat (BooChat), Coder (BooCoder), Terminal (BooTerm), Orchestrator, Arena,
|
||||
plus artifact/settings kinds.
|
||||
|
||||
- **Backend** — an AI engine a task is dispatched to: *native* (BooChat
|
||||
inference on a local llama-swap model) or an *external* CLI agent (Claude Code,
|
||||
OpenCode, Qwen, Goose). Code sometimes calls this the "agent" (`tasks.agent`).
|
||||
|
||||
- **BooChat Agent** (a.k.a. *persona*) — a preset from the `data/AGENTS.md`
|
||||
registry (e.g. "Code Reviewer", "Debugger"): a system prompt + tool whitelist +
|
||||
sampling knobs that runs **on the native backend** with a chosen model.
|
||||
Distinct from a Backend — this is the overloaded sense of "agent" the UI's
|
||||
Agent picker selects.
|
||||
|
||||
## Arena
|
||||
|
||||
A way to run the **same prompt** against several AI competitors at once and pick
|
||||
the best result.
|
||||
|
||||
- **Battle** — one Arena run. Dated. Produces a results folder at
|
||||
`/<project-root>/Arena/<dated-battle>/`. (The earlier API-only feature called
|
||||
this an "arena"; a Battle is one such run.)
|
||||
|
||||
- **Battle Type** — what is being compared:
|
||||
- *Coding* — Contestants change code; a result is the **diff** they produced
|
||||
(plus their explanation). Each Contestant works in its own worktree.
|
||||
- *Q&A* — Contestants answer a prompt; a result is the **text answer**. No
|
||||
code changes.
|
||||
|
||||
- **Contestant** — one competitor in a Battle, given the Battle's prompt. What
|
||||
defines a Contestant depends on Battle Type:
|
||||
- *Coding* — a **Backend + Model** (e.g. Claude Code + opus, native BooCode +
|
||||
35b). Each works in its own isolated git **worktree** (a branched on-disk
|
||||
copy of the project). Contestants do not see each other's work.
|
||||
- *Q&A* — a **BooChat Agent (persona) + Model** (e.g. Debugger + 35b), running
|
||||
on the native backend only. No worktree (no code changes).
|
||||
The same model can appear under two Contestants, so a Contestant's identity is
|
||||
the (backend-or-persona, model) pair, not the model alone.
|
||||
|
||||
- **Benchmark** — per-Contestant performance captured during a Battle. Wall-clock
|
||||
**duration** is recorded for every Contestant; **throughput** (tokens/sec) is
|
||||
recorded only for local (llama-swap) models, which are the ones the speed
|
||||
comparison is meaningful for.
|
||||
|
||||
- **Arena results folder** (`/<project-root>/Arena/<dated-battle>/`) — where a
|
||||
Battle's *results* are written (not the working copies — those stay in each
|
||||
Contestant's worktree). Holds the per-Contestant result and the final
|
||||
analysis.
|
||||
|
||||
- **Lane** — how a Battle's Contestants are scheduled. The *local lane* holds
|
||||
every llama-swap-backed Contestant and runs them strictly one at a time (the
|
||||
local server can only load one model at a time, which also keeps their speed
|
||||
Benchmark fair). The *cloud lane* holds cloud-backed Contestants (Claude Code,
|
||||
OpenCode-on-cloud) and runs them all in parallel. The two lanes run
|
||||
concurrently with each other.
|
||||
|
||||
- **Analysis** — an end-of-Battle judgement of the Contestants' results,
|
||||
produced by the default BooChat model, naming a **Winner**.
|
||||
|
||||
- **Cross-examination** — an after-the-Battle step where a chosen model (from any
|
||||
agent) is pointed at the Battle's results to interrogate / compare them.
|
||||
@@ -1,9 +1,9 @@
|
||||
# Current focus
|
||||
|
||||
Last updated: 2026-06-02
|
||||
Last updated: 2026-06-07
|
||||
|
||||
- **Last shipped:** `v2.7.8-ember-coder-tabs-model-chips` (2026-06-01)
|
||||
- **Branch:** `codebase-audit-cleanup` (audit + cleanup epic, off main HEAD)
|
||||
- **In progress:** Phase 3 — stale comments + docs refresh
|
||||
- **Last shipped:** `v2.8.0-fork-lifts` (2026-06-07) — eight fork-lift integrations from `/opt/forks`: boocontext sidecar, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards and TokenScope analyzer/persist module.
|
||||
- **Branch:** `main`
|
||||
- **In progress:** nothing committed — all phases 3-9 of fork-lifts-mit epic are shipped. Optional/exploratory: verify-gate ensembler over pending changes; web Arena token UI display.
|
||||
|
||||
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.
|
||||
|
||||
15
README.md
15
README.md
@@ -1,10 +1,10 @@
|
||||
# boocode
|
||||
|
||||
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals).
|
||||
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals) — plus the in-app **Orchestrator**, a deterministic multi-agent conductor that runs read-only Han analysis/review flows on local Qwen.
|
||||
|
||||
**Latest release:** `v2.2.1-pane-scoped-chats` (2026-05-26) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md)
|
||||
**Latest release:** `v2.7.17-orchestrator` (2026-06-03) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md)
|
||||
|
||||
**Agent navigation:** [`AGENTS.md`](AGENTS.md) · **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md)
|
||||
**Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md) · **Roadmap:** [`boocode_roadmap.md`](boocode_roadmap.md)
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -75,15 +75,16 @@ curl http://100.114.205.53:9502/api/health
|
||||
|
||||
## What's shipped
|
||||
|
||||
See [`boocode_roadmap.md`](boocode_roadmap.md) for full version history. Highlights as of **v2.2.1**:
|
||||
See [`boocode_roadmap.md`](boocode_roadmap.md) and [`CHANGELOG.md`](CHANGELOG.md) for full version history. Highlights as of **v2.7.17**:
|
||||
|
||||
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder)
|
||||
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder / orchestrator)
|
||||
- **BooTerm**: in-browser terminal panes via tmux + xterm.js, per-session tmux sessions, SSH-out support
|
||||
- **BooCoder (v2.2)**: write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`), pending-changes queue with diff UI, Paseo-style provider snapshot (7 providers: boocode, cursor, claude, opencode, goose, qwen, copilot), `AgentComposerBar` (provider / mode / model / thinking), ACP dispatch with inline permission prompts + tool/reasoning streaming, PTY fallback, Arena, MCP server (6 tools, stdio), CLI client, human inbox, Boomerang orchestration, path-guard fuzz suite, **pane-scoped chats** (v2.2.1 — each coder/terminal pane owns its chat)
|
||||
- **BooCoder**: write tools (`edit_file` with fuzzy matching, `create_file`, `delete_file`, `apply_pending`, `rewind`, git-ref checkpoints), pending-changes queue + a **Files/Git diff panel** (stage / commit / discard), provider snapshot (5 providers: boocode, claude, opencode, goose, qwen — cursor/copilot retired), `AgentComposerBar`, warm ACP + **persistent agent sessions** (opencode HTTP server; claude via the Agent SDK with native session resume) + PTY fallback, config-backed provider lifecycle, Arena (same task → N models), MCP server, CLI client, human inbox, Boomerang orchestration, pane-scoped chats
|
||||
- **Orchestrator** (v2.7.17): launch any of 22 read-only Han flows (research, code-review, investigate, architectural-analysis, …) from BooChat or BooCoder via the Workflow button, a slash command, or **+ menu → New Orchestrator**; each step runs as a bounded agent on local Qwen (hard read-only via `qwen --approval-mode plan`), streaming live in a Paseo-style run pane with an evidence-disciplined, adversarially-validated report. Persisted + resumable. `@boocode/contracts` single-sources the cross-app wire contracts (v2.7.13).
|
||||
|
||||
## Planned
|
||||
|
||||
- **v2.3 provider lifecycle** — config-backed provider registry (`/data/coder-providers.json`), enable/disable toggles, two-tier probe (openspec drafted). See [`CURRENT.md`](CURRENT.md).
|
||||
Most prior roadmap milestones have shipped (see [`boocode_roadmap.md`](boocode_roadmap.md)). What remains is optional/exploratory — e.g. a verify-gate ensembler over pending changes (majority-vote diff ranking). No committed milestones currently in flight.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
NODE_ENV=production
|
||||
PORT=9502
|
||||
HOST=100.114.205.53
|
||||
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat
|
||||
LLAMA_SWAP_URL=http://100.101.41.16:8401
|
||||
PROJECT_ROOT_WHITELIST=/opt
|
||||
BOOTSTRAP_ROOT=/opt/projects
|
||||
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
|
||||
LOG_LEVEL=info
|
||||
SEARXNG_URL=http://100.114.205.53:8888
|
||||
GITEA_BASE_URL=https://git.indifferentketchup.com
|
||||
GITEA_USER=indifferentketchup
|
||||
GITEA_SSH_HOST=100.114.205.53:2222
|
||||
MCP_CONFIG_PATH=/data/mcp.json
|
||||
SKILLS_ROOT=/opt/boocode/data/skills
|
||||
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json
|
||||
CLAUDE_SDK_BACKEND=1
|
||||
@@ -32,3 +32,15 @@
|
||||
- **Claude SDK backend tool RESULTS arrive as `type:'user'` SDK messages** (tool_result content blocks): `mapSdkMessage` (`claude-sdk-map.ts`) MUST map the `user` case → a terminal `tool_update` (completed/failed + output), else the tool_call persists `status:'running'` and the UI spinner never stops. The dispatcher's `tool_update` path then publishes + persists it.
|
||||
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in.
|
||||
- **A new per-message coder field silently drops unless you update every mapper**: the HTTP read SELECT + `mapCoderMessageRow` (`apps/coder/src/routes/messages.ts`), **the WS `snapshot` SELECT (`apps/coder/src/routes/ws.ts`)** — it has its OWN column list and the client's `snapshot` handler `setMessages`-overwrites the HTTP load, so a field present in the HTTP route but absent here shows live yet vanishes on refresh — `CoderPane.tsx` (`RawCoderMessage`/`CoderMessage`/`mapCoderTimelineRow` + the live `message_complete` WS reducer), `CoderMessageWire` (`CoderMessageList.tsx`), and `api/types.ts`. The client `mapCoderTimelineRow` whitelists fields — easiest to forget. This bit `model` twice: the client chain (`v2.7.9`) and then the WS snapshot SELECT (`v2.7.11`) — the chip showed live but vanished on coder refresh until both were fixed.
|
||||
|
||||
## Orchestrator (v2.7.17)
|
||||
|
||||
- **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`.
|
||||
- **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`).
|
||||
|
||||
## Edit safety guards (v2.8)
|
||||
|
||||
- **`services/edit-guards.ts`** — `validateEditResult(original, updated, filePath)` runs in `pending_changes.ts` immediately before `writeFileAtomic`. Rejects catastrophic truncation (>60% char loss AND >50% line loss). Throws a `formatGuardError` message that percolates to the agent as a visible error.
|
||||
- **`services/edit-guards-imports.ts`** — `checkDroppedImports(original, updated, filePath)` detects removed import/require lines. Called alongside the truncation guard.
|
||||
- Both guards run on the `/apply` path only (not on queue). Re-queued identical edits re-validate at apply time.
|
||||
- Guard functions are pure — no DB or filesystem access. Easy to unit-test.
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './planning.js';
|
||||
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
|
||||
import { codeReview } from './code-review.js';
|
||||
import { parallelResearch } from './parallel-research.js';
|
||||
|
||||
const spines: Spine[] = [
|
||||
// analysis / research
|
||||
@@ -53,7 +54,7 @@ const spines: Spine[] = [
|
||||
stakeholderSummary,
|
||||
];
|
||||
|
||||
const bespoke: Flow[] = [codeReview];
|
||||
const bespoke: Flow[] = [codeReview, parallelResearch];
|
||||
|
||||
const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke];
|
||||
|
||||
|
||||
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal file
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Flow, Step, StepContext } from '../types.js';
|
||||
|
||||
const q = (ctx: StepContext) => String(ctx.input.question);
|
||||
|
||||
/**
|
||||
* Parallel research flow — dispatches 3 research agents simultaneously,
|
||||
* then synthesizes the result on the first one to complete.
|
||||
*/
|
||||
export const parallelResearch: Flow = {
|
||||
name: 'parallel-research',
|
||||
description: 'Research from 3 angles in parallel, synthesize results on first completion',
|
||||
steps: [
|
||||
{
|
||||
id: 'angle-web',
|
||||
kind: 'agent',
|
||||
agent: 'research-analyst',
|
||||
run: (ctx) =>
|
||||
`Research the following question from a web / prior-art perspective:\n\n${q(ctx)}`,
|
||||
},
|
||||
{
|
||||
id: 'angle-code',
|
||||
kind: 'agent',
|
||||
agent: 'codebase-explorer',
|
||||
deps: [],
|
||||
run: (ctx) =>
|
||||
`Research the following question from a codebase analysis perspective:\n\n${q(ctx)}`,
|
||||
},
|
||||
{
|
||||
id: 'angle-security',
|
||||
kind: 'agent',
|
||||
agent: 'adversarial-security-analyst',
|
||||
deps: [],
|
||||
run: (ctx) =>
|
||||
`Research the following question from a security perspective:\n\n${q(ctx)}`,
|
||||
},
|
||||
{
|
||||
id: 'synthesize',
|
||||
kind: 'code',
|
||||
deps: ['angle-web', 'angle-code', 'angle-security'],
|
||||
trigger_rule: 'one_success',
|
||||
run: (ctx) => {
|
||||
const web = ctx.results['angle-web'];
|
||||
const code = ctx.results['angle-code'];
|
||||
const security = ctx.results['angle-security'];
|
||||
const parts = [
|
||||
'# Parallel Research Synthesis',
|
||||
'',
|
||||
web ? `## Web Angle\n${web}` : '## Web Angle\n*(not yet completed)*',
|
||||
code ? `## Code Angle\n${code}` : '## Code Angle\n*(not yet completed)*',
|
||||
security ? `## Security Angle\n${security}` : '## Security Angle\n*(not yet completed)*',
|
||||
];
|
||||
return parts.join('\n\n');
|
||||
},
|
||||
},
|
||||
],
|
||||
render: (ctx) => {
|
||||
return ctx.results['synthesize'] ?? 'No synthesis produced.';
|
||||
},
|
||||
};
|
||||
@@ -38,7 +38,9 @@ export interface StepContext {
|
||||
readonly model?: string;
|
||||
}
|
||||
|
||||
export type StepKind = 'agent' | 'code';
|
||||
export type StepKind = 'agent' | 'code' | 'approval';
|
||||
|
||||
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
|
||||
|
||||
export interface Step {
|
||||
/** unique id within the flow; other steps depend on it by this id */
|
||||
@@ -46,6 +48,8 @@ export interface Step {
|
||||
kind: StepKind;
|
||||
/** ids that must complete (or skip) before this step runs */
|
||||
deps?: string[];
|
||||
/** how dependency satisfaction is evaluated (default: all_success) */
|
||||
trigger_rule?: TriggerRule;
|
||||
/** for kind:'agent' — the persona file name under conductor/agents (no .md) */
|
||||
agent?: string;
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
||||
import { WRITE_TOOLS } from './services/tools/index.js';
|
||||
import { adaptWriteTool } from './services/tools/adapter.js';
|
||||
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||
import { runWithInferenceContext } from './services/tools/inference_context.js';
|
||||
// Routes
|
||||
import { registerMessageRoutes } from './routes/messages.js';
|
||||
import { registerSkillRoutes } from './routes/skills.js';
|
||||
@@ -23,8 +23,8 @@ import { registerAgentSessionRoutes } from './routes/agent-sessions.js';
|
||||
import { registerTaskRoutes } from './routes/tasks.js';
|
||||
import { registerInboxRoutes } from './routes/inbox.js';
|
||||
import { registerStatsRoutes } from './routes/stats.js';
|
||||
import { registerArenaRoutes } from './routes/arena.js';
|
||||
import { registerRunsRoutes } from './routes/runs.js';
|
||||
import { registerArenaRoutes } from './routes/arena.js';
|
||||
import { registerProviderRoutes } from './routes/providers.js';
|
||||
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
||||
import { registerLifecycleRoutes } from './routes/lifecycle.js';
|
||||
@@ -34,10 +34,13 @@ import { createDispatcher } from './services/dispatcher.js';
|
||||
// Orchestrator (Phase 2): DB-backed flow-runner; advances on the dispatcher's
|
||||
// onTaskTerminal hook.
|
||||
import { createFlowRunner } from './services/flow-runner.js';
|
||||
// Arena: DB-backed battle-runner; also advances on the onTaskTerminal hook.
|
||||
import { createBattleRunner, type DispatchContestantFn } from './services/arena-runner.js';
|
||||
import { createAnalyzer } from './services/arena-analyzer.js';
|
||||
import { agentPool } from './services/agent-pool.js';
|
||||
import { createOrphanWorktreeReaper } from './services/orphan-worktree-reaper.js';
|
||||
import { probeAgents } from './services/agent-probe.js';
|
||||
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||
import { getProviderSnapshot, persistProbedModels, fetchLlamaSwapModels } from './services/provider-snapshot.js';
|
||||
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||
import { publishAgentStatus } from './services/agent-status-publish.js';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -171,22 +174,27 @@ async function main() {
|
||||
}
|
||||
);
|
||||
|
||||
// Wrap the inference runner to set/clear the write-tool context around each run.
|
||||
// The inference runner calls enqueue() which fires asynchronously — we hook
|
||||
// into the enqueue to set context before the run starts.
|
||||
// Wrap the inference runner to bind the write-tool context around each run.
|
||||
// enqueue() starts its async loop synchronously, so wrapping the call in
|
||||
// runWithInferenceContext propagates the per-run context (sql, sessionId, the
|
||||
// Plan/Ask/Bypass gate) through every awaited tool execution — and concurrent
|
||||
// runs (a user message racing a dispatcher-polled native task) each get their
|
||||
// own, instead of clobbering a shared global.
|
||||
const inferenceApi = {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
|
||||
// Set the inference context so write tools can access sql + sessionId.
|
||||
// The context persists for the duration of the inference run. Since
|
||||
// BooCoder is single-user and runs one inference at a time per session,
|
||||
// this module-level state is safe.
|
||||
setInferenceContext({ sql, sessionId, taskId: null });
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
enqueue: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
user: string,
|
||||
permissionMode?: 'plan' | 'ask' | 'bypass',
|
||||
) => {
|
||||
runWithInferenceContext({ sql, sessionId, taskId: null, permissionMode }, () => {
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
});
|
||||
},
|
||||
cancel: async (sessionId: string, chatId: string) => {
|
||||
const result = await inference.cancel(sessionId, chatId);
|
||||
clearInferenceContext();
|
||||
return result;
|
||||
// No context to clear — AsyncLocalStorage scopes it to each run's own chain.
|
||||
return inference.cancel(sessionId, chatId);
|
||||
},
|
||||
hasActive: (chatId: string) => inference.hasActive(chatId),
|
||||
};
|
||||
@@ -220,31 +228,119 @@ async function main() {
|
||||
|
||||
// Orchestrator (Phase 2): the flow-runner reacts to the dispatcher's
|
||||
// onTaskTerminal hook to advance flow_runs. Created before the dispatcher so its
|
||||
// terminal callback can be wired in. Its launch() is driven by the runs route
|
||||
// (a later phase); resume on startup is a later phase too.
|
||||
// terminal callback can be wired in.
|
||||
const flowRunner = createFlowRunner({ sql, broker, log: app.log, config });
|
||||
|
||||
// Phase 4: dispatcher — polls tasks table and runs inference. onTaskTerminal
|
||||
// notifies the flow-runner when a step's task settles (D-2).
|
||||
// Arena SEAM (a): build the local-model set from the live llama-swap model list.
|
||||
// Both bare IDs ('qwen3.6-35b') and prefixed IDs ('llama-swap/qwen3.6-35b') are
|
||||
// included so opencode-style prefixed contestants and native-style bare contestants
|
||||
// both classify correctly as local.
|
||||
const localModelsList = await fetchLlamaSwapModels(config).catch(() => []);
|
||||
const localModels = new Set([
|
||||
...localModelsList.map((m) => m.id),
|
||||
...localModelsList.map((m) => `llama-swap/${m.id}`),
|
||||
]);
|
||||
|
||||
// Arena dispatch function — Phase 4 SEAM (b).
|
||||
// Coding: insert a tasks row with agent=identity (null for native/boocode);
|
||||
// the dispatcher creates a worktree and runs the external agent (or native).
|
||||
// Q&A: pre-create a session with agent_id stamped to the persona slug so native
|
||||
// inference loads the persona's system_prompt + tools from AGENTS.md;
|
||||
// task.session_id is pre-set so runNativeInference reuses the session.
|
||||
const dispatchContestant: DispatchContestantFn = async ({
|
||||
projectId,
|
||||
prompt,
|
||||
identity,
|
||||
model,
|
||||
battleType,
|
||||
}) => {
|
||||
if (battleType === 'qa') {
|
||||
const sessionName = `Arena Q&A [${identity}]: ${prompt.slice(0, 30)}`;
|
||||
const [session] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, agent_id, status)
|
||||
VALUES (${projectId}, ${sessionName}, ${model}, ${identity}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
const [task] = await sql<{ id: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, model, session_id)
|
||||
VALUES (${projectId}, ${prompt}, ${model}, ${session!.id})
|
||||
RETURNING id
|
||||
`;
|
||||
return { taskId: task!.id, sessionId: session!.id };
|
||||
}
|
||||
// Coding: boocode = native inference (no external agent); any other identity
|
||||
// is an external agent name (claude, opencode, qwen, goose) that maps to
|
||||
// available_agents and gets its own per-task worktree via runExternalAgent.
|
||||
// Session is created lazily by the dispatcher, so sessionId is unknown here.
|
||||
const agentName = identity === 'boocode' ? null : identity;
|
||||
const [task] = await sql<{ id: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model)
|
||||
VALUES (${projectId}, ${prompt}, ${agentName}, ${model})
|
||||
RETURNING id
|
||||
`;
|
||||
return { taskId: task!.id, sessionId: null };
|
||||
};
|
||||
|
||||
// Arena analyzer: two-stage digest→judge (v1). Pluggable seam — a v2 Han
|
||||
// Orchestrator flow can replace this without schema changes.
|
||||
const analyzer = createAnalyzer({
|
||||
sql,
|
||||
broker,
|
||||
log: app.log,
|
||||
config,
|
||||
localModels,
|
||||
});
|
||||
|
||||
// Arena battle-runner: notified on the same onTaskTerminal hook as the flow-runner.
|
||||
const battleRunner = createBattleRunner({
|
||||
sql,
|
||||
broker,
|
||||
log: app.log,
|
||||
dispatch: dispatchContestant,
|
||||
onBattleComplete: (battleId) => {
|
||||
void analyzer.analyze(battleId);
|
||||
},
|
||||
onCrossExamStart: ({ battleId, crossExamId, identity, model }) => {
|
||||
void analyzer.crossExamine(battleId, crossExamId, { identity, model });
|
||||
},
|
||||
localModels,
|
||||
});
|
||||
|
||||
// Compose onTaskTerminal: both flow-runner and battle-runner are notified.
|
||||
// Each ignores tasks it doesn't own (flow-runner checks flow_steps.task_id;
|
||||
// battle-runner checks contestants.task_id).
|
||||
const onTaskTerminal = (taskId: string, state: string): void => {
|
||||
flowRunner.handleTaskTerminal(taskId, state);
|
||||
battleRunner.handleTaskTerminal(taskId, state);
|
||||
};
|
||||
|
||||
// Phase 4: dispatcher — polls tasks table and runs inference. The composed
|
||||
// onTaskTerminal hook notifies both the flow-runner and the battle-runner when
|
||||
// any task settles.
|
||||
const dispatcher = createDispatcher({
|
||||
sql,
|
||||
inference: inferenceApi,
|
||||
broker,
|
||||
log: app.log,
|
||||
config,
|
||||
onTaskTerminal: flowRunner.handleTaskTerminal,
|
||||
onTaskTerminal,
|
||||
});
|
||||
dispatcher.start();
|
||||
|
||||
// Phase 5: re-advance any flow_runs that were 'running' when the service last
|
||||
// stopped (D-9). Runs AFTER dispatcher.start() so re-dispatched 'pending' tasks
|
||||
// are picked up by the dispatcher's startup poll.
|
||||
// Re-advance in-flight flow_runs and battles after a coder restart. Both run
|
||||
// AFTER dispatcher.start() so re-dispatched 'pending' tasks are picked up.
|
||||
void flowRunner.initResume().catch((err) => {
|
||||
app.log.error(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'flow-runner: initResume failed',
|
||||
);
|
||||
});
|
||||
void battleRunner.initResume().catch((err) => {
|
||||
app.log.error(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'arena: initResume failed',
|
||||
);
|
||||
});
|
||||
|
||||
// v2.6 Phase 3: configure + start the agent-pool lifecycle sweep (idle-TTL +
|
||||
// LRU-cap eviction of warm backends, plus each backend's proactive health probe)
|
||||
@@ -281,8 +377,8 @@ async function main() {
|
||||
registerTaskRoutes(app, sql, inferenceApi, dispatcher.cancelExternalTask);
|
||||
registerInboxRoutes(app, sql);
|
||||
registerStatsRoutes(app, sql);
|
||||
registerArenaRoutes(app, sql);
|
||||
registerRunsRoutes(app, sql, flowRunner, dispatcher.cancelExternalTask);
|
||||
registerArenaRoutes(app, sql, battleRunner, dispatcher.cancelExternalTask, config);
|
||||
registerProviderRoutes(app, sql, config);
|
||||
registerWorktreeSafetyRoutes(app, sql);
|
||||
registerLifecycleRoutes(app, sql);
|
||||
|
||||
42
apps/coder/src/plugins/host.ts
Normal file
42
apps/coder/src/plugins/host.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type HookName =
|
||||
| 'tool.execute.before'
|
||||
| 'tool.execute.after'
|
||||
| 'turn.start'
|
||||
| 'turn.end'
|
||||
| 'task.terminal';
|
||||
|
||||
export interface ToolHookContext {
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
projectRoot: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface ToolResultContext extends ToolHookContext {
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
export type PluginHook = (ctx: any) => Promise<any>;
|
||||
|
||||
const hooks = new Map<HookName, PluginHook[]>();
|
||||
|
||||
export function registerHook(name: HookName, fn: PluginHook): void {
|
||||
const list = hooks.get(name) || [];
|
||||
list.push(fn);
|
||||
hooks.set(name, list);
|
||||
}
|
||||
|
||||
export async function emitHook(name: HookName, ctx: any): Promise<any> {
|
||||
const list = hooks.get(name);
|
||||
if (!list) return ctx;
|
||||
let current = ctx;
|
||||
for (const fn of list) {
|
||||
const result = await fn(current);
|
||||
if (result !== undefined) current = result;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function clearHooks(): void {
|
||||
hooks.clear();
|
||||
}
|
||||
78
apps/coder/src/routes/analytics.ts
Normal file
78
apps/coder/src/routes/analytics.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
// token-analyzer-ui: aggregate token/cost analytics across all agent_sessions.
|
||||
// v1 — global view only (no per-project or per-user filtering).
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost: number;
|
||||
session_count: number;
|
||||
}
|
||||
|
||||
export interface SessionAnalyticsRow {
|
||||
session_id: string;
|
||||
session_name: string;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost: number;
|
||||
last_active_at: string | null;
|
||||
}
|
||||
|
||||
export interface TokenBreakdownAgg {
|
||||
category: string;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// GET /api/analytics/summary — aggregate totals across all agent_sessions.
|
||||
app.get('/api/analytics/summary', async () => {
|
||||
const [row] = await sql<AnalyticsSummary[]>`
|
||||
SELECT
|
||||
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||
COUNT(DISTINCT c.session_id)::INT AS session_count
|
||||
FROM agent_sessions a
|
||||
JOIN chats c ON c.id = a.chat_id
|
||||
`;
|
||||
return row ?? { total_input_tokens: 0, total_output_tokens: 0, total_cost: 0, session_count: 0 };
|
||||
});
|
||||
|
||||
// GET /api/analytics/sessions — per-session token/cost breakdown.
|
||||
app.get('/api/analytics/sessions', async () => {
|
||||
const rows = await sql<SessionAnalyticsRow[]>`
|
||||
SELECT
|
||||
c.session_id AS session_id,
|
||||
s.name AS session_name,
|
||||
COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens,
|
||||
COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens,
|
||||
COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost,
|
||||
MAX(a.last_active_at) AS last_active_at
|
||||
FROM agent_sessions a
|
||||
JOIN chats c ON c.id = a.chat_id
|
||||
JOIN sessions s ON s.id = c.session_id
|
||||
GROUP BY c.session_id, s.name
|
||||
ORDER BY MAX(a.last_active_at) DESC NULLS LAST
|
||||
`;
|
||||
return { sessions: rows };
|
||||
});
|
||||
|
||||
// GET /api/analytics/token-breakdown — aggregate token_breakdown categories
|
||||
// across all tasks that carry the JSONB field.
|
||||
app.get('/api/analytics/token-breakdown', async () => {
|
||||
const rows = await sql<{ category: string; total_tokens: number }[]>`
|
||||
SELECT
|
||||
key AS category,
|
||||
SUM((value->>0)::BIGINT)::BIGINT AS total_tokens
|
||||
FROM tasks,
|
||||
LATERAL jsonb_each(token_breakdown)
|
||||
WHERE token_breakdown IS NOT NULL
|
||||
AND jsonb_typeof(token_breakdown) = 'object'
|
||||
GROUP BY key
|
||||
ORDER BY total_tokens DESC
|
||||
`;
|
||||
return { categories: rows };
|
||||
});
|
||||
}
|
||||
@@ -1,136 +1,412 @@
|
||||
/**
|
||||
* v2.0.5: Arena routes — competitive dispatch of the same task to multiple agents.
|
||||
* Arena routes — HTTP surface for the Battle UI.
|
||||
*
|
||||
* POST /api/arena — create an arena with 2-5 contestants
|
||||
* GET /api/arena/:id — get all tasks in an arena
|
||||
* POST /api/arena/:id/select/:task_id — mark a task as the arena winner
|
||||
* POST /api/battles — launch a battle
|
||||
* GET /api/battles?project_id= — list battles for a project
|
||||
* GET /api/battles/:id — one battle + contestants + cross-exams
|
||||
* POST /api/battles/:id/stop — cancel a running battle
|
||||
* POST /api/battles/:id/analyze — trigger analysis (Phase 5 fills the logic)
|
||||
* POST /api/battles/:id/cross-examine — start a cross-examination (Phase 5 fills the logic)
|
||||
*
|
||||
* Mirrors the shape of runs.ts (Orchestrator routes). Battle creation delegates to
|
||||
* the battle-runner; cancellation calls cancelBattle then aborts in-flight tasks
|
||||
* via the dispatcher's cancelExternalTask.
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import type { BattleRunner } from '../services/arena-runner.js';
|
||||
import type { ExternalCancelFn } from './tasks.js';
|
||||
import { arenaModelCall } from '../services/arena-model-call.js';
|
||||
|
||||
const ContestantSchema = z.object({
|
||||
agent: z.string().max(100).optional(),
|
||||
model: z.string().max(200).optional(),
|
||||
mode_id: z.string().max(200).optional(),
|
||||
thinking_option_id: z.string().max(200).optional(),
|
||||
// ─── Validation schemas ───────────────────────────────────────────────────────
|
||||
|
||||
const UuidParam = z.string().uuid();
|
||||
|
||||
const ContestantInput = z.object({
|
||||
identity: z.string().min(1).max(200),
|
||||
model: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
const CreateArenaBody = z.object({
|
||||
const CreateBattleBody = z.object({
|
||||
project_id: z.string().uuid(),
|
||||
input: z.string().min(1).max(64_000),
|
||||
contestants: z.array(ContestantSchema).min(2).max(5),
|
||||
battle_type: z.enum(['coding', 'qa']),
|
||||
prompt: z.string().min(1).max(64_000),
|
||||
contestants: z
|
||||
.array(ContestantInput)
|
||||
.min(2, 'at least 2 contestants required')
|
||||
.max(6, 'at most 6 contestants allowed'),
|
||||
});
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
mode_id: string | null;
|
||||
thinking_option_id: string | null;
|
||||
state: string;
|
||||
}
|
||||
const ListBattlesQuery = z.object({
|
||||
project_id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// POST /api/arena — create a new arena
|
||||
app.post('/api/arena', async (req, reply) => {
|
||||
const parsed = CreateArenaBody.safeParse(req.body);
|
||||
const CrossExamineBody = z.object({
|
||||
identity: z.string().min(1).max(200),
|
||||
model: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
const SetWinnerBody = z.object({
|
||||
winner_contestant_id: z.string().uuid().nullable(),
|
||||
});
|
||||
|
||||
// ─── Route registration ───────────────────────────────────────────────────────
|
||||
|
||||
const GeneratePromptBody = z.object({
|
||||
description: z.string().min(1).max(2_000),
|
||||
});
|
||||
|
||||
export function registerArenaRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
battleRunner: BattleRunner,
|
||||
cancelExternal: ExternalCancelFn,
|
||||
config: Config,
|
||||
): void {
|
||||
|
||||
// POST /api/battles/generate-prompt — draft a fuller battle prompt from a
|
||||
// short description using the default BooChat model. One-shot, non-streaming.
|
||||
// Must be registered BEFORE /api/battles/:id so the literal 'generate-prompt'
|
||||
// path is not mistaken for a UUID param.
|
||||
app.post('/api/battles/generate-prompt', async (req, reply) => {
|
||||
const parsed = GeneratePromptBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const { project_id, input, contestants } = parsed.data;
|
||||
const arenaId = crypto.randomUUID();
|
||||
const { description } = parsed.data;
|
||||
|
||||
const tasks: TaskRow[] = [];
|
||||
for (const contestant of contestants) {
|
||||
const [task] = await sql<TaskRow[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, arena_id)
|
||||
VALUES (
|
||||
${project_id},
|
||||
${input},
|
||||
${contestant.agent ?? null},
|
||||
${contestant.model ?? null},
|
||||
${contestant.mode_id ?? null},
|
||||
${contestant.thinking_option_id ?? null},
|
||||
${arenaId}
|
||||
)
|
||||
RETURNING id, agent, model, mode_id, thinking_option_id, state
|
||||
`;
|
||||
tasks.push(task!);
|
||||
try {
|
||||
const prompt = await arenaModelCall({
|
||||
config,
|
||||
model: config.DEFAULT_MODEL,
|
||||
system: [
|
||||
'You are a battle-prompt writer for an AI Arena.',
|
||||
'The user gives you a short description of a coding or Q&A challenge.',
|
||||
'Expand it into a clear, self-contained prompt (2–6 sentences) that any AI model can act on.',
|
||||
'Include specific acceptance criteria where helpful.',
|
||||
'Output ONLY the prompt — no preamble, no labels, no meta-commentary.',
|
||||
].join(' '),
|
||||
user: description,
|
||||
maxTokens: 400,
|
||||
temperature: 0.6,
|
||||
});
|
||||
return { prompt };
|
||||
} catch (err) {
|
||||
app.log.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'arena generate-prompt: model call failed',
|
||||
);
|
||||
reply.code(502);
|
||||
return { error: 'model call failed' };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/battles — launch a battle
|
||||
app.post('/api/battles', async (req, reply) => {
|
||||
const parsed = CreateBattleBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const { project_id, battle_type, prompt, contestants } = parsed.data;
|
||||
|
||||
// Reject duplicate (identity, model) pairs up front — the schema UNIQUE
|
||||
// constraint would catch it too, but an early 422 is friendlier.
|
||||
const seen = new Set<string>();
|
||||
for (const c of contestants) {
|
||||
const key = `${c.identity}::${c.model}`;
|
||||
if (seen.has(key)) {
|
||||
reply.code(422);
|
||||
return {
|
||||
error: 'duplicate_contestant',
|
||||
message: `duplicate contestant: identity="${c.identity}" model="${c.model}"`,
|
||||
};
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
// Verify project exists
|
||||
const [proj] = await sql<{ id: string }[]>`SELECT id FROM projects WHERE id = ${project_id}`;
|
||||
if (!proj) {
|
||||
reply.code(404);
|
||||
return { error: 'project not found' };
|
||||
}
|
||||
|
||||
const { battleId } = await battleRunner.startBattle({
|
||||
projectId: project_id,
|
||||
battleType: battle_type,
|
||||
prompt,
|
||||
contestants,
|
||||
});
|
||||
|
||||
reply.code(201);
|
||||
return {
|
||||
arena_id: arenaId,
|
||||
tasks: tasks.map((t) => ({
|
||||
id: t.id,
|
||||
agent: t.agent,
|
||||
model: t.model,
|
||||
mode_id: t.mode_id,
|
||||
thinking_option_id: t.thinking_option_id,
|
||||
state: t.state,
|
||||
})),
|
||||
};
|
||||
return { battle_id: battleId };
|
||||
});
|
||||
|
||||
// GET /api/arena/:arena_id — list all tasks in an arena
|
||||
app.get<{ Params: { arena_id: string } }>('/api/arena/:arena_id', async (req, reply) => {
|
||||
const { arena_id } = req.params;
|
||||
|
||||
// Validate UUID format
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(arena_id)) {
|
||||
// GET /api/battles?project_id= — list battles, most-recent-first
|
||||
app.get('/api/battles', async (req, reply) => {
|
||||
const parsed = ListBattlesQuery.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid arena_id format' };
|
||||
return { error: 'invalid query', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const tasks = await sql`
|
||||
SELECT id, project_id, state, input, output_summary, agent, model, mode_id, thinking_option_id, execution_path, session_id, started_at, ended_at, created_at, arena_id
|
||||
FROM tasks
|
||||
WHERE arena_id = ${arena_id}
|
||||
ORDER BY created_at
|
||||
const battles = await sql`
|
||||
SELECT id, project_id, battle_type, prompt, status,
|
||||
winner_contestant_id, results_path, error,
|
||||
created_at, updated_at
|
||||
FROM battles
|
||||
WHERE project_id = ${parsed.data.project_id}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
if (tasks.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'arena not found' };
|
||||
}
|
||||
|
||||
return { arena_id, tasks };
|
||||
return { battles };
|
||||
});
|
||||
|
||||
// POST /api/arena/:arena_id/select/:task_id — mark the winner
|
||||
app.post<{ Params: { arena_id: string; task_id: string } }>(
|
||||
'/api/arena/:arena_id/select/:task_id',
|
||||
async (req, reply) => {
|
||||
const { arena_id, task_id } = req.params;
|
||||
|
||||
// Verify the task belongs to this arena
|
||||
const rows = await sql<{ id: string; state: string; arena_id: string | null }[]>`
|
||||
SELECT id, state, arena_id FROM tasks WHERE id = ${task_id}
|
||||
`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'task not found' };
|
||||
}
|
||||
|
||||
const task = rows[0]!;
|
||||
if (task.arena_id !== arena_id) {
|
||||
reply.code(409);
|
||||
return { error: 'task does not belong to this arena' };
|
||||
}
|
||||
|
||||
// Mark as selected via output_summary prefix (lightweight — no schema change)
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET output_summary = COALESCE('[SELECTED] ' || output_summary, '[SELECTED]')
|
||||
WHERE id = ${task_id}
|
||||
`;
|
||||
|
||||
return { selected: true, task_id, arena_id };
|
||||
// GET /api/battles/:id — one battle + its contestants + cross-examinations
|
||||
app.get<{ Params: { id: string } }>('/api/battles/:id', async (req, reply) => {
|
||||
const parsedId = UuidParam.safeParse(req.params.id);
|
||||
if (!parsedId.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid id' };
|
||||
}
|
||||
);
|
||||
const id = parsedId.data;
|
||||
|
||||
const [battle] = await sql<{
|
||||
id: string;
|
||||
project_id: string;
|
||||
battle_type: string;
|
||||
prompt: string;
|
||||
status: string;
|
||||
winner_contestant_id: string | null;
|
||||
results_path: string | null;
|
||||
error: string | null;
|
||||
created_at: unknown;
|
||||
updated_at: unknown;
|
||||
}[]>`
|
||||
SELECT id, project_id, battle_type, prompt, status,
|
||||
winner_contestant_id, results_path, error,
|
||||
created_at, updated_at
|
||||
FROM battles WHERE id = ${id}
|
||||
`;
|
||||
|
||||
if (!battle) {
|
||||
reply.code(404);
|
||||
return { error: 'battle not found' };
|
||||
}
|
||||
|
||||
const contestants = await sql`
|
||||
SELECT id, battle_id, identity, model, lane, task_id, worktree_id,
|
||||
status, duration_ms, tokens_per_sec, cost_tokens, token_breakdown, result_path, error,
|
||||
created_at, updated_at
|
||||
FROM contestants
|
||||
WHERE battle_id = ${id}
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
|
||||
const crossExaminations = await sql`
|
||||
SELECT id, battle_id, identity, model, verdict, created_at
|
||||
FROM cross_examinations
|
||||
WHERE battle_id = ${id}
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
|
||||
return { battle, contestants, cross_examinations: crossExaminations };
|
||||
});
|
||||
|
||||
// POST /api/battles/:id/stop — cancel a running battle
|
||||
app.post<{ Params: { id: string } }>('/api/battles/:id/stop', async (req, reply) => {
|
||||
const parsedId = UuidParam.safeParse(req.params.id);
|
||||
if (!parsedId.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid id' };
|
||||
}
|
||||
const id = parsedId.data;
|
||||
|
||||
const [row] = await sql<{ id: string; status: string }[]>`
|
||||
SELECT id, status FROM battles WHERE id = ${id}
|
||||
`;
|
||||
if (!row) {
|
||||
reply.code(404);
|
||||
return { error: 'battle not found' };
|
||||
}
|
||||
if (row.status !== 'running') {
|
||||
reply.code(409);
|
||||
return { error: `cannot stop battle in status '${row.status}'` };
|
||||
}
|
||||
|
||||
const { cancelled, taskIds } = await battleRunner.cancelBattle(id);
|
||||
if (!cancelled) {
|
||||
reply.code(409);
|
||||
return { error: 'battle is no longer running' };
|
||||
}
|
||||
|
||||
// Abort any in-flight dispatcher tasks (cloud contestants running externally).
|
||||
for (const taskId of taskIds) {
|
||||
cancelExternal(taskId);
|
||||
}
|
||||
|
||||
return { cancelled: true };
|
||||
});
|
||||
|
||||
// GET /api/battles/:id/analysis — read analysis.md from the battle's results_path
|
||||
app.get<{ Params: { id: string } }>('/api/battles/:id/analysis', async (req, reply) => {
|
||||
const parsedId = UuidParam.safeParse(req.params.id);
|
||||
if (!parsedId.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid id' };
|
||||
}
|
||||
const id = parsedId.data;
|
||||
|
||||
const [row] = await sql<{ results_path: string | null }[]>`
|
||||
SELECT results_path FROM battles WHERE id = ${id}
|
||||
`;
|
||||
if (!row) {
|
||||
reply.code(404);
|
||||
return { error: 'battle not found' };
|
||||
}
|
||||
if (!row.results_path) {
|
||||
reply.code(404);
|
||||
return { error: 'analysis not ready' };
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await readFile(join(row.results_path, 'analysis.md'), 'utf8');
|
||||
return { text };
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { error: 'analysis not ready' };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/battles/:id/analyze — trigger or re-trigger analysis
|
||||
app.post<{ Params: { id: string } }>('/api/battles/:id/analyze', async (req, reply) => {
|
||||
const parsedId = UuidParam.safeParse(req.params.id);
|
||||
if (!parsedId.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid id' };
|
||||
}
|
||||
const id = parsedId.data;
|
||||
|
||||
const [row] = await sql<{ id: string; status: string }[]>`
|
||||
SELECT id, status FROM battles WHERE id = ${id}
|
||||
`;
|
||||
if (!row) {
|
||||
reply.code(404);
|
||||
return { error: 'battle not found' };
|
||||
}
|
||||
if (row.status === 'running') {
|
||||
reply.code(409);
|
||||
return { error: 'battle is still running — wait for all contestants to finish' };
|
||||
}
|
||||
|
||||
const result = await battleRunner.triggerAnalysis(id);
|
||||
if (!result.triggered) {
|
||||
reply.code(404);
|
||||
return { error: 'battle not found' };
|
||||
}
|
||||
|
||||
reply.code(202);
|
||||
return { triggered: true };
|
||||
});
|
||||
|
||||
// PATCH /api/battles/:id/winner — manually set or clear the winner.
|
||||
// Validates the contestant belongs to the battle; publishes battle_updated so
|
||||
// the pane badge reflects the override immediately. Human is authoritative.
|
||||
app.patch<{ Params: { id: string } }>('/api/battles/:id/winner', async (req, reply) => {
|
||||
const parsedId = UuidParam.safeParse(req.params.id);
|
||||
if (!parsedId.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid id' };
|
||||
}
|
||||
|
||||
const parsed = SetWinnerBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const result = await battleRunner.setWinner(parsedId.data, parsed.data.winner_contestant_id);
|
||||
if (!result.ok) {
|
||||
if (result.notFound) { reply.code(404); return { error: 'battle not found' }; }
|
||||
if (result.invalidContestant) { reply.code(422); return { error: 'contestant not found in this battle' }; }
|
||||
reply.code(500); return { error: 'unknown error' };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// GET /api/battles/:id/contestants/:cid/diff — read the diff.patch for a coding contestant.
|
||||
app.get<{ Params: { id: string; cid: string } }>('/api/battles/:id/contestants/:cid/diff', async (req, reply) => {
|
||||
const parsedId = UuidParam.safeParse(req.params.id);
|
||||
const parsedCid = UuidParam.safeParse(req.params.cid);
|
||||
if (!parsedId.success || !parsedCid.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid id' };
|
||||
}
|
||||
|
||||
const [contestant] = await sql<{ result_path: string | null }[]>`
|
||||
SELECT result_path FROM contestants
|
||||
WHERE id = ${parsedCid.data} AND battle_id = ${parsedId.data}
|
||||
`;
|
||||
if (!contestant) {
|
||||
reply.code(404);
|
||||
return { error: 'contestant not found' };
|
||||
}
|
||||
if (!contestant.result_path) {
|
||||
reply.code(404);
|
||||
return { error: 'diff not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await readFile(join(contestant.result_path, 'diff.patch'), 'utf8');
|
||||
return { diff: text };
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { error: 'diff not available' };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/battles/:id/cross-examine — start a cross-examination
|
||||
app.post<{ Params: { id: string } }>('/api/battles/:id/cross-examine', async (req, reply) => {
|
||||
const parsedId = UuidParam.safeParse(req.params.id);
|
||||
if (!parsedId.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid id' };
|
||||
}
|
||||
const id = parsedId.data;
|
||||
|
||||
const parsed = CrossExamineBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const [row] = await sql<{ id: string; status: string }[]>`
|
||||
SELECT id, status FROM battles WHERE id = ${id}
|
||||
`;
|
||||
if (!row) {
|
||||
reply.code(404);
|
||||
return { error: 'battle not found' };
|
||||
}
|
||||
if (row.status === 'running') {
|
||||
reply.code(409);
|
||||
return { error: 'battle is still running — cross-examine after all contestants finish' };
|
||||
}
|
||||
|
||||
const { crossExamId } = await battleRunner.startCrossExam(id, {
|
||||
identity: parsed.data.identity,
|
||||
model: parsed.data.model,
|
||||
});
|
||||
|
||||
reply.code(202);
|
||||
return { cross_exam_id: crossExamId };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import { resolveChatId } from './chat-resolve.js';
|
||||
import { asPermissionMode } from '../services/tools/types.js';
|
||||
|
||||
const AnswerUserInputBody = z.object({
|
||||
tool_call_id: z.string().min(1),
|
||||
@@ -43,7 +44,13 @@ const SendBody = z.object({
|
||||
});
|
||||
|
||||
interface InferenceApi {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
enqueue: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
user: string,
|
||||
permissionMode?: 'plan' | 'ask' | 'bypass',
|
||||
) => void;
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
@@ -245,7 +252,16 @@ export function registerMessageRoutes(
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
|
||||
// Native BooCode permission gate (plan/ask/bypass) — threaded into the
|
||||
// write-tool context so create/edit/delete and apply_pending honor it.
|
||||
// Plan = read-only, Ask = stage to the queue (agent can't self-apply),
|
||||
// Bypass = apply each write immediately. Other mode ids (e.g. an external
|
||||
// fallback's native mode) leave the gate undefined = legacy behavior.
|
||||
req.log.info(
|
||||
{ provider, mode_id, permissionMode: asPermissionMode(mode_id), chatId },
|
||||
'native enqueue — permission gate',
|
||||
);
|
||||
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default', asPermissionMode(mode_id));
|
||||
|
||||
reply.code(202);
|
||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||
|
||||
@@ -54,9 +54,6 @@ DO $$ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v2.0.5: arena support — group tasks into competitive arenas.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS arena_id UUID;
|
||||
|
||||
-- Human inbox: tasks needing attention
|
||||
CREATE OR REPLACE VIEW human_inbox AS
|
||||
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||
@@ -81,6 +78,7 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
|
||||
DROP VIEW IF EXISTS human_inbox;
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS feature_values;
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS worktree_path;
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;
|
||||
CREATE OR REPLACE VIEW human_inbox AS
|
||||
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||
|
||||
@@ -157,7 +155,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path)
|
||||
DROP TABLE IF EXISTS session_worktrees;
|
||||
|
||||
-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and
|
||||
-- skills route set it from the frontend tab; session-less creators (arena, MCP,
|
||||
-- skills route set it from the frontend tab; session-less creators (MCP,
|
||||
-- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL;
|
||||
|
||||
@@ -271,7 +269,7 @@ ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_backend_chk
|
||||
CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk'));
|
||||
|
||||
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
|
||||
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same
|
||||
-- new_task tool, MCP server) fires pg_notify('tasks_new') in the same
|
||||
-- transaction, so the dispatcher reacts immediately instead of waiting for the
|
||||
-- fallback poll. Postgres holds the notification until COMMIT, so the listener
|
||||
-- always sees the committed row. A trigger covers all insert paths with no
|
||||
@@ -357,3 +355,86 @@ DO $$ BEGIN
|
||||
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Arena: battles + contestants + cross_examinations.
|
||||
-- project_id carries no FK (matches tasks.project_id + flow_runs.project_id convention).
|
||||
-- winner_contestant_id FK is deferred (forward reference): added via guarded ALTER below.
|
||||
CREATE TABLE IF NOT EXISTS battles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL,
|
||||
battle_type TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
winner_contestant_id UUID,
|
||||
results_path TEXT,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT battles_type_chk CHECK (battle_type IN ('coding', 'qa')),
|
||||
CONSTRAINT battles_status_chk CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contestants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
battle_id UUID NOT NULL REFERENCES battles(id) ON DELETE CASCADE,
|
||||
identity TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
lane TEXT NOT NULL,
|
||||
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
duration_ms INTEGER,
|
||||
tokens_per_sec DOUBLE PRECISION,
|
||||
cost_tokens INTEGER,
|
||||
result_path TEXT,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT contestants_lane_chk CHECK (lane IN ('local', 'cloud')),
|
||||
CONSTRAINT contestants_status_chk CHECK (status IN ('queued', 'running', 'done', 'error')),
|
||||
UNIQUE (battle_id, identity, model)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cross_examinations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
battle_id UUID NOT NULL REFERENCES battles(id) ON DELETE CASCADE,
|
||||
identity TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
verdict TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
-- Add the winner FK now that contestants exists.
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'battles_winner_contestant_id_fkey') THEN
|
||||
ALTER TABLE battles ADD CONSTRAINT battles_winner_contestant_id_fkey
|
||||
FOREIGN KEY (winner_contestant_id) REFERENCES contestants(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- battles query (GET /api/battles?project_id=).
|
||||
CREATE INDEX IF NOT EXISTS battles_project_created_idx ON battles(project_id, created_at DESC);
|
||||
|
||||
-- Lane-scheduler advance scans (contestants WHERE battle_id = ? AND status = ?).
|
||||
CREATE INDEX IF NOT EXISTS contestants_battle_status_idx ON contestants(battle_id, status);
|
||||
|
||||
-- onTaskTerminal callback: look up the contestant owning a completed task.
|
||||
CREATE INDEX IF NOT EXISTS contestants_task_id_idx ON contestants(task_id);
|
||||
|
||||
-- Cross-examination listing per battle.
|
||||
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id);
|
||||
|
||||
-- TokenScope: per-category token breakdown on arena contestants and tasks.
|
||||
ALTER TABLE contestants ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
|
||||
|
||||
-- Orchestrator flow step events (append-only event log for resume/replay).
|
||||
CREATE TABLE IF NOT EXISTS flow_step_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
run_id UUID NOT NULL REFERENCES flow_runs(id),
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
event VARCHAR(32) NOT NULL,
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);
|
||||
|
||||
254
apps/coder/src/services/__tests__/arena-analyzer-helpers.test.ts
Normal file
254
apps/coder/src/services/__tests__/arena-analyzer-helpers.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildDigestPrompt,
|
||||
buildJudgePrompt,
|
||||
buildCrossExamPrompt,
|
||||
extractWinner,
|
||||
shouldNameWinner,
|
||||
type ContestantDigest,
|
||||
type ContestantDigestInput,
|
||||
} from '../arena-analyzer-helpers.js';
|
||||
|
||||
// ─── shouldNameWinner ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('shouldNameWinner', () => {
|
||||
it('returns false with 0 succeeded contestants', () => {
|
||||
expect(shouldNameWinner(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false with exactly 1 succeeded contestant', () => {
|
||||
expect(shouldNameWinner(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true with exactly 2 succeeded contestants', () => {
|
||||
expect(shouldNameWinner(2)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true with more than 2 succeeded contestants', () => {
|
||||
expect(shouldNameWinner(3)).toBe(true);
|
||||
expect(shouldNameWinner(6)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractWinner ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('extractWinner', () => {
|
||||
it('extracts identity and model from a WINNER: line', () => {
|
||||
const output = 'Some analysis\n\nWINNER: claude/opus-4-5\n\nMore text.';
|
||||
expect(extractWinner(output)).toEqual({ identity: 'claude', model: 'opus-4-5' });
|
||||
});
|
||||
|
||||
it('is case-insensitive for the WINNER keyword', () => {
|
||||
expect(extractWinner('winner: boocode/qwen3.6-35b')).toEqual({
|
||||
identity: 'boocode',
|
||||
model: 'qwen3.6-35b',
|
||||
});
|
||||
expect(extractWinner('Winner: opencode/some-model')).toEqual({
|
||||
identity: 'opencode',
|
||||
model: 'some-model',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when NO_WINNER is declared', () => {
|
||||
expect(extractWinner('WINNER: NO_WINNER')).toBeNull();
|
||||
expect(extractWinner('winner: no_winner')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no WINNER line is present', () => {
|
||||
expect(extractWinner('Just some analysis text with no verdict.')).toBeNull();
|
||||
expect(extractWinner('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the WINNER line has no slash separator', () => {
|
||||
expect(extractWinner('WINNER: justidentity')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the WINNER line is empty after the colon', () => {
|
||||
expect(extractWinner('WINNER:')).toBeNull();
|
||||
expect(extractWinner('WINNER: ')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles leading and trailing whitespace around the slash parts', () => {
|
||||
const result = extractWinner('WINNER: claude / opus-4-5 ');
|
||||
expect(result).toEqual({ identity: 'claude', model: 'opus-4-5' });
|
||||
});
|
||||
|
||||
it('picks the first WINNER line when multiple are present', () => {
|
||||
const output = 'WINNER: claude/opus-4-5\nWINNER: opencode/other-model';
|
||||
expect(extractWinner(output)).toEqual({ identity: 'claude', model: 'opus-4-5' });
|
||||
});
|
||||
|
||||
it('handles model names that contain slashes by splitting at the first slash only', () => {
|
||||
// edge case: model name with a slash — should still split at first slash
|
||||
// identity = 'native', model = 'llama-swap/qwen3.6'
|
||||
const result = extractWinner('WINNER: native/llama-swap/qwen3.6');
|
||||
expect(result).toEqual({ identity: 'native', model: 'llama-swap/qwen3.6' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildDigestPrompt ────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildDigestPrompt', () => {
|
||||
const base: ContestantDigestInput = {
|
||||
identity: 'claude',
|
||||
model: 'opus-4-5',
|
||||
resultMd: '# Output\n\nSome result content.',
|
||||
benchmarkLine: '12000ms',
|
||||
};
|
||||
|
||||
it('returns an object with non-empty system and user strings', () => {
|
||||
const { system, user } = buildDigestPrompt(base);
|
||||
expect(system.length).toBeGreaterThan(0);
|
||||
expect(user.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes the contestant identity and model in the user prompt', () => {
|
||||
const { user } = buildDigestPrompt(base);
|
||||
expect(user).toContain('claude');
|
||||
expect(user).toContain('opus-4-5');
|
||||
});
|
||||
|
||||
it('includes the benchmark line in the user prompt', () => {
|
||||
const { user } = buildDigestPrompt(base);
|
||||
expect(user).toContain('12000ms');
|
||||
});
|
||||
|
||||
it('includes the result.md content in the user prompt', () => {
|
||||
const { user } = buildDigestPrompt(base);
|
||||
expect(user).toContain('Some result content.');
|
||||
});
|
||||
|
||||
it('includes the diff.patch when provided', () => {
|
||||
const input: ContestantDigestInput = { ...base, diffPatch: '--- a/foo.ts\n+++ b/foo.ts\n+added' };
|
||||
const { user } = buildDigestPrompt(input);
|
||||
expect(user).toContain('added');
|
||||
expect(user).toContain('```diff');
|
||||
});
|
||||
|
||||
it('omits the diff section when diffPatch is undefined', () => {
|
||||
const { user } = buildDigestPrompt(base);
|
||||
expect(user).not.toContain('```diff');
|
||||
});
|
||||
|
||||
it('truncates resultMd longer than 8000 characters', () => {
|
||||
const longResult = 'x'.repeat(10_000);
|
||||
const { user } = buildDigestPrompt({ ...base, resultMd: longResult });
|
||||
// The truncated content must not exceed 8000 chars in the sliced section.
|
||||
// We just check the total user string doesn't balloon unreasonably.
|
||||
expect(user.length).toBeLessThan(15_000);
|
||||
});
|
||||
|
||||
it('truncates diffPatch longer than 5000 characters', () => {
|
||||
const longDiff = '+' + 'x'.repeat(10_000);
|
||||
const { user } = buildDigestPrompt({ ...base, diffPatch: longDiff });
|
||||
expect(user.length).toBeLessThan(16_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildJudgePrompt ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildJudgePrompt', () => {
|
||||
const digests: ContestantDigest[] = [
|
||||
{ identity: 'claude', model: 'opus-4-5', digest: 'Good result.', benchmarkLine: '5000ms' },
|
||||
{ identity: 'opencode', model: 'qwen3.6', digest: 'Decent result.', benchmarkLine: '8000ms' },
|
||||
];
|
||||
|
||||
it('includes the original prompt in the user section', () => {
|
||||
const { user } = buildJudgePrompt('Write a sorting algorithm', digests);
|
||||
expect(user).toContain('Write a sorting algorithm');
|
||||
});
|
||||
|
||||
it('includes each contestant heading in the user section', () => {
|
||||
const { user } = buildJudgePrompt('prompt', digests);
|
||||
expect(user).toContain('claude');
|
||||
expect(user).toContain('opus-4-5');
|
||||
expect(user).toContain('opencode');
|
||||
expect(user).toContain('qwen3.6');
|
||||
});
|
||||
|
||||
it('includes each contestant digest text', () => {
|
||||
const { user } = buildJudgePrompt('prompt', digests);
|
||||
expect(user).toContain('Good result.');
|
||||
expect(user).toContain('Decent result.');
|
||||
});
|
||||
|
||||
it('instructs the model to name a WINNER when 2+ digests are provided', () => {
|
||||
const { system } = buildJudgePrompt('prompt', digests);
|
||||
expect(system).toContain('WINNER:');
|
||||
});
|
||||
|
||||
it('instructs the model NOT to name a winner when fewer than 2 digests are provided', () => {
|
||||
const oneDigest = digests.slice(0, 1);
|
||||
const { system } = buildJudgePrompt('prompt', oneDigest);
|
||||
expect(system).toContain('NO_WINNER');
|
||||
expect(system).not.toContain('WINNER: <identity>');
|
||||
});
|
||||
|
||||
it('instructs NO_WINNER when digests list is empty', () => {
|
||||
const { system } = buildJudgePrompt('prompt', []);
|
||||
expect(system).toContain('NO_WINNER');
|
||||
});
|
||||
|
||||
it('truncates originalPrompt longer than 2000 characters', () => {
|
||||
const longPrompt = 'p'.repeat(5_000);
|
||||
const { user } = buildJudgePrompt(longPrompt, digests);
|
||||
// Should not contain more than 2000 chars of the prompt.
|
||||
const promptSection = user.split('# Contestant Digests')[0] ?? '';
|
||||
expect(promptSection.length).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildCrossExamPrompt ─────────────────────────────────────────────────────
|
||||
|
||||
describe('buildCrossExamPrompt', () => {
|
||||
const digests: ContestantDigest[] = [
|
||||
{ identity: 'claude', model: 'opus-4-5', digest: 'Strong result.', benchmarkLine: '5000ms' },
|
||||
{ identity: 'boocode', model: 'qwen3.6-35b', digest: 'Decent result.', benchmarkLine: '12000ms' },
|
||||
];
|
||||
|
||||
const baseOpts = {
|
||||
originalPrompt: 'Write a sorting algorithm.',
|
||||
digests,
|
||||
analysisContent: '# Arena Analysis\n\nClaude did better.\n\nWINNER: claude/opus-4-5',
|
||||
proposedWinner: 'claude/opus-4-5',
|
||||
examinerIdentity: 'goose',
|
||||
examinerModel: 'gpt-4o',
|
||||
};
|
||||
|
||||
it('includes the examiner identity and model in the system prompt', () => {
|
||||
const { system } = buildCrossExamPrompt(baseOpts);
|
||||
expect(system).toContain('goose');
|
||||
expect(system).toContain('gpt-4o');
|
||||
});
|
||||
|
||||
it('includes the original prompt in the user section', () => {
|
||||
const { user } = buildCrossExamPrompt(baseOpts);
|
||||
expect(user).toContain('Write a sorting algorithm.');
|
||||
});
|
||||
|
||||
it('includes each contestant digest', () => {
|
||||
const { user } = buildCrossExamPrompt(baseOpts);
|
||||
expect(user).toContain('Strong result.');
|
||||
expect(user).toContain('Decent result.');
|
||||
});
|
||||
|
||||
it('includes the proposed analysis content', () => {
|
||||
const { user } = buildCrossExamPrompt(baseOpts);
|
||||
expect(user).toContain('Claude did better.');
|
||||
});
|
||||
|
||||
it('includes the proposed winner when set', () => {
|
||||
const { user } = buildCrossExamPrompt(baseOpts);
|
||||
expect(user).toContain('claude/opus-4-5');
|
||||
});
|
||||
|
||||
it('notes that no winner was proposed when proposedWinner is null', () => {
|
||||
const { user } = buildCrossExamPrompt({ ...baseOpts, proposedWinner: null });
|
||||
expect(user).toContain('No winner was proposed');
|
||||
});
|
||||
|
||||
it('instructs the examiner to provide a VERDICT line', () => {
|
||||
const { system } = buildCrossExamPrompt(baseOpts);
|
||||
expect(system).toContain('VERDICT:');
|
||||
});
|
||||
});
|
||||
350
apps/coder/src/services/__tests__/arena-decisions.test.ts
Normal file
350
apps/coder/src/services/__tests__/arena-decisions.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
classifyLane,
|
||||
nextLocalContestant,
|
||||
isBattleComplete,
|
||||
computeBenchmark,
|
||||
sanitizeSlug,
|
||||
buildBattleSlug,
|
||||
buildContestantDir,
|
||||
reconcileContestantResume,
|
||||
reconcileContestants,
|
||||
type ContestantSlot,
|
||||
} from '../arena-decisions.js';
|
||||
|
||||
// Local models = what the llama-swap server actually serves.
|
||||
const LOCAL_MODELS: ReadonlySet<string> = new Set([
|
||||
'qwen3.6-35b-a3b-mxfp4',
|
||||
'qwen2.5-coder-7b',
|
||||
]);
|
||||
|
||||
// ─── classifyLane ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('classifyLane', () => {
|
||||
it('classifies qa battles as local regardless of identity or model', () => {
|
||||
expect(classifyLane('qa', 'boocode', 'qwen3.6-35b-a3b-mxfp4', LOCAL_MODELS)).toBe('local');
|
||||
expect(classifyLane('qa', 'claude', 'claude-opus-4-5', LOCAL_MODELS)).toBe('local');
|
||||
expect(classifyLane('qa', 'Debugger', 'cloud-model', new Set())).toBe('local');
|
||||
expect(classifyLane('qa', 'opencode', 'any-model', LOCAL_MODELS)).toBe('local');
|
||||
});
|
||||
|
||||
it('classifies coding contestants as local when model is in localModels', () => {
|
||||
expect(classifyLane('coding', 'boocode', 'qwen3.6-35b-a3b-mxfp4', LOCAL_MODELS)).toBe('local');
|
||||
expect(classifyLane('coding', 'opencode', 'qwen3.6-35b-a3b-mxfp4', LOCAL_MODELS)).toBe('local');
|
||||
expect(classifyLane('coding', 'qwen', 'qwen2.5-coder-7b', LOCAL_MODELS)).toBe('local');
|
||||
});
|
||||
|
||||
it('classifies coding contestants as cloud when model is not in localModels', () => {
|
||||
expect(classifyLane('coding', 'claude', 'claude-opus-4-5', LOCAL_MODELS)).toBe('cloud');
|
||||
expect(classifyLane('coding', 'opencode', 'claude-opus-4-5', LOCAL_MODELS)).toBe('cloud');
|
||||
expect(classifyLane('coding', 'goose', 'gpt-4o', LOCAL_MODELS)).toBe('cloud');
|
||||
expect(classifyLane('coding', 'qwen', 'unknown-remote-model', LOCAL_MODELS)).toBe('cloud');
|
||||
});
|
||||
|
||||
it('uses the injected localModels set, not a hardcoded list', () => {
|
||||
const custom = new Set(['my-local-model']);
|
||||
expect(classifyLane('coding', 'any-agent', 'my-local-model', custom)).toBe('local');
|
||||
expect(classifyLane('coding', 'boocode', 'other-model', custom)).toBe('cloud');
|
||||
});
|
||||
|
||||
it('defaults to cloud for an empty localModels set', () => {
|
||||
expect(classifyLane('coding', 'boocode', 'qwen3.6-35b-a3b-mxfp4', new Set())).toBe('cloud');
|
||||
expect(classifyLane('coding', 'native', 'any-local-model', new Set())).toBe('cloud');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── nextLocalContestant ─────────────────────────────────────────────────────
|
||||
|
||||
describe('nextLocalContestant', () => {
|
||||
it('returns null for an empty list', () => {
|
||||
expect(nextLocalContestant([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no local contestants are queued', () => {
|
||||
const slots: ContestantSlot[] = [
|
||||
{ id: 'c1', lane: 'local', status: 'running' },
|
||||
{ id: 'c2', lane: 'cloud', status: 'queued' },
|
||||
];
|
||||
expect(nextLocalContestant(slots)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the first queued local contestant in order', () => {
|
||||
const slots: ContestantSlot[] = [
|
||||
{ id: 'c1', lane: 'local', status: 'done' },
|
||||
{ id: 'c2', lane: 'local', status: 'queued' },
|
||||
{ id: 'c3', lane: 'local', status: 'queued' },
|
||||
];
|
||||
expect(nextLocalContestant(slots)).toBe('c2');
|
||||
});
|
||||
|
||||
it('skips done/error local contestants and cloud contestants', () => {
|
||||
const slots: ContestantSlot[] = [
|
||||
{ id: 'c1', lane: 'cloud', status: 'queued' },
|
||||
{ id: 'c2', lane: 'local', status: 'error' },
|
||||
{ id: 'c3', lane: 'local', status: 'queued' },
|
||||
];
|
||||
expect(nextLocalContestant(slots)).toBe('c3');
|
||||
});
|
||||
|
||||
it('returns null when all local contestants are done or error', () => {
|
||||
const slots: ContestantSlot[] = [
|
||||
{ id: 'c1', lane: 'local', status: 'done' },
|
||||
{ id: 'c2', lane: 'local', status: 'error' },
|
||||
];
|
||||
expect(nextLocalContestant(slots)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isBattleComplete ────────────────────────────────────────────────────────
|
||||
|
||||
describe('isBattleComplete', () => {
|
||||
it('returns false for an empty list', () => {
|
||||
expect(isBattleComplete([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when all contestants are done', () => {
|
||||
expect(isBattleComplete([{ status: 'done' }, { status: 'done' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when all contestants are error', () => {
|
||||
expect(isBattleComplete([{ status: 'error' }, { status: 'error' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a mixed done/error result', () => {
|
||||
expect(isBattleComplete([{ status: 'done' }, { status: 'error' }, { status: 'done' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false while any contestant is still running', () => {
|
||||
expect(isBattleComplete([{ status: 'done' }, { status: 'running' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false while any contestant is still queued', () => {
|
||||
expect(isBattleComplete([{ status: 'done' }, { status: 'queued' }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── computeBenchmark ────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeBenchmark', () => {
|
||||
const t0 = new Date('2026-06-06T10:00:00.000Z');
|
||||
const t1 = new Date('2026-06-06T10:00:05.000Z'); // +5 000ms
|
||||
|
||||
it('computes duration in ms for both lanes', () => {
|
||||
const local = computeBenchmark(t0, t1, 100, 'local');
|
||||
expect(local.durationMs).toBe(5000);
|
||||
const cloud = computeBenchmark(t0, t1, null, 'cloud');
|
||||
expect(cloud.durationMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('computes tokens/sec for local lane when costTokens is known', () => {
|
||||
const bench = computeBenchmark(t0, t1, 500, 'local');
|
||||
expect(bench.tokensPerSec).toBeCloseTo(100, 5); // 500 / 5 = 100 tok/s
|
||||
});
|
||||
|
||||
it('omits tokens/sec for cloud lane regardless of costTokens', () => {
|
||||
const bench = computeBenchmark(t0, t1, 500, 'cloud');
|
||||
expect(bench.tokensPerSec).toBeNull();
|
||||
});
|
||||
|
||||
it('omits tokens/sec for local lane when costTokens is null', () => {
|
||||
const bench = computeBenchmark(t0, t1, null, 'local');
|
||||
expect(bench.tokensPerSec).toBeNull();
|
||||
});
|
||||
|
||||
it('returns durationMs = 0 and null tokensPerSec when timestamps are equal', () => {
|
||||
const bench = computeBenchmark(t0, t0, 100, 'local');
|
||||
expect(bench.durationMs).toBe(0);
|
||||
expect(bench.tokensPerSec).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps negative duration to 0 (clock skew)', () => {
|
||||
const bench = computeBenchmark(t1, t0, 50, 'local');
|
||||
expect(bench.durationMs).toBe(0);
|
||||
expect(bench.tokensPerSec).toBeNull();
|
||||
});
|
||||
|
||||
it('includes token breakdown when provided', () => {
|
||||
const breakdown = {
|
||||
system: 10,
|
||||
user: 20,
|
||||
assistant: 30,
|
||||
tools: 40,
|
||||
reasoning: 5,
|
||||
total: 105,
|
||||
};
|
||||
const bench = computeBenchmark(t0, t1, 500, 'local', breakdown);
|
||||
expect(bench.tokenBreakdown).toEqual(breakdown);
|
||||
});
|
||||
|
||||
it('defaults token breakdown to null when omitted', () => {
|
||||
const bench = computeBenchmark(t0, t1, 500, 'local');
|
||||
expect(bench.tokenBreakdown).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sanitizeSlug ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('sanitizeSlug', () => {
|
||||
it('lowercases and preserves alphanumeric + hyphens', () => {
|
||||
expect(sanitizeSlug('claude')).toBe('claude');
|
||||
expect(sanitizeSlug('claude-opus-4-5')).toBe('claude-opus-4-5');
|
||||
});
|
||||
|
||||
it('replaces spaces and special characters with hyphens', () => {
|
||||
expect(sanitizeSlug('Code Reviewer')).toBe('code-reviewer');
|
||||
expect(sanitizeSlug('native/boocode')).toBe('native-boocode');
|
||||
expect(sanitizeSlug('qwen2.5-coder-35b')).toBe('qwen2-5-coder-35b');
|
||||
});
|
||||
|
||||
it('collapses consecutive non-alphanumeric runs to a single hyphen', () => {
|
||||
expect(sanitizeSlug('foo bar---baz')).toBe('foo-bar-baz');
|
||||
});
|
||||
|
||||
it('strips leading and trailing hyphens', () => {
|
||||
expect(sanitizeSlug('---foo---')).toBe('foo');
|
||||
});
|
||||
|
||||
it('truncates to 64 characters', () => {
|
||||
const long = 'a'.repeat(100);
|
||||
expect(sanitizeSlug(long).length).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildBattleSlug ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildBattleSlug', () => {
|
||||
it('builds a deterministic dated slug from id, type, and createdAt', () => {
|
||||
const id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
const createdAt = new Date('2026-06-06T12:00:00.000Z');
|
||||
const slug = buildBattleSlug(id, 'coding', createdAt);
|
||||
expect(slug).toBe('2026-06-06-coding-a1b2c3d4');
|
||||
});
|
||||
|
||||
it('includes the battle type in the slug', () => {
|
||||
const id = 'aaaaaaaa-0000-0000-0000-000000000000';
|
||||
const createdAt = new Date('2026-01-01T00:00:00.000Z');
|
||||
expect(buildBattleSlug(id, 'qa', createdAt)).toContain('-qa-');
|
||||
expect(buildBattleSlug(id, 'coding', createdAt)).toContain('-coding-');
|
||||
});
|
||||
|
||||
it('uses the first 8 hex chars of the uuid (dashes stripped)', () => {
|
||||
const id = 'deadbeef-0000-0000-0000-000000000000';
|
||||
const slug = buildBattleSlug(id, 'coding', new Date('2026-06-06T00:00:00Z'));
|
||||
expect(slug.endsWith('-deadbeef')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildContestantDir ──────────────────────────────────────────────────────
|
||||
|
||||
describe('buildContestantDir', () => {
|
||||
it('joins sanitized identity and model with a hyphen', () => {
|
||||
expect(buildContestantDir('claude', 'claude-opus-4-5')).toBe('claude-claude-opus-4-5');
|
||||
});
|
||||
|
||||
it('sanitizes both parts independently', () => {
|
||||
expect(buildContestantDir('Code Reviewer', 'qwen2.5-35b')).toBe('code-reviewer-qwen2-5-35b');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reconcileContestantResume ───────────────────────────────────────────────
|
||||
|
||||
describe('reconcileContestantResume', () => {
|
||||
it('keeps non-running contestants regardless of task state', () => {
|
||||
for (const status of ['queued', 'done', 'error']) {
|
||||
expect(reconcileContestantResume(status, 'tid', 'completed')).toBe('keep');
|
||||
expect(reconcileContestantResume(status, null, null)).toBe('keep');
|
||||
}
|
||||
});
|
||||
|
||||
it('re-dispatches a running contestant with no task_id', () => {
|
||||
expect(reconcileContestantResume('running', null, null)).toBe('re-dispatch');
|
||||
});
|
||||
|
||||
it('re-dispatches a running contestant whose task row is absent', () => {
|
||||
expect(reconcileContestantResume('running', 'tid', null)).toBe('re-dispatch');
|
||||
});
|
||||
|
||||
it('marks done when the task completed before the terminal callback ran', () => {
|
||||
expect(reconcileContestantResume('running', 'tid', 'completed')).toBe('mark-done');
|
||||
});
|
||||
|
||||
it('marks error when the task failed', () => {
|
||||
expect(reconcileContestantResume('running', 'tid', 'failed')).toBe('mark-error');
|
||||
});
|
||||
|
||||
it('marks cancelled when the task was cancelled', () => {
|
||||
expect(reconcileContestantResume('running', 'tid', 'cancelled')).toBe('mark-cancelled');
|
||||
});
|
||||
|
||||
it('keeps a running contestant whose task is pending (dispatcher handles it)', () => {
|
||||
expect(reconcileContestantResume('running', 'tid', 'pending')).toBe('keep');
|
||||
});
|
||||
|
||||
it('re-dispatches when the task is stuck running (process died)', () => {
|
||||
expect(reconcileContestantResume('running', 'tid', 'running')).toBe('re-dispatch');
|
||||
});
|
||||
|
||||
it('re-dispatches when the task is blocked (permission dialog gone on restart)', () => {
|
||||
expect(reconcileContestantResume('running', 'tid', 'blocked')).toBe('re-dispatch');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reconcileContestants ────────────────────────────────────────────────────
|
||||
|
||||
describe('reconcileContestants', () => {
|
||||
it('returns one decision per contestant', () => {
|
||||
const contestants = [
|
||||
{ contestantId: 'c1', taskId: null, status: 'done' },
|
||||
{ contestantId: 'c2', taskId: 't1', status: 'running' },
|
||||
{ contestantId: 'c3', taskId: 't2', status: 'running' },
|
||||
];
|
||||
const taskStates = new Map([['t1', 'completed'], ['t2', 'running']]);
|
||||
const decisions = reconcileContestants(contestants, taskStates);
|
||||
expect(decisions).toHaveLength(3);
|
||||
expect(decisions[0]).toEqual({ contestantId: 'c1', action: 'keep' });
|
||||
expect(decisions[1]).toEqual({ contestantId: 'c2', action: 'mark-done' });
|
||||
expect(decisions[2]).toEqual({ contestantId: 'c3', action: 're-dispatch' });
|
||||
});
|
||||
|
||||
it('re-dispatches a running contestant whose taskId is absent from taskStates', () => {
|
||||
const contestants = [{ contestantId: 'c1', taskId: 'orphan', status: 'running' }];
|
||||
const decisions = reconcileContestants(contestants, new Map());
|
||||
expect(decisions[0]?.action).toBe('re-dispatch');
|
||||
});
|
||||
|
||||
it('re-dispatches a running contestant with null taskId', () => {
|
||||
const contestants = [{ contestantId: 'c1', taskId: null, status: 'running' }];
|
||||
const decisions = reconcileContestants(contestants, new Map());
|
||||
expect(decisions[0]?.action).toBe('re-dispatch');
|
||||
});
|
||||
|
||||
it('returns empty array for no contestants', () => {
|
||||
expect(reconcileContestants([], new Map())).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a running contestant whose task is pending', () => {
|
||||
const contestants = [{ contestantId: 'c1', taskId: 't1', status: 'running' }];
|
||||
const taskStates = new Map([['t1', 'pending']]);
|
||||
const decisions = reconcileContestants(contestants, taskStates);
|
||||
expect(decisions[0]?.action).toBe('keep');
|
||||
});
|
||||
|
||||
it('handles a mixed battle: done/queued kept, stale running re-dispatched', () => {
|
||||
const contestants = [
|
||||
{ contestantId: 'c1', taskId: 't1', status: 'done' },
|
||||
{ contestantId: 'c2', taskId: null, status: 'queued' },
|
||||
{ contestantId: 'c3', taskId: 't2', status: 'running' },
|
||||
{ contestantId: 'c4', taskId: 't3', status: 'running' },
|
||||
];
|
||||
const taskStates = new Map([
|
||||
['t1', 'completed'],
|
||||
['t2', 'running'], // stuck — process dead
|
||||
['t3', 'pending'], // dispatcher will handle
|
||||
]);
|
||||
const decisions = reconcileContestants(contestants, taskStates);
|
||||
expect(decisions.find((d) => d.contestantId === 'c1')?.action).toBe('keep');
|
||||
expect(decisions.find((d) => d.contestantId === 'c2')?.action).toBe('keep');
|
||||
expect(decisions.find((d) => d.contestantId === 'c3')?.action).toBe('re-dispatch');
|
||||
expect(decisions.find((d) => d.contestantId === 'c4')?.action).toBe('keep');
|
||||
});
|
||||
});
|
||||
@@ -161,6 +161,52 @@ describe('locateMatch — strategy 4: Levenshtein', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('locateMatch — strategy 4: fail-closed on ambiguity (corruption guard)', () => {
|
||||
it('refuses (ambiguous) when two equally-similar anchored blocks both clear the bar', () => {
|
||||
// The repetitive-file case that duplicated blocks: two blocks share the same
|
||||
// first+last anchor lines and their middle lines are EQUALLY similar to the
|
||||
// (drifted) needle. Tier 4 must refuse rather than splice over one of them.
|
||||
const content = [
|
||||
'const x = {',
|
||||
' total = aa;',
|
||||
'};',
|
||||
'const x = {',
|
||||
' total = bb;',
|
||||
'};',
|
||||
].join('\n');
|
||||
const needle = ['const x = {', ' total = ab;', '};'].join('\n');
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result.kind).toBe('ambiguous');
|
||||
});
|
||||
|
||||
it('refuses a below-threshold near-miss that the old 0.66 floor would have spliced', () => {
|
||||
// ~0.7 similar: under the raised 0.85 floor this is now not_found, so the
|
||||
// caller surfaces a correctable error instead of corrupting the file.
|
||||
const content = 'const grandTotalAmount = a + b;\n';
|
||||
const needle = 'const totalValue = a + b;';
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
|
||||
it('still matches a single genuine high-similarity drift uniquely', () => {
|
||||
const content = 'const total = sum + tax;\n';
|
||||
const needle = 'const totals = sum + tax;'; // one-char typo, ~0.96
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result.kind).toBe('fuzzy');
|
||||
const { start, end } = span(result);
|
||||
expect(content.slice(start, end)).toBe('const total = sum + tax;');
|
||||
});
|
||||
|
||||
it('requires an exact first+last line anchor for multi-line needles', () => {
|
||||
// First line drifted too far to anchor → no window is scored → not_found,
|
||||
// even though the middle lines are identical.
|
||||
const content = ['function compute() {', ' return a + b;', ' return done;', '}'].join('\n');
|
||||
const needle = ['totally different opener', ' return a + b;', '}'].join('\n');
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('locateMatch — edge cases', () => {
|
||||
it('returns not_found for an empty needle', () => {
|
||||
expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' });
|
||||
|
||||
@@ -83,6 +83,53 @@ describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () =>
|
||||
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
|
||||
});
|
||||
|
||||
it('re-emitted identical edits dedupe at queue and never duplicate on apply', async () => {
|
||||
// Regression: the 2-3x block-stamping corruption. An anchored insert queued
|
||||
// three times (a local model re-emitting the same tool call) must collapse to
|
||||
// ONE pending row and apply exactly once.
|
||||
await queueCreate(sql, testSessionId, null, 'dup.js', '<script>\nrender();\n', projectRoot)
|
||||
.then((c) => applyOne(sql, c.id, projectRoot));
|
||||
|
||||
const oldStr = '<script>';
|
||||
const newStr = '<script>\nconst recordFormats = ["gif"];';
|
||||
const a = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const b = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const c = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
// All three calls return the SAME pending row (deduped).
|
||||
expect(b.id).toBe(a.id);
|
||||
expect(c.id).toBe(a.id);
|
||||
|
||||
await applyOne(sql, a.id, projectRoot);
|
||||
let content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
|
||||
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
|
||||
// Even a fresh, separately-queued identical edit re-applied is a no-op, not a stamp.
|
||||
const again = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const res = await applyOne(sql, again.id, projectRoot);
|
||||
expect(res.success).toBe(true);
|
||||
content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
|
||||
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves CRLF line endings on edit', async () => {
|
||||
await queueCreate(sql, testSessionId, null, 'crlf.txt', 'line one\r\nline two\r\nline three\r\n', projectRoot)
|
||||
.then((c) => applyOne(sql, c.id, projectRoot));
|
||||
const edit = await queueEdit(sql, testSessionId, null, 'crlf.txt', 'line two', 'line TWO', projectRoot);
|
||||
const res = await applyOne(sql, edit.id, projectRoot);
|
||||
expect(res.success).toBe(true);
|
||||
const content = await readFile(resolve(testDir, 'crlf.txt'), 'utf8');
|
||||
expect(content).toBe('line one\r\nline TWO\r\nline three\r\n');
|
||||
});
|
||||
|
||||
it('refuses an edit that matches multiple locations instead of corrupting', async () => {
|
||||
await queueCreate(sql, testSessionId, null, 'ambig.js', 'x=1;\ny=2;\nx=1;\n', projectRoot)
|
||||
.then((ch) => applyOne(sql, ch.id, projectRoot));
|
||||
const edit = await queueEdit(sql, testSessionId, null, 'ambig.js', 'x=1;', 'x=9;', projectRoot);
|
||||
const res = await applyOne(sql, edit.id, projectRoot);
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/matches 2 locations/);
|
||||
});
|
||||
|
||||
it('rewindOne → verify reverted', async () => {
|
||||
// Setup: create and apply a file
|
||||
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);
|
||||
|
||||
69
apps/coder/src/services/__tests__/plan-edit.test.ts
Normal file
69
apps/coder/src/services/__tests__/plan-edit.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { planEdit } from '../pending_changes.js';
|
||||
|
||||
// planEdit is the pure core of applyOne's edit splice. These tests pin the
|
||||
// idempotency guards that stop the "block stamped 2-3x" corruption: applying the
|
||||
// same queued edit more than once must be a no-op, never a duplicate.
|
||||
|
||||
describe('planEdit — normal application', () => {
|
||||
it('applies a unique exact edit', () => {
|
||||
const content = 'a\nfoo\nb\n';
|
||||
const plan = planEdit(content, 'foo', 'bar');
|
||||
expect(plan).toEqual({ kind: 'apply', updated: 'a\nbar\nb\n' });
|
||||
});
|
||||
|
||||
it('reports ambiguous when old_string occurs more than once', () => {
|
||||
const content = 'foo\nx\nfoo\n';
|
||||
const plan = planEdit(content, 'foo', 'bar');
|
||||
expect(plan).toEqual({ kind: 'ambiguous', count: 2 });
|
||||
});
|
||||
|
||||
it('reports not_found when old_string is absent and new is not present', () => {
|
||||
const content = 'alpha\nbeta\n';
|
||||
const plan = planEdit(content, 'gamma that is clearly nowhere', 'delta');
|
||||
expect(plan).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('planEdit — idempotency (the corruption guard)', () => {
|
||||
it('treats a re-applied anchored insert as already-applied (no duplicate)', () => {
|
||||
// The exact mechanism that tripled `const recordFormats` in settings.html:
|
||||
// an anchored insert (old=anchor, new=anchor+block) where the anchor still
|
||||
// matches uniquely after the first apply.
|
||||
const oldStr = '<script>';
|
||||
const newStr = '<script>\nconst recordFormats = ["gif","mp4"];';
|
||||
const before = '<script>\nfunction render() {}\n</script>\n';
|
||||
|
||||
const first = planEdit(before, oldStr, newStr);
|
||||
expect(first.kind).toBe('apply');
|
||||
const after = first.kind === 'apply' ? first.updated : '';
|
||||
expect((after.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
|
||||
// Re-applying the identical edit to the already-edited content is a no-op.
|
||||
const second = planEdit(after, oldStr, newStr);
|
||||
expect(second).toEqual({ kind: 'noop', reason: 'already-applied' });
|
||||
});
|
||||
|
||||
it('treats an edit whose old_string is gone but new_string is present as already-applied', () => {
|
||||
const content = 'const total = sum + tax;\n';
|
||||
const plan = planEdit(content, 'const subtotal = sum;', 'const total = sum + tax;');
|
||||
expect(plan).toEqual({ kind: 'noop', reason: 'already-applied' });
|
||||
});
|
||||
|
||||
it('treats a no-change splice as a noop', () => {
|
||||
const content = 'a\nfoo\nb\n';
|
||||
const plan = planEdit(content, 'foo', 'foo');
|
||||
expect(plan).toEqual({ kind: 'noop', reason: 'identical' });
|
||||
});
|
||||
|
||||
it('does not duplicate across three repeated applications', () => {
|
||||
const oldStr = 'function f() {';
|
||||
const newStr = 'function f() {\n const x = 1;';
|
||||
let content = 'function f() {\n return x;\n}\n';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const plan = planEdit(content, oldStr, newStr);
|
||||
if (plan.kind === 'apply') content = plan.updated;
|
||||
}
|
||||
expect((content.match(/const x = 1;/g) || []).length).toBe(1);
|
||||
});
|
||||
});
|
||||
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal file
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluateTriggerRule } from '../flow-runner-decisions.js';
|
||||
|
||||
describe('evaluateTriggerRule', () => {
|
||||
it('all_success requires all deps done', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a', 'b']), new Set(), new Set())).toBe(true);
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set())).toBe(false);
|
||||
});
|
||||
|
||||
it('one_success fires on first completion', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(), 'one_success')).toBe(true);
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(), new Set(), new Set(), 'one_success')).toBe(false);
|
||||
});
|
||||
|
||||
it('all_done includes skipped deps', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(['b']), new Set(), 'all_done')).toBe(true);
|
||||
});
|
||||
|
||||
it('all_success treats excluded deps as satisfied', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(['b']))).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to all_success', () => {
|
||||
expect(evaluateTriggerRule(['a'], new Set(['a']), new Set(), new Set())).toBe(true);
|
||||
expect(evaluateTriggerRule(['a'], new Set(), new Set(), new Set())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for empty deps', () => {
|
||||
expect(evaluateTriggerRule([], new Set(), new Set(), new Set())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -68,11 +68,18 @@ export function deriveModesFromACP(
|
||||
): { modes: ProviderMode[]; currentModeId: string | null } {
|
||||
if (modeState?.availableModes?.length) {
|
||||
return {
|
||||
modes: modeState.availableModes.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.name,
|
||||
description: mode.description ?? undefined,
|
||||
})),
|
||||
// ACP omits the unattended flag; inherit it from the manifest fallback by
|
||||
// id so the unified permission picker can still detect each agent's bypass
|
||||
// mode (e.g. opencode `full-access`) from live-probed modes.
|
||||
modes: modeState.availableModes.map((mode) => {
|
||||
const fb = fallbackModes.find((f) => f.id === mode.id);
|
||||
return {
|
||||
id: mode.id,
|
||||
label: mode.name,
|
||||
description: mode.description ?? undefined,
|
||||
...(fb?.isUnattended ? { isUnattended: true } : {}),
|
||||
};
|
||||
}),
|
||||
currentModeId: modeState.currentModeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
191
apps/coder/src/services/arena-analyzer-helpers.ts
Normal file
191
apps/coder/src/services/arena-analyzer-helpers.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Pure, side-effect-free helpers for the Arena analyzer.
|
||||
* No DB, no IO, no network — safe to unit-test directly.
|
||||
*
|
||||
* Covers: digest-prompt assembly, judge-prompt assembly, winner extraction
|
||||
* from the judge output, the <2-survivors no-winner rule, and the
|
||||
* cross-examination prompt.
|
||||
*/
|
||||
|
||||
// ─── Shared types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ContestantDigestInput {
|
||||
identity: string;
|
||||
model: string;
|
||||
resultMd: string;
|
||||
diffPatch?: string;
|
||||
benchmarkLine: string;
|
||||
}
|
||||
|
||||
export interface ContestantDigest {
|
||||
identity: string;
|
||||
model: string;
|
||||
digest: string;
|
||||
benchmarkLine: string;
|
||||
}
|
||||
|
||||
// ─── Digest stage ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the system + user prompts for the per-contestant digest call.
|
||||
* The digest is a short structured summary; it keeps each call's context small
|
||||
* so the downstream judge only sees digests (not raw diffs).
|
||||
*/
|
||||
export function buildDigestPrompt(input: ContestantDigestInput): { system: string; user: string } {
|
||||
const system =
|
||||
'You are an expert technical analyst evaluating the output of an AI coding or Q&A battle. ' +
|
||||
'Produce a concise structured digest (under 300 words, Markdown bullet points) covering: ' +
|
||||
'(1) correctness and quality, (2) completeness, (3) notable strengths, (4) notable weaknesses or issues. ' +
|
||||
'Do not reference the battle or other contestants — focus only on this submission.';
|
||||
|
||||
const parts: string[] = [
|
||||
`# Contestant: ${input.identity} / ${input.model}`,
|
||||
`\nBenchmark: ${input.benchmarkLine}`,
|
||||
'\n## Result\n',
|
||||
input.resultMd.slice(0, 8_000),
|
||||
];
|
||||
|
||||
if (input.diffPatch) {
|
||||
parts.push('\n## Code Changes (diff)\n```diff');
|
||||
parts.push(input.diffPatch.slice(0, 5_000));
|
||||
parts.push('```');
|
||||
}
|
||||
|
||||
return { system, user: parts.join('\n') };
|
||||
}
|
||||
|
||||
// ─── Judge stage ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the system + user prompts for the comparative judge call.
|
||||
* Receives contestant digests (NOT raw diffs) to keep context bounded.
|
||||
*
|
||||
* The judge output must contain a line starting with WINNER: or NO_WINNER.
|
||||
* The caller extracts it with extractWinner().
|
||||
*/
|
||||
export function buildJudgePrompt(
|
||||
originalPrompt: string,
|
||||
digests: ContestantDigest[],
|
||||
): { system: string; user: string } {
|
||||
const canName = shouldNameWinner(digests.length);
|
||||
|
||||
const winnerInstruction = canName
|
||||
? 'After your comparative analysis, name the best submission on its own line in this exact format:\n' +
|
||||
'WINNER: <identity>/<model>\n' +
|
||||
'where <identity> and <model> exactly match the heading above. No other text on that line.'
|
||||
: 'Fewer than 2 contestants succeeded. Do NOT name a winner. Write the following on its own line:\nNO_WINNER';
|
||||
|
||||
const system =
|
||||
'You are an expert judge for an AI battle. You have received digest summaries of each ' +
|
||||
"contestant's work on the same task. Write a comparative analysis, then follow these instructions:\n" +
|
||||
winnerInstruction;
|
||||
|
||||
const parts: string[] = [
|
||||
'# Original Task Prompt\n',
|
||||
originalPrompt.slice(0, 2_000),
|
||||
'\n# Contestant Digests\n',
|
||||
];
|
||||
|
||||
for (const d of digests) {
|
||||
parts.push(`\n## ${d.identity} / ${d.model}`);
|
||||
parts.push(`Benchmark: ${d.benchmarkLine}`);
|
||||
parts.push(d.digest);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
'\n# Instructions\nCompare the contestants and follow the winner-naming instructions above.',
|
||||
);
|
||||
|
||||
return { system, user: parts.join('\n') };
|
||||
}
|
||||
|
||||
// ─── No-winner rule ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true when enough contestants succeeded to name a winner.
|
||||
* Rule: at least 2 must have produced a result. With 0 or 1 success the
|
||||
* analysis must NOT name a winner (no meaningful comparison possible).
|
||||
*/
|
||||
export function shouldNameWinner(succeededCount: number): boolean {
|
||||
return succeededCount >= 2;
|
||||
}
|
||||
|
||||
// ─── Winner extraction ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the judge's text output and extract the declared winner.
|
||||
* Looks for a line matching: WINNER: <identity>/<model>
|
||||
* Returns null when no valid winner line is found, or when the line contains
|
||||
* NO_WINNER.
|
||||
*
|
||||
* The parse is lenient on surrounding whitespace and case for the keyword.
|
||||
*/
|
||||
export function extractWinner(judgeOutput: string): { identity: string; model: string } | null {
|
||||
for (const line of judgeOutput.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.toUpperCase().startsWith('WINNER:')) continue;
|
||||
|
||||
const rest = trimmed.slice('WINNER:'.length).trim();
|
||||
if (rest.toUpperCase() === 'NO_WINNER' || rest === '') return null;
|
||||
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx === -1) return null;
|
||||
|
||||
const identity = rest.slice(0, slashIdx).trim();
|
||||
const model = rest.slice(slashIdx + 1).trim();
|
||||
if (identity && model) return { identity, model };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Cross-examination stage ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the system + user prompts for a cross-examination call.
|
||||
* The cross-examiner sees the original prompt, contestant digests, and the
|
||||
* proposed analysis, and is asked to challenge the result.
|
||||
*/
|
||||
export function buildCrossExamPrompt(opts: {
|
||||
originalPrompt: string;
|
||||
digests: ContestantDigest[];
|
||||
analysisContent: string;
|
||||
proposedWinner: string | null;
|
||||
examinerIdentity: string;
|
||||
examinerModel: string;
|
||||
}): { system: string; user: string } {
|
||||
const system =
|
||||
`You are ${opts.examinerIdentity} (model: ${opts.examinerModel}), acting as an independent ` +
|
||||
'cross-examiner in an AI battle. Your role is to critically challenge the proposed analysis ' +
|
||||
'and winner, then give your own verdict. Be rigorous but fair. ' +
|
||||
'End your response with your verdict on its own line:\n' +
|
||||
'VERDICT: <identity>/<model> — if you agree or disagree with the proposed winner but can name one\n' +
|
||||
'VERDICT: NO_WINNER — if no clear winner exists';
|
||||
|
||||
const parts: string[] = [
|
||||
'# Original Task Prompt\n',
|
||||
opts.originalPrompt.slice(0, 2_000),
|
||||
'\n# Contestant Digests\n',
|
||||
];
|
||||
|
||||
for (const d of opts.digests) {
|
||||
parts.push(`\n## ${d.identity} / ${d.model}`);
|
||||
parts.push(`Benchmark: ${d.benchmarkLine}`);
|
||||
parts.push(d.digest);
|
||||
}
|
||||
|
||||
parts.push('\n# Proposed Analysis\n');
|
||||
parts.push(opts.analysisContent.slice(0, 5_000));
|
||||
|
||||
if (opts.proposedWinner) {
|
||||
parts.push(`\n*(Proposed winner: ${opts.proposedWinner})*`);
|
||||
} else {
|
||||
parts.push('\n*(No winner was proposed — fewer than 2 contestants succeeded.)*');
|
||||
}
|
||||
|
||||
parts.push(
|
||||
'\n# Your Cross-Examination\n' +
|
||||
'Challenge the analysis above, then give your independent verdict (VERDICT: … on its own line).',
|
||||
);
|
||||
|
||||
return { system, user: parts.join('\n') };
|
||||
}
|
||||
496
apps/coder/src/services/arena-analyzer.ts
Normal file
496
apps/coder/src/services/arena-analyzer.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Arena Analyzer — pluggable seam for battle analysis and cross-examination.
|
||||
*
|
||||
* The Analyzer interface is the plug point: a v2 Han Orchestrator flow can
|
||||
* replace the v1 two-stage digest→judge implementation without a schema change.
|
||||
*
|
||||
* v1 implementation uses DEFAULT_MODEL via direct llama-swap calls (arenaModelCall):
|
||||
* Digest stage — one call per succeeded contestant, concurrent; produces a
|
||||
* bounded summary of each result (result.md + diff.patch for
|
||||
* coding, result.md for Q&A).
|
||||
* Judge stage — one call with all digests + the original prompt; writes
|
||||
* analysis.md, names a winner (unless < 2 succeeded), and
|
||||
* updates battles.winner_contestant_id.
|
||||
*
|
||||
* Cross-examination:
|
||||
* Local model — direct arenaModelCall to llama-swap with the chosen model.
|
||||
* Cloud model — inserts a tasks row (triggers the dispatcher via pg_notify);
|
||||
* polls for completion; reads output_summary as the verdict.
|
||||
* In both cases the verdict is written to cross_examinations.verdict, appended
|
||||
* to <resultsPath>/cross-exam.md, and a battle_updated frame is published.
|
||||
*
|
||||
* Never throws — all errors are caught, logged, and swallowed so the caller
|
||||
* (arena-runner's onBattleComplete / onCrossExamStart) is never wedged.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Config } from '../config.js';
|
||||
import type { BattleType } from '@boocode/contracts/arena';
|
||||
import { arenaModelCall } from './arena-model-call.js';
|
||||
import {
|
||||
buildDigestPrompt,
|
||||
buildJudgePrompt,
|
||||
buildCrossExamPrompt,
|
||||
extractWinner,
|
||||
shouldNameWinner,
|
||||
type ContestantDigest,
|
||||
} from './arena-analyzer-helpers.js';
|
||||
|
||||
// ─── Public interface ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Pluggable analysis seam — swap to a Han Orchestrator flow in v2. */
|
||||
export interface Analyzer {
|
||||
/** Run the two-stage digest→judge analysis for a completed battle. */
|
||||
analyze(battleId: string): Promise<void>;
|
||||
/**
|
||||
* Run a cross-examination for an already-inserted cross_examinations row.
|
||||
* The result is written back to that row and a battle_updated frame is published.
|
||||
*/
|
||||
crossExamine(
|
||||
battleId: string,
|
||||
crossExamId: string,
|
||||
opts: { identity: string; model: string },
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Internal DB row types ────────────────────────────────────────────────────
|
||||
|
||||
interface BattleRow {
|
||||
id: string;
|
||||
project_id: string;
|
||||
battle_type: BattleType;
|
||||
prompt: string;
|
||||
status: string;
|
||||
results_path: string | null;
|
||||
winner_contestant_id: string | null;
|
||||
}
|
||||
|
||||
interface ContestantRow {
|
||||
id: string;
|
||||
identity: string;
|
||||
model: string;
|
||||
lane: string;
|
||||
status: string;
|
||||
result_path: string | null;
|
||||
duration_ms: number | null;
|
||||
tokens_per_sec: number | null;
|
||||
}
|
||||
|
||||
// ─── Factory ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AnalyzerDeps {
|
||||
sql: Sql;
|
||||
broker: Broker;
|
||||
log: FastifyBaseLogger;
|
||||
config: Pick<Config, 'LLAMA_SWAP_URL' | 'DEFAULT_MODEL'>;
|
||||
/** Model IDs served by local llama-swap — cross-exam routing uses this. */
|
||||
localModels: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function createAnalyzer(deps: AnalyzerDeps): Analyzer {
|
||||
const { sql, broker, log, config, localModels } = deps;
|
||||
|
||||
// ─── analyze ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function analyze(battleId: string): Promise<void> {
|
||||
try {
|
||||
await runAnalysis(battleId);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{ err: errMsg(err), battleId },
|
||||
'arena-analyzer: analysis failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAnalysis(battleId: string): Promise<void> {
|
||||
const battle = await loadBattle(battleId);
|
||||
if (!battle) {
|
||||
log.warn({ battleId }, 'arena-analyzer: battle not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const contestants = await loadContestants(battleId);
|
||||
const succeeded = contestants.filter((c) => c.status === 'done' && c.result_path);
|
||||
|
||||
log.info(
|
||||
{ battleId, total: contestants.length, succeeded: succeeded.length },
|
||||
'arena-analyzer: starting analysis',
|
||||
);
|
||||
|
||||
// Digest stage — concurrent, one call per succeeded contestant.
|
||||
const digests = (
|
||||
await Promise.all(succeeded.map((c) => digestContestant(battle, c)))
|
||||
).filter((d): d is ContestantDigest => d !== null);
|
||||
|
||||
// Failed contestants are noted in the analysis even if they produced no digest.
|
||||
const failedNotes = contestants
|
||||
.filter((c) => c.status === 'error')
|
||||
.map((c) => `- **${c.identity} / ${c.model}**: failed (no result)\n`);
|
||||
|
||||
// Judge stage — single call with all digests.
|
||||
const { analysisText, winner } = await judgeContestants(battle, digests, failedNotes);
|
||||
|
||||
// Write analysis.md to the battle results folder.
|
||||
const resultsPath = battle.results_path;
|
||||
if (resultsPath) {
|
||||
await mkdir(resultsPath, { recursive: true });
|
||||
await writeFile(join(resultsPath, 'analysis.md'), analysisText, 'utf8');
|
||||
}
|
||||
|
||||
// Resolve the winner to a contestant id and update the battle row.
|
||||
let winnerId: string | null = null;
|
||||
if (winner && shouldNameWinner(succeeded.length)) {
|
||||
const winnerContestant = contestants.find(
|
||||
(c) => c.identity === winner.identity && c.model === winner.model,
|
||||
);
|
||||
if (winnerContestant) {
|
||||
winnerId = winnerContestant.id;
|
||||
await sql`
|
||||
UPDATE battles
|
||||
SET winner_contestant_id = ${winnerId}, updated_at = clock_timestamp()
|
||||
WHERE id = ${battleId}
|
||||
`;
|
||||
log.info({ battleId, winnerId, identity: winner.identity, model: winner.model }, 'arena-analyzer: winner set');
|
||||
} else {
|
||||
log.warn({ battleId, winner }, 'arena-analyzer: judge named a winner not found in contestants');
|
||||
}
|
||||
}
|
||||
|
||||
publishUser({
|
||||
type: 'battle_updated',
|
||||
battle_id: battleId,
|
||||
winner_contestant_id: winnerId,
|
||||
analysis_ready: true,
|
||||
});
|
||||
|
||||
log.info({ battleId }, 'arena-analyzer: analysis complete');
|
||||
}
|
||||
|
||||
// ─── crossExamine ─────────────────────────────────────────────────────────
|
||||
|
||||
async function crossExamine(
|
||||
battleId: string,
|
||||
crossExamId: string,
|
||||
opts: { identity: string; model: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await runCrossExam(battleId, crossExamId, opts);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{ err: errMsg(err), battleId, crossExamId },
|
||||
'arena-analyzer: cross-exam failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCrossExam(
|
||||
battleId: string,
|
||||
crossExamId: string,
|
||||
opts: { identity: string; model: string },
|
||||
): Promise<void> {
|
||||
const battle = await loadBattle(battleId);
|
||||
if (!battle) {
|
||||
log.warn({ battleId }, 'arena-analyzer: battle not found for cross-exam');
|
||||
return;
|
||||
}
|
||||
|
||||
const contestants = await loadContestants(battleId);
|
||||
|
||||
// Re-read the digests (if contestants have results) for context.
|
||||
const succeeded = contestants.filter((c) => c.status === 'done' && c.result_path);
|
||||
const digests = (
|
||||
await Promise.all(succeeded.map((c) => digestContestant(battle, c)))
|
||||
).filter((d): d is ContestantDigest => d !== null);
|
||||
|
||||
// Read analysis.md for the proposed analysis content.
|
||||
let analysisContent = '';
|
||||
if (battle.results_path) {
|
||||
analysisContent = await readFile(
|
||||
join(battle.results_path, 'analysis.md'), 'utf8',
|
||||
).catch(() => '');
|
||||
}
|
||||
|
||||
// Resolve proposed winner label.
|
||||
let proposedWinner: string | null = null;
|
||||
if (battle.winner_contestant_id) {
|
||||
const w = contestants.find((c) => c.id === battle.winner_contestant_id);
|
||||
if (w) proposedWinner = `${w.identity}/${w.model}`;
|
||||
}
|
||||
|
||||
const { system, user } = buildCrossExamPrompt({
|
||||
originalPrompt: battle.prompt,
|
||||
digests,
|
||||
analysisContent,
|
||||
proposedWinner,
|
||||
examinerIdentity: opts.identity,
|
||||
examinerModel: opts.model,
|
||||
});
|
||||
|
||||
log.info({ battleId, crossExamId, identity: opts.identity, model: opts.model }, 'arena-analyzer: running cross-exam');
|
||||
|
||||
const verdict = await executeModelCall({
|
||||
battleId,
|
||||
projectId: battle.project_id,
|
||||
identity: opts.identity,
|
||||
model: opts.model,
|
||||
system,
|
||||
user,
|
||||
});
|
||||
|
||||
// Persist verdict and append to cross-exam.md.
|
||||
await sql`
|
||||
UPDATE cross_examinations
|
||||
SET verdict = ${verdict}
|
||||
WHERE id = ${crossExamId}
|
||||
`;
|
||||
|
||||
if (battle.results_path) {
|
||||
const crossExamPath = join(battle.results_path, 'cross-exam.md');
|
||||
const section =
|
||||
`\n---\n\n# Cross-Examination by ${opts.identity} / ${opts.model}\n\n` +
|
||||
`${verdict}\n`;
|
||||
await writeFile(crossExamPath, section, { flag: 'a', encoding: 'utf8' });
|
||||
}
|
||||
|
||||
publishUser({
|
||||
type: 'battle_updated',
|
||||
battle_id: battleId,
|
||||
cross_exam_id: crossExamId,
|
||||
});
|
||||
|
||||
log.info({ battleId, crossExamId }, 'arena-analyzer: cross-exam complete');
|
||||
}
|
||||
|
||||
// ─── Model call routing ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Route a one-shot model call to llama-swap (local) or the task dispatcher
|
||||
* (cloud). Cloud dispatch inserts a tasks row and polls for completion.
|
||||
*/
|
||||
async function executeModelCall(opts: {
|
||||
battleId: string;
|
||||
projectId: string;
|
||||
identity: string;
|
||||
model: string;
|
||||
system: string;
|
||||
user: string;
|
||||
}): Promise<string> {
|
||||
const isLocal = localModels.has(opts.model) || localModels.has(`llama-swap/${opts.model}`);
|
||||
|
||||
if (isLocal) {
|
||||
return arenaModelCall({
|
||||
config,
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
user: opts.user,
|
||||
maxTokens: 2_000,
|
||||
temperature: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
// Cloud path: dispatch through the task system and poll for completion.
|
||||
return executeCloudModelCall(opts);
|
||||
}
|
||||
|
||||
async function executeCloudModelCall(opts: {
|
||||
projectId: string;
|
||||
identity: string;
|
||||
model: string;
|
||||
system: string;
|
||||
user: string;
|
||||
}): Promise<string> {
|
||||
// The cross-exam prompt is the full input to the external agent. We embed
|
||||
// the system prompt as a preamble in the user message (external agents don't
|
||||
// take a separate system arg through the tasks dispatcher).
|
||||
const input = `${opts.system}\n\n${opts.user}`;
|
||||
|
||||
// For well-known external agents, stamp the agent name so the dispatcher
|
||||
// routes via PTY/ACP. For unknown identities fall back to native inference
|
||||
// (agent = null → DEFAULT_MODEL text generation).
|
||||
const knownAgents = new Set(['claude', 'opencode', 'qwen', 'goose']);
|
||||
const agentName = knownAgents.has(opts.identity) ? opts.identity : null;
|
||||
|
||||
const [task] = await sql<{ id: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model)
|
||||
VALUES (${opts.projectId}, ${input}, ${agentName}, ${opts.model})
|
||||
RETURNING id
|
||||
`;
|
||||
const taskId = task!.id;
|
||||
|
||||
log.info({ taskId, identity: opts.identity, model: opts.model }, 'arena-analyzer: cloud cross-exam task dispatched');
|
||||
|
||||
// Poll until terminal (up to 5 minutes).
|
||||
const timeoutMs = 5 * 60 * 1_000;
|
||||
const pollMs = 2_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(pollMs);
|
||||
const [row] = await sql<{ state: string; output_summary: string | null }[]>`
|
||||
SELECT state, output_summary FROM tasks WHERE id = ${taskId}
|
||||
`;
|
||||
if (!row) break;
|
||||
if (row.state === 'completed') return row.output_summary ?? '';
|
||||
if (row.state === 'failed' || row.state === 'cancelled') {
|
||||
throw new Error(`cross-exam task ${row.state}: ${row.output_summary ?? ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`cloud cross-exam task timed out after ${timeoutMs / 1000}s`);
|
||||
}
|
||||
|
||||
// ─── Digest helper ────────────────────────────────────────────────────────
|
||||
|
||||
async function digestContestant(
|
||||
battle: BattleRow,
|
||||
c: ContestantRow,
|
||||
): Promise<ContestantDigest | null> {
|
||||
if (!c.result_path) return null;
|
||||
|
||||
const resultMd = await readFile(join(c.result_path, 'result.md'), 'utf8').catch(() => '');
|
||||
|
||||
let diffPatch: string | undefined;
|
||||
if (battle.battle_type === 'coding') {
|
||||
diffPatch = await readFile(join(c.result_path, 'diff.patch'), 'utf8').catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const benchmarkLine = formatBenchmarkLine(c);
|
||||
const { system, user } = buildDigestPrompt({
|
||||
identity: c.identity,
|
||||
model: c.model,
|
||||
resultMd,
|
||||
diffPatch,
|
||||
benchmarkLine,
|
||||
});
|
||||
|
||||
let digest: string;
|
||||
try {
|
||||
digest = await arenaModelCall({
|
||||
config,
|
||||
model: config.DEFAULT_MODEL,
|
||||
system,
|
||||
user,
|
||||
maxTokens: 500,
|
||||
temperature: 0.3,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err: errMsg(err), identity: c.identity, model: c.model },
|
||||
'arena-analyzer: digest call failed — skipping contestant',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { identity: c.identity, model: c.model, digest, benchmarkLine };
|
||||
}
|
||||
|
||||
// ─── Judge helper ─────────────────────────────────────────────────────────
|
||||
|
||||
async function judgeContestants(
|
||||
battle: BattleRow,
|
||||
digests: ContestantDigest[],
|
||||
failedNotes: string[],
|
||||
): Promise<{ analysisText: string; winner: { identity: string; model: string } | null }> {
|
||||
const { system, user } = buildJudgePrompt(battle.prompt, digests);
|
||||
|
||||
let judgeOutput = '';
|
||||
try {
|
||||
judgeOutput = await arenaModelCall({
|
||||
config,
|
||||
model: config.DEFAULT_MODEL,
|
||||
system,
|
||||
user,
|
||||
maxTokens: 2_000,
|
||||
temperature: 0.3,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err: errMsg(err), battleId: battle.id }, 'arena-analyzer: judge call failed');
|
||||
judgeOutput = '*(Judge call failed — no comparison produced.)*';
|
||||
}
|
||||
|
||||
const winner = shouldNameWinner(digests.length) ? extractWinner(judgeOutput) : null;
|
||||
|
||||
const sections: string[] = [
|
||||
`# Arena Analysis`,
|
||||
`\n**Battle type:** ${battle.battle_type}`,
|
||||
];
|
||||
|
||||
if (failedNotes.length > 0) {
|
||||
sections.push('\n## Failed Contestants\n');
|
||||
sections.push(...failedNotes);
|
||||
}
|
||||
|
||||
if (digests.length > 0) {
|
||||
sections.push('\n## Contestant Digests\n');
|
||||
for (const d of digests) {
|
||||
sections.push(`### ${d.identity} / ${d.model}`);
|
||||
sections.push(`*Benchmark: ${d.benchmarkLine}*\n`);
|
||||
sections.push(d.digest);
|
||||
}
|
||||
}
|
||||
|
||||
sections.push("\n## Judge's Verdict\n");
|
||||
sections.push(judgeOutput);
|
||||
|
||||
if (winner) {
|
||||
sections.push(`\n## Winner\n**${winner.identity} / ${winner.model}**`);
|
||||
} else {
|
||||
const reason =
|
||||
digests.length < 2
|
||||
? 'fewer than 2 contestants produced results'
|
||||
: 'no clear winner identified';
|
||||
sections.push(`\n## Winner\n*No winner named (${reason}).*`);
|
||||
}
|
||||
|
||||
return { analysisText: sections.join('\n'), winner };
|
||||
}
|
||||
|
||||
// ─── DB helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadBattle(battleId: string): Promise<BattleRow | null> {
|
||||
const [b] = await sql<BattleRow[]>`
|
||||
SELECT id, project_id, battle_type, prompt, status, results_path, winner_contestant_id
|
||||
FROM battles WHERE id = ${battleId}
|
||||
`;
|
||||
return b ?? null;
|
||||
}
|
||||
|
||||
async function loadContestants(battleId: string): Promise<ContestantRow[]> {
|
||||
return sql<ContestantRow[]>`
|
||||
SELECT id, identity, model, lane, status, result_path, duration_ms, tokens_per_sec
|
||||
FROM contestants WHERE battle_id = ${battleId}
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Misc helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function formatBenchmarkLine(c: ContestantRow): string {
|
||||
const parts: string[] = [];
|
||||
if (c.duration_ms !== null) parts.push(`${c.duration_ms}ms`);
|
||||
if (c.tokens_per_sec !== null) parts.push(`${c.tokens_per_sec.toFixed(1)} tok/s`);
|
||||
return parts.length > 0 ? parts.join(', ') : 'no benchmark';
|
||||
}
|
||||
|
||||
function publishUser(frame: Record<string, unknown>): void {
|
||||
broker.publishUserFrame('default', frame as unknown as WsFrame);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
return { analyze, crossExamine };
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
188
apps/coder/src/services/arena-decisions.ts
Normal file
188
apps/coder/src/services/arena-decisions.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Pure scheduling and classification decisions for the Arena battle-runner.
|
||||
* No database, no IO. Mirrors the pattern of flow-runner-decisions.ts.
|
||||
*
|
||||
* Vocabulary:
|
||||
* local lane — llama-swap-backed contestants, run strictly one at a time
|
||||
* cloud lane — cloud-backed contestants, run all in parallel
|
||||
*
|
||||
* A contestant's status lifecycle:
|
||||
* queued → running → done | error
|
||||
*/
|
||||
import type { BattleType, ContestantLane, TokenBreakdown } from '@boocode/contracts/arena';
|
||||
|
||||
// ─── Lane classification ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Classify a contestant into a lane.
|
||||
*
|
||||
* Q&A contestants always run on the native (llama-swap) backend → local.
|
||||
* Coding contestants: their MODEL is checked against the localModels set
|
||||
* (all model IDs served by the local llama-swap server). This means an
|
||||
* opencode or qwen contestant pointed at a local model counts as local,
|
||||
* which correctly captures GPU-contention and fair benchmarking (ADR 0001).
|
||||
*
|
||||
* @param battleType 'coding' | 'qa'
|
||||
* @param identity backend name (coding) or persona name (qa) — not used for lane logic
|
||||
* @param model the contestant's model id
|
||||
* @param localModels set of model IDs served by the local llama-swap server
|
||||
*/
|
||||
export function classifyLane(
|
||||
battleType: BattleType,
|
||||
_identity: string,
|
||||
model: string,
|
||||
localModels: ReadonlySet<string>,
|
||||
): ContestantLane {
|
||||
if (battleType === 'qa') return 'local';
|
||||
return localModels.has(model) ? 'local' : 'cloud';
|
||||
}
|
||||
|
||||
// ─── Local-lane queue ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ContestantSlot {
|
||||
id: string;
|
||||
lane: ContestantLane;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next queued local contestant to dispatch — the first 'queued' contestant
|
||||
* in the local lane, in creation order (caller must supply rows in created_at ASC).
|
||||
* Returns null when the local queue is empty or all local slots are non-queued.
|
||||
*/
|
||||
export function nextLocalContestant(contestants: readonly ContestantSlot[]): string | null {
|
||||
for (const c of contestants) {
|
||||
if (c.lane === 'local' && c.status === 'queued') return c.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Battle completion ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True when every contestant has reached a terminal state (done | error).
|
||||
* Returns false for an empty list — a battle with no contestants never completes.
|
||||
*/
|
||||
export function isBattleComplete(contestants: readonly { status: string }[]): boolean {
|
||||
if (contestants.length === 0) return false;
|
||||
return contestants.every((c) => c.status === 'done' || c.status === 'error');
|
||||
}
|
||||
|
||||
// ─── Benchmark ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Benchmark {
|
||||
durationMs: number;
|
||||
tokensPerSec: number | null;
|
||||
tokenBreakdown: TokenBreakdown | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the benchmark for a contestant.
|
||||
* Wall-clock duration is captured for every contestant; tokens/sec is only
|
||||
* meaningful for local (llama-swap) contestants where the model has sole
|
||||
* access to the GPU and the measurement is fair.
|
||||
*/
|
||||
export function computeBenchmark(
|
||||
startedAt: Date,
|
||||
endedAt: Date,
|
||||
costTokens: number | null,
|
||||
lane: ContestantLane,
|
||||
tokenBreakdown: TokenBreakdown | null = null,
|
||||
): Benchmark {
|
||||
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
|
||||
const tokensPerSec =
|
||||
lane === 'local' && costTokens !== null && durationMs > 0
|
||||
? (costTokens / durationMs) * 1000
|
||||
: null;
|
||||
return { durationMs, tokensPerSec, tokenBreakdown };
|
||||
}
|
||||
|
||||
// ─── Slug / path helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitize a string for use as a directory name component.
|
||||
* Lowercases, replaces non-alphanumeric runs with '-', trims leading/trailing
|
||||
* dashes, and caps at 64 characters.
|
||||
*/
|
||||
export function sanitizeSlug(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the dated battle slug used as the Arena results folder name.
|
||||
* Format: YYYY-MM-DD-<battleType>-<first-8-hex-of-uuid>
|
||||
* Deterministic: callers can rebuild it from (id, type, created_at) on resume.
|
||||
*/
|
||||
export function buildBattleSlug(battleId: string, battleType: BattleType, createdAt: Date): string {
|
||||
const date = createdAt.toISOString().slice(0, 10);
|
||||
const shortId = battleId.replace(/-/g, '').slice(0, 8);
|
||||
return `${date}-${battleType}-${shortId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-contestant results directory name within a battle folder.
|
||||
* Format: <sanitized-identity>-<sanitized-model>
|
||||
*/
|
||||
export function buildContestantDir(identity: string, model: string): string {
|
||||
return `${sanitizeSlug(identity)}-${sanitizeSlug(model)}`;
|
||||
}
|
||||
|
||||
// ─── Resume reconciliation ────────────────────────────────────────────────────
|
||||
|
||||
export type ContestantResumeAction =
|
||||
| 'keep'
|
||||
| 're-dispatch'
|
||||
| 'mark-done'
|
||||
| 'mark-error'
|
||||
| 'mark-cancelled';
|
||||
|
||||
export interface ContestantResumeDecision {
|
||||
contestantId: string;
|
||||
action: ContestantResumeAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to do with ONE contestant during startup resume.
|
||||
* Mirrors reconcileResumeStep from flow-runner-decisions.ts.
|
||||
*
|
||||
* @param status contestants.status
|
||||
* @param taskId contestants.task_id (null when not yet dispatched)
|
||||
* @param taskState tasks.state for taskId, or null if the task row is absent
|
||||
*/
|
||||
export function reconcileContestantResume(
|
||||
status: string,
|
||||
taskId: string | null,
|
||||
taskState: string | null,
|
||||
): ContestantResumeAction {
|
||||
if (status !== 'running') return 'keep';
|
||||
if (!taskId || taskState === null) return 're-dispatch';
|
||||
switch (taskState) {
|
||||
case 'completed': return 'mark-done';
|
||||
case 'failed': return 'mark-error';
|
||||
case 'cancelled': return 'mark-cancelled';
|
||||
case 'pending': return 'keep'; // dispatcher startup poll will run it normally
|
||||
default: return 're-dispatch'; // 'running'/'blocked' — process is dead
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile every contestant of an in-flight battle for startup resume.
|
||||
* Returns one decision per contestant. Pure — no IO.
|
||||
*/
|
||||
export function reconcileContestants(
|
||||
contestants: ReadonlyArray<{ contestantId: string; taskId: string | null; status: string }>,
|
||||
taskStates: ReadonlyMap<string, string>,
|
||||
): ContestantResumeDecision[] {
|
||||
return contestants.map((c) => ({
|
||||
contestantId: c.contestantId,
|
||||
action: reconcileContestantResume(
|
||||
c.status,
|
||||
c.taskId,
|
||||
c.taskId ? (taskStates.get(c.taskId) ?? null) : null,
|
||||
),
|
||||
}));
|
||||
}
|
||||
70
apps/coder/src/services/arena-model-call.ts
Normal file
70
apps/coder/src/services/arena-model-call.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* One-shot model completion for the Arena analyzer.
|
||||
*
|
||||
* Calls the local llama-swap server directly for a single non-streaming
|
||||
* completion. Used for the digest and judge stages (always DEFAULT_MODEL)
|
||||
* and for local-model cross-examinations (any local model).
|
||||
*
|
||||
* Mirrors apps/server/src/services/task-model.ts but targets the coder's
|
||||
* config shape and uses a longer timeout appropriate for analysis calls.
|
||||
*/
|
||||
|
||||
import type { Config } from '../config.js';
|
||||
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
export async function arenaModelCall(opts: {
|
||||
config: Pick<Config, 'LLAMA_SWAP_URL'>;
|
||||
model: string;
|
||||
system: string;
|
||||
user: string;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
}): Promise<string> {
|
||||
const { config, model, system, user } = opts;
|
||||
const maxTokens = opts.maxTokens ?? 2_000;
|
||||
const temperature = opts.temperature ?? 0.3;
|
||||
|
||||
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
stream: false,
|
||||
chat_template_kwargs: { enable_thinking: false },
|
||||
}),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`llama-swap responded ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{
|
||||
message?: { content?: string; reasoning_content?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
const choice = data.choices?.[0]?.message;
|
||||
if (!choice) return '';
|
||||
|
||||
const content = (choice.content ?? '').trim();
|
||||
if (content.length > 0) return content;
|
||||
|
||||
// For thinking-mode models the answer sometimes only lands in reasoning_content.
|
||||
const reasoning = (choice.reasoning_content ?? '').trim();
|
||||
if (reasoning.length > 0) {
|
||||
const lines = reasoning.split('\n').filter((l) => l.trim().length > 0);
|
||||
return lines[lines.length - 1] ?? '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
895
apps/coder/src/services/arena-runner.ts
Normal file
895
apps/coder/src/services/arena-runner.ts
Normal file
@@ -0,0 +1,895 @@
|
||||
/**
|
||||
* Arena battle-runner — DB-backed execution engine for Arena battles.
|
||||
*
|
||||
* Mirrors flow-runner.ts but implements the Arena's two-lane scheduler instead
|
||||
* of the Orchestrator's wave scheduler. Persists to battles/contestants tables
|
||||
* (not flow_runs/flow_steps). Each contestant is dispatched as a real tasks row
|
||||
* via an injected DispatchContestantFn (Phase 4 wires this to the dispatcher).
|
||||
* Advances on the dispatcher's onTaskTerminal hook.
|
||||
*
|
||||
* Scheduling:
|
||||
* - Cloud lane: all contestants start immediately, in parallel.
|
||||
* - Local lane: contestants run strictly one at a time (serial queue). Only
|
||||
* the first local contestant runs at start; the next is dispatched when the
|
||||
* current one terminates. Both lanes run concurrently with each other.
|
||||
*
|
||||
* Results:
|
||||
* Written to <projectRoot>/Arena/<battleSlug>/<identity>-<model>/
|
||||
* Coding: result.md + diff.patch (from the contestant's worktree).
|
||||
* Q&A: result.md with the text answer.
|
||||
*
|
||||
* Analyzer seam:
|
||||
* onBattleComplete is called when all contestants are terminal. Phase 5 wires
|
||||
* this to the two-stage digest→judge analyzer. A failed contestant does NOT
|
||||
* abort the battle — others continue and the analyzer judges survivors.
|
||||
*/
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { BattleType, ContestantLane } from '@boocode/contracts/arena';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { diffWorktree } from './worktrees.js';
|
||||
import {
|
||||
buildBattleSlug,
|
||||
buildContestantDir,
|
||||
classifyLane,
|
||||
computeBenchmark,
|
||||
isBattleComplete,
|
||||
nextLocalContestant,
|
||||
reconcileContestants,
|
||||
type ContestantResumeAction,
|
||||
type ContestantSlot,
|
||||
} from './arena-decisions.js';
|
||||
|
||||
// ─── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ContestantSpec {
|
||||
/** Backend name (coding) or persona name (qa). */
|
||||
identity: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface BattleStartOpts {
|
||||
projectId: string;
|
||||
battleType: BattleType;
|
||||
prompt: string;
|
||||
/** 2–6 contestants. Duplicate (identity, model) pairs are rejected by the schema UNIQUE constraint. */
|
||||
contestants: ContestantSpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Injected dispatch function — Phase 4 wires this to the real task inserter.
|
||||
* Must INSERT a tasks row and return its id. The arena-runner sets the
|
||||
* contestant's task_id and status after this call.
|
||||
* `sessionId` is returned when already known (Q&A pre-creates the session);
|
||||
* null for coding contestants whose session is created lazily by the dispatcher.
|
||||
*/
|
||||
export type DispatchContestantFn = (opts: {
|
||||
projectId: string;
|
||||
contestantId: string;
|
||||
prompt: string;
|
||||
identity: string;
|
||||
model: string;
|
||||
battleType: BattleType;
|
||||
}) => Promise<{ taskId: string; sessionId: string | null }>;
|
||||
|
||||
/**
|
||||
* Called once when every contestant in a battle has reached a terminal state.
|
||||
* Phase 5 wires this to the two-stage digest→judge analyzer.
|
||||
* Must never throw — the caller swallows errors.
|
||||
*/
|
||||
export type OnBattleComplete = (battleId: string) => void;
|
||||
|
||||
/**
|
||||
* Called after a cross_examinations row has been inserted, with its id.
|
||||
* Phase 5 wires this to the analyzer's cross-examination runner.
|
||||
* Must never throw — the caller swallows errors.
|
||||
*/
|
||||
export type OnCrossExamStart = (opts: {
|
||||
battleId: string;
|
||||
crossExamId: string;
|
||||
identity: string;
|
||||
model: string;
|
||||
}) => void;
|
||||
|
||||
export interface BattleRunner {
|
||||
/** Start a battle: persist it + its contestants, classify lanes, dispatch initial wave. */
|
||||
startBattle(opts: BattleStartOpts): Promise<{ battleId: string }>;
|
||||
/**
|
||||
* Wire to createDispatcher({ onTaskTerminal }). Fires when ANY task settles;
|
||||
* the runner ignores tasks it doesn't own. Never throws.
|
||||
*/
|
||||
handleTaskTerminal(taskId: string, state: string): void;
|
||||
/**
|
||||
* Re-advance any battles still marked 'running' after a coder restart.
|
||||
* Mirrors flow-runner's initResume (D-9). Never throws.
|
||||
*/
|
||||
initResume(): Promise<void>;
|
||||
/**
|
||||
* Cancel a running battle. Marks it and all non-terminal contestants cancelled,
|
||||
* publishes frames, and returns the task_ids of in-flight contestants so the
|
||||
* route can abort them via the dispatcher's cancelExternalTask.
|
||||
*/
|
||||
cancelBattle(battleId: string): Promise<{ cancelled: boolean; taskIds: string[] }>;
|
||||
/**
|
||||
* Trigger analysis for a completed (or manually re-analyzed) battle.
|
||||
* Phase 5 wires this to the two-stage digest→judge analyzer. For now, calls
|
||||
* the injected onBattleComplete seam directly.
|
||||
*/
|
||||
triggerAnalysis(battleId: string): Promise<{ triggered: boolean }>;
|
||||
/**
|
||||
* Start a cross-examination on a battle. Inserts a cross_examinations row and
|
||||
* invokes the analyzer seam. Phase 5 fills the actual verdict logic.
|
||||
*/
|
||||
startCrossExam(
|
||||
battleId: string,
|
||||
opts: { identity: string; model: string },
|
||||
): Promise<{ crossExamId: string }>;
|
||||
/**
|
||||
* Manually set (or clear) the winner. Validates the contestant belongs to the
|
||||
* battle, updates battles.winner_contestant_id, and publishes a battle_updated
|
||||
* frame so the pane reflects the override immediately.
|
||||
*/
|
||||
setWinner(battleId: string, winnerId: string | null): Promise<{
|
||||
ok: boolean;
|
||||
notFound?: boolean;
|
||||
invalidContestant?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── Internal row shapes ──────────────────────────────────────────────────────
|
||||
|
||||
interface ContestantRow {
|
||||
id: string;
|
||||
battle_id: string;
|
||||
identity: string;
|
||||
model: string;
|
||||
lane: ContestantLane;
|
||||
task_id: string | null;
|
||||
worktree_id: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface BattleRow {
|
||||
id: string;
|
||||
project_id: string;
|
||||
battle_type: BattleType;
|
||||
prompt: string;
|
||||
status: string;
|
||||
results_path: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// ─── Deps / factory ───────────────────────────────────────────────────────────
|
||||
|
||||
interface Deps {
|
||||
sql: Sql;
|
||||
broker: Broker;
|
||||
log: FastifyBaseLogger;
|
||||
dispatch: DispatchContestantFn;
|
||||
onBattleComplete: OnBattleComplete;
|
||||
/**
|
||||
* Called after a cross_examinations row is inserted. Phase 5 wires this to
|
||||
* the analyzer's cross-examination runner. Optional: absent → no cross-exam
|
||||
* logic runs (stub behaviour for tests).
|
||||
*/
|
||||
onCrossExamStart?: OnCrossExamStart;
|
||||
/**
|
||||
* Model IDs served by the local llama-swap server. Used for lane classification:
|
||||
* a contestant whose model is in this set runs in the local lane (serial, GPU-fair).
|
||||
* Q&A contestants are always local regardless of this set.
|
||||
* Defaults to an empty set → all coding contestants go to the cloud lane.
|
||||
*/
|
||||
localModels?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCAL_MODELS: ReadonlySet<string> = new Set();
|
||||
|
||||
export function createBattleRunner(deps: Deps): BattleRunner {
|
||||
const { sql, broker, log, dispatch, onBattleComplete, onCrossExamStart } = deps;
|
||||
const localModels = deps.localModels ?? DEFAULT_LOCAL_MODELS;
|
||||
|
||||
// Serialize local-lane advance per battle so two near-simultaneous terminal
|
||||
// callbacks don't double-dispatch the next local contestant.
|
||||
const advanceChain = new Map<string, Promise<void>>();
|
||||
|
||||
// Delta bridge: per-contestant broker unsubscribe functions.
|
||||
// 'terminated' sentinel prevents a late-arriving setupDeltaBridge from
|
||||
// registering a subscription that would never be cleaned up.
|
||||
const deltaUnsubs = new Map<string, (() => void) | 'terminated'>();
|
||||
|
||||
function publishUser(frame: Record<string, unknown>): void {
|
||||
broker.publishUserFrame('default', frame as unknown as WsFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the contestant's inference session and forward delta frames
|
||||
* to the user channel as contestant_updated{delta}. Polls for session_id
|
||||
* when not immediately known (coding contestants whose session is created
|
||||
* lazily by the dispatcher). Unsubscribes on termination or max retries.
|
||||
*/
|
||||
async function setupDeltaBridge(
|
||||
battleId: string,
|
||||
contestantId: string,
|
||||
taskId: string,
|
||||
knownSessionId: string | null,
|
||||
): Promise<void> {
|
||||
let sessionId = knownSessionId;
|
||||
if (!sessionId) {
|
||||
// Coding contestant: session_id is written by the dispatcher just before
|
||||
// inference starts. Poll until it appears or the contestant terminates.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (deltaUnsubs.get(contestantId) === 'terminated') return;
|
||||
const [row] = await sql<{ session_id: string | null }[]>`
|
||||
SELECT session_id FROM tasks WHERE id = ${taskId}
|
||||
`.catch(() => []);
|
||||
if (row?.session_id) { sessionId = row.session_id; break; }
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
}
|
||||
if (!sessionId) return;
|
||||
if (deltaUnsubs.get(contestantId) === 'terminated') return;
|
||||
|
||||
const unsub = broker.subscribe(sessionId, (frame) => {
|
||||
if (frame.type === 'delta') {
|
||||
const deltaContent = (frame as unknown as { content?: unknown }).content;
|
||||
if (typeof deltaContent === 'string') {
|
||||
publishUser({
|
||||
type: 'contestant_updated',
|
||||
battle_id: battleId,
|
||||
contestant_id: contestantId,
|
||||
delta: deltaContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const existing = deltaUnsubs.get(contestantId);
|
||||
if (existing === 'terminated') {
|
||||
unsub();
|
||||
} else {
|
||||
deltaUnsubs.set(contestantId, unsub);
|
||||
}
|
||||
}
|
||||
|
||||
function teardownDeltaBridge(contestantId: string): void {
|
||||
const entry = deltaUnsubs.get(contestantId);
|
||||
if (typeof entry === 'function') {
|
||||
entry();
|
||||
deltaUnsubs.delete(contestantId);
|
||||
} else {
|
||||
deltaUnsubs.set(contestantId, 'terminated');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── startBattle ────────────────────────────────────────────────────────────
|
||||
|
||||
async function startBattle(opts: BattleStartOpts): Promise<{ battleId: string }> {
|
||||
if (opts.contestants.length < 2 || opts.contestants.length > 6) {
|
||||
throw new Error(`battle requires 2–6 contestants; got ${opts.contestants.length}`);
|
||||
}
|
||||
|
||||
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${opts.projectId}`;
|
||||
if (!proj) throw new Error(`project not found: ${opts.projectId}`);
|
||||
|
||||
// Insert the battle row as 'running'; update results_path once we have the id.
|
||||
const [battle] = await sql<{ id: string; created_at: Date }[]>`
|
||||
INSERT INTO battles (project_id, battle_type, prompt, status)
|
||||
VALUES (${opts.projectId}, ${opts.battleType}, ${opts.prompt}, 'running')
|
||||
RETURNING id, created_at
|
||||
`;
|
||||
const battleId = battle!.id;
|
||||
const battleSlug = buildBattleSlug(battleId, opts.battleType, battle!.created_at);
|
||||
const resultsPath = join(proj.path, 'Arena', battleSlug);
|
||||
|
||||
await sql`
|
||||
UPDATE battles SET results_path = ${resultsPath}, updated_at = clock_timestamp()
|
||||
WHERE id = ${battleId}
|
||||
`;
|
||||
|
||||
// Insert all contestant rows with lane classification.
|
||||
const contestantRows: Array<{ id: string; identity: string; model: string; lane: ContestantLane }> = [];
|
||||
for (const spec of opts.contestants) {
|
||||
const lane = classifyLane(opts.battleType, spec.identity, spec.model, localModels);
|
||||
const [row] = await sql<{ id: string }[]>`
|
||||
INSERT INTO contestants (battle_id, identity, model, lane, status)
|
||||
VALUES (${battleId}, ${spec.identity}, ${spec.model}, ${lane}, 'queued')
|
||||
RETURNING id
|
||||
`;
|
||||
contestantRows.push({ id: row!.id, identity: spec.identity, model: spec.model, lane });
|
||||
}
|
||||
|
||||
// Write initial manifest so the results folder is always populated.
|
||||
await writeManifest(
|
||||
battleId, resultsPath, opts.battleType, opts.prompt, battle!.created_at,
|
||||
contestantRows.map((c) => ({ identity: c.identity, model: c.model, lane: c.lane })),
|
||||
null,
|
||||
).catch((err) => {
|
||||
log.warn({ err: errMsg(err), battleId }, 'arena-runner: initial manifest write failed');
|
||||
});
|
||||
|
||||
publishUser({
|
||||
type: 'battle_started',
|
||||
battle_id: battleId,
|
||||
battle_type: opts.battleType,
|
||||
prompt: opts.prompt,
|
||||
contestants: contestantRows.map((c) => ({
|
||||
id: c.id,
|
||||
identity: c.identity,
|
||||
model: c.model,
|
||||
lane: c.lane,
|
||||
})),
|
||||
});
|
||||
|
||||
// Dispatch: cloud lane starts all contestants in parallel; local lane starts
|
||||
// only the first queued contestant (serial queue).
|
||||
let localStarted = false;
|
||||
for (const c of contestantRows) {
|
||||
if (c.lane === 'cloud') {
|
||||
await dispatchContestant(battleId, opts.projectId, opts.battleType, opts.prompt, c);
|
||||
} else if (!localStarted) {
|
||||
await dispatchContestant(battleId, opts.projectId, opts.battleType, opts.prompt, c);
|
||||
localStarted = true;
|
||||
// remaining local contestants stay 'queued' until this one finishes
|
||||
}
|
||||
}
|
||||
|
||||
return { battleId };
|
||||
}
|
||||
|
||||
async function dispatchContestant(
|
||||
battleId: string,
|
||||
projectId: string,
|
||||
battleType: BattleType,
|
||||
prompt: string,
|
||||
c: { id: string; identity: string; model: string; lane: ContestantLane },
|
||||
): Promise<void> {
|
||||
const { taskId, sessionId } = await dispatch({
|
||||
projectId,
|
||||
contestantId: c.id,
|
||||
prompt,
|
||||
identity: c.identity,
|
||||
model: c.model,
|
||||
battleType,
|
||||
});
|
||||
await sql`
|
||||
UPDATE contestants
|
||||
SET task_id = ${taskId}, status = 'running', updated_at = clock_timestamp()
|
||||
WHERE id = ${c.id}
|
||||
`;
|
||||
publishContestantFrame(battleId, c.id, { status: 'running' });
|
||||
// Start the delta bridge in the background; unsubscribe when the contestant
|
||||
// terminates (teardownDeltaBridge called in handleTaskTerminal).
|
||||
void setupDeltaBridge(battleId, c.id, taskId, sessionId ?? null);
|
||||
}
|
||||
|
||||
// ─── local-lane advance (serialized per battle) ───────────────────────────
|
||||
|
||||
function advanceLocalLane(battleId: string): Promise<void> {
|
||||
const prev = advanceChain.get(battleId) ?? Promise.resolve();
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() =>
|
||||
advanceLocalLaneInner(battleId).catch((err) => {
|
||||
log.error({ err: errMsg(err), battleId }, 'arena-runner: advanceLocalLane failed');
|
||||
}),
|
||||
);
|
||||
advanceChain.set(battleId, next);
|
||||
void next.finally(() => {
|
||||
if (advanceChain.get(battleId) === next) advanceChain.delete(battleId);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
async function advanceLocalLaneInner(battleId: string): Promise<void> {
|
||||
const battle = await loadBattle(battleId);
|
||||
if (!battle || battle.status !== 'running') return;
|
||||
|
||||
const contestants = await loadContestants(battleId);
|
||||
const slots: ContestantSlot[] = contestants.map((c) => ({
|
||||
id: c.id,
|
||||
lane: c.lane,
|
||||
status: c.status,
|
||||
}));
|
||||
|
||||
// Nothing to do if the local lane is still busy.
|
||||
const localRunning = slots.some((c) => c.lane === 'local' && c.status === 'running');
|
||||
if (localRunning) return;
|
||||
|
||||
const nextId = nextLocalContestant(slots);
|
||||
if (!nextId) return; // local queue is exhausted
|
||||
|
||||
const next = contestants.find((c) => c.id === nextId)!;
|
||||
await dispatchContestant(battleId, battle.project_id, battle.battle_type, battle.prompt, {
|
||||
id: next.id,
|
||||
identity: next.identity,
|
||||
model: next.model,
|
||||
lane: next.lane,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── handleTaskTerminal ───────────────────────────────────────────────────
|
||||
|
||||
function handleTaskTerminal(taskId: string, state: string): void {
|
||||
void (async () => {
|
||||
// Look up which contestant owns this task (contestants_task_id_idx).
|
||||
const [row] = await sql<ContestantRow[]>`
|
||||
SELECT id, battle_id, identity, model, lane, task_id, worktree_id, status
|
||||
FROM contestants WHERE task_id = ${taskId}
|
||||
`;
|
||||
if (!row) return; // not an arena task — ignore
|
||||
if (row.status !== 'running') return; // already settled (idempotent)
|
||||
|
||||
const battle = await loadBattle(row.battle_id);
|
||||
|
||||
// Pull the task row for benchmark + output.
|
||||
const [task] = await sql<{
|
||||
chat_id: string | null;
|
||||
started_at: Date | null;
|
||||
ended_at: Date | null;
|
||||
cost_tokens: number | null;
|
||||
}[]>`SELECT chat_id, started_at, ended_at, cost_tokens FROM tasks WHERE id = ${taskId}`;
|
||||
|
||||
const endedAt = task?.ended_at ?? new Date();
|
||||
|
||||
if (state === 'completed') {
|
||||
const startedAt = task?.started_at ?? endedAt;
|
||||
const bench = computeBenchmark(startedAt, endedAt, task?.cost_tokens ?? null, row.lane);
|
||||
|
||||
const output = task?.chat_id ? await readChatOutput(task.chat_id) : '';
|
||||
|
||||
const resultPath = battle
|
||||
? await writeContestantResults(battle, row, output, bench).catch((err) => {
|
||||
log.warn({ err: errMsg(err), contestantId: row.id }, 'arena-runner: result write failed');
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
await sql`
|
||||
UPDATE contestants
|
||||
SET status = 'done',
|
||||
duration_ms = ${Math.round(bench.durationMs)},
|
||||
tokens_per_sec = ${bench.tokensPerSec},
|
||||
cost_tokens = ${task?.cost_tokens ?? null},
|
||||
result_path = ${resultPath},
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${row.id} AND status = 'running'
|
||||
`;
|
||||
teardownDeltaBridge(row.id);
|
||||
|
||||
// Check if this was the last contestant.
|
||||
const allContestants = await loadContestants(row.battle_id);
|
||||
const battleDone = isBattleComplete(allContestants);
|
||||
|
||||
publishContestantFrame(row.battle_id, row.id, {
|
||||
status: 'done',
|
||||
duration_ms: Math.round(bench.durationMs),
|
||||
...(bench.tokensPerSec !== null ? { tokens_per_sec: bench.tokensPerSec } : {}),
|
||||
...(battleDone ? { battle_status: 'completed' } : {}),
|
||||
});
|
||||
|
||||
if (battleDone) {
|
||||
await completeBattle(row.battle_id);
|
||||
} else if (row.lane === 'local') {
|
||||
void advanceLocalLane(row.battle_id);
|
||||
}
|
||||
} else {
|
||||
// failed or cancelled — the contest continues; this contestant is error.
|
||||
const errorMsg = state === 'cancelled' ? 'cancelled' : `task ${state}`;
|
||||
await sql`
|
||||
UPDATE contestants
|
||||
SET status = 'error', error = ${errorMsg}, updated_at = clock_timestamp()
|
||||
WHERE id = ${row.id} AND status = 'running'
|
||||
`;
|
||||
teardownDeltaBridge(row.id);
|
||||
|
||||
const allContestants = await loadContestants(row.battle_id);
|
||||
const battleDone = isBattleComplete(allContestants);
|
||||
|
||||
publishContestantFrame(row.battle_id, row.id, {
|
||||
status: 'error',
|
||||
error: errorMsg,
|
||||
...(battleDone ? { battle_status: 'completed' } : {}),
|
||||
});
|
||||
|
||||
if (battleDone) {
|
||||
await completeBattle(row.battle_id);
|
||||
} else if (row.lane === 'local') {
|
||||
void advanceLocalLane(row.battle_id);
|
||||
}
|
||||
}
|
||||
})().catch((err) => {
|
||||
log.error({ err: errMsg(err), taskId }, 'arena-runner: handleTaskTerminal failed');
|
||||
});
|
||||
}
|
||||
|
||||
// ─── battle finalization ──────────────────────────────────────────────────
|
||||
|
||||
async function completeBattle(battleId: string): Promise<void> {
|
||||
const updated = await sql`
|
||||
UPDATE battles SET status = 'completed', updated_at = clock_timestamp()
|
||||
WHERE id = ${battleId} AND status = 'running'
|
||||
`;
|
||||
if (updated.count === 0) return; // already terminal (race guard)
|
||||
log.info({ battleId }, 'arena-runner: battle completed');
|
||||
|
||||
// Update manifest with finished_at timestamp.
|
||||
const completedBattle = await loadBattle(battleId);
|
||||
if (completedBattle?.results_path) {
|
||||
const contestants = await loadContestants(battleId);
|
||||
await writeManifest(
|
||||
battleId,
|
||||
completedBattle.results_path,
|
||||
completedBattle.battle_type,
|
||||
completedBattle.prompt,
|
||||
completedBattle.created_at,
|
||||
contestants.map((c) => ({ identity: c.identity, model: c.model, lane: c.lane })),
|
||||
new Date(),
|
||||
).catch((err) => {
|
||||
log.warn({ err: errMsg(err), battleId }, 'arena-runner: manifest update failed');
|
||||
});
|
||||
}
|
||||
|
||||
onBattleComplete(battleId);
|
||||
}
|
||||
|
||||
// ─── manifest writer ─────────────────────────────────────────────────────
|
||||
|
||||
async function writeManifest(
|
||||
battleId: string,
|
||||
resultsPath: string,
|
||||
battleType: BattleType,
|
||||
prompt: string,
|
||||
createdAt: Date,
|
||||
contestants: Array<{ identity: string; model: string; lane: ContestantLane }>,
|
||||
finishedAt: Date | null,
|
||||
): Promise<void> {
|
||||
await mkdir(resultsPath, { recursive: true });
|
||||
const manifest = {
|
||||
id: battleId,
|
||||
battle_type: battleType,
|
||||
prompt,
|
||||
contestants,
|
||||
created_at: createdAt.toISOString(),
|
||||
finished_at: finishedAt?.toISOString() ?? null,
|
||||
};
|
||||
await writeFile(join(resultsPath, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// ─── results writer ───────────────────────────────────────────────────────
|
||||
|
||||
async function writeContestantResults(
|
||||
battle: BattleRow,
|
||||
contestant: { identity: string; model: string; lane: ContestantLane; worktree_id: string | null },
|
||||
output: string,
|
||||
bench: { durationMs: number; tokensPerSec: number | null },
|
||||
): Promise<string> {
|
||||
const resultsPath = await getOrBuildResultsPath(battle);
|
||||
if (!resultsPath) throw new Error('cannot resolve results path for battle ' + battle.id);
|
||||
|
||||
const contestantDir = buildContestantDir(contestant.identity, contestant.model);
|
||||
const dir = join(resultsPath, contestantDir);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const benchLines = [
|
||||
`duration: ${bench.durationMs}ms`,
|
||||
bench.tokensPerSec != null ? `tokens/sec: ${bench.tokensPerSec.toFixed(1)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const resultMd =
|
||||
`# ${contestant.identity} / ${contestant.model}\n\n` +
|
||||
`## Benchmark\n\n${benchLines}\n\n` +
|
||||
`## Output\n\n${output}\n`;
|
||||
await writeFile(join(dir, 'result.md'), resultMd, 'utf8');
|
||||
|
||||
if (battle.battle_type === 'coding' && contestant.worktree_id) {
|
||||
const [wt] = await sql<{ path: string; base_commit: string | null }[]>`
|
||||
SELECT path, base_commit FROM worktrees WHERE id = ${contestant.worktree_id}
|
||||
`;
|
||||
if (wt) {
|
||||
const [proj] = await sql<{ path: string }[]>`
|
||||
SELECT path FROM projects WHERE id = ${battle.project_id}
|
||||
`;
|
||||
if (proj) {
|
||||
const diff = await diffWorktree(wt.path, proj.path, {
|
||||
baseRef: wt.base_commit ?? undefined,
|
||||
}).catch(() => '');
|
||||
await writeFile(join(dir, 'diff.patch'), diff, 'utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Resolve or rebuild results_path for a battle (handles crash-before-UPDATE). */
|
||||
async function getOrBuildResultsPath(battle: BattleRow): Promise<string | null> {
|
||||
if (battle.results_path) return battle.results_path;
|
||||
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${battle.project_id}`;
|
||||
if (!proj) return null;
|
||||
const slug = buildBattleSlug(battle.id, battle.battle_type, battle.created_at);
|
||||
const resultsPath = join(proj.path, 'Arena', slug);
|
||||
await sql`
|
||||
UPDATE battles SET results_path = ${resultsPath}, updated_at = clock_timestamp()
|
||||
WHERE id = ${battle.id}
|
||||
`;
|
||||
return resultsPath;
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function readChatOutput(chatId: string): Promise<string> {
|
||||
const [m] = await sql<{ content: string | null }[]>`
|
||||
SELECT content FROM messages
|
||||
WHERE chat_id = ${chatId} AND role = 'assistant'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`;
|
||||
return m?.content ?? '';
|
||||
}
|
||||
|
||||
async function loadBattle(battleId: string): Promise<BattleRow | null> {
|
||||
const [b] = await sql<BattleRow[]>`
|
||||
SELECT id, project_id, battle_type, prompt, status, results_path, created_at
|
||||
FROM battles WHERE id = ${battleId}
|
||||
`;
|
||||
return b ?? null;
|
||||
}
|
||||
|
||||
async function loadContestants(battleId: string): Promise<ContestantRow[]> {
|
||||
return sql<ContestantRow[]>`
|
||||
SELECT id, battle_id, identity, model, lane, task_id, worktree_id, status
|
||||
FROM contestants WHERE battle_id = ${battleId}
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
}
|
||||
|
||||
function publishContestantFrame(
|
||||
battleId: string,
|
||||
contestantId: string,
|
||||
extra: Record<string, unknown>,
|
||||
): void {
|
||||
publishUser({
|
||||
type: 'contestant_updated',
|
||||
battle_id: battleId,
|
||||
contestant_id: contestantId,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── initResume ───────────────────────────────────────────────────────────
|
||||
|
||||
async function initResume(): Promise<void> {
|
||||
const battles = await sql<BattleRow[]>`
|
||||
SELECT id, project_id, battle_type, prompt, status, results_path, created_at
|
||||
FROM battles WHERE status = 'running'
|
||||
`;
|
||||
if (battles.length === 0) return;
|
||||
log.info({ count: battles.length }, 'arena-runner: resuming in-flight battles on startup');
|
||||
for (const battle of battles) {
|
||||
await resumeBattle(battle).catch((err) => {
|
||||
log.error({ err: errMsg(err), battleId: battle.id }, 'arena-runner: initResume failed for battle');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeBattle(battle: BattleRow): Promise<void> {
|
||||
const contestants = await loadContestants(battle.id);
|
||||
|
||||
const taskIds = contestants.map((c) => c.task_id).filter((id): id is string => id !== null);
|
||||
const taskStates = new Map<string, string>();
|
||||
if (taskIds.length > 0) {
|
||||
const tasks = await sql<{ id: string; state: string }[]>`
|
||||
SELECT id, state FROM tasks WHERE id = ANY(${taskIds})
|
||||
`;
|
||||
for (const t of tasks) taskStates.set(t.id, t.state);
|
||||
}
|
||||
|
||||
const decisions = reconcileContestants(
|
||||
contestants.map((c) => ({ contestantId: c.id, taskId: c.task_id, status: c.status })),
|
||||
taskStates,
|
||||
);
|
||||
|
||||
for (const decision of decisions) {
|
||||
if (decision.action === 'keep') continue;
|
||||
const contestant = contestants.find((c) => c.id === decision.contestantId)!;
|
||||
await applyResumeDecision(battle, contestant, decision.action);
|
||||
}
|
||||
|
||||
// Re-check completion after applying decisions.
|
||||
const updated = await loadContestants(battle.id);
|
||||
if (isBattleComplete(updated)) {
|
||||
await completeBattle(battle.id);
|
||||
} else {
|
||||
// Advance local lane in case a slot opened up.
|
||||
void advanceLocalLane(battle.id);
|
||||
}
|
||||
|
||||
log.info({ battleId: battle.id }, 'arena-runner: battle resumed');
|
||||
}
|
||||
|
||||
async function applyResumeDecision(
|
||||
battle: BattleRow,
|
||||
contestant: ContestantRow,
|
||||
action: ContestantResumeAction,
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'keep': break;
|
||||
|
||||
case 'mark-done': {
|
||||
const taskRow = contestant.task_id
|
||||
? (await sql<{ started_at: Date | null; ended_at: Date | null; cost_tokens: number | null; chat_id: string | null }[]>`
|
||||
SELECT started_at, ended_at, cost_tokens, chat_id FROM tasks WHERE id = ${contestant.task_id}`)[0]
|
||||
: null;
|
||||
const endedAt = taskRow?.ended_at ?? new Date();
|
||||
const startedAt = taskRow?.started_at ?? endedAt;
|
||||
const bench = computeBenchmark(startedAt, endedAt, taskRow?.cost_tokens ?? null, contestant.lane);
|
||||
const output = taskRow?.chat_id ? await readChatOutput(taskRow.chat_id) : '';
|
||||
const resultPath = battle
|
||||
? await writeContestantResults(battle, contestant, output, bench).catch((err) => {
|
||||
log.warn({ err: errMsg(err), contestantId: contestant.id }, 'arena-runner: resume result write failed');
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
await sql`
|
||||
UPDATE contestants
|
||||
SET status = 'done',
|
||||
duration_ms = ${Math.round(bench.durationMs)},
|
||||
tokens_per_sec = ${bench.tokensPerSec},
|
||||
result_path = ${resultPath},
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${contestant.id}
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mark-error':
|
||||
await sql`
|
||||
UPDATE contestants
|
||||
SET status = 'error', error = 'task failed before callback',
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${contestant.id}
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'mark-cancelled':
|
||||
await sql`
|
||||
UPDATE contestants
|
||||
SET status = 'error', error = 'cancelled before callback',
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${contestant.id}
|
||||
`;
|
||||
break;
|
||||
|
||||
case 're-dispatch': {
|
||||
const { taskId } = await dispatch({
|
||||
projectId: battle.project_id,
|
||||
contestantId: contestant.id,
|
||||
prompt: battle.prompt,
|
||||
identity: contestant.identity,
|
||||
model: contestant.model,
|
||||
battleType: battle.battle_type,
|
||||
});
|
||||
await sql`
|
||||
UPDATE contestants
|
||||
SET task_id = ${taskId}, updated_at = clock_timestamp()
|
||||
WHERE id = ${contestant.id}
|
||||
`;
|
||||
log.info(
|
||||
{ battleId: battle.id, contestantId: contestant.id, taskId },
|
||||
'arena-runner: contestant re-dispatched on resume',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── cancelBattle ─────────────────────────────────────────────────────────
|
||||
|
||||
async function cancelBattle(battleId: string): Promise<{ cancelled: boolean; taskIds: string[] }> {
|
||||
const updated = await sql`
|
||||
UPDATE battles SET status = 'cancelled', updated_at = clock_timestamp()
|
||||
WHERE id = ${battleId} AND status = 'running'
|
||||
`;
|
||||
if (updated.count === 0) return { cancelled: false, taskIds: [] };
|
||||
|
||||
// Mark all non-terminal contestants cancelled and collect in-flight task_ids.
|
||||
const contestants = await sql<{ id: string; task_id: string | null; status: string }[]>`
|
||||
SELECT id, task_id, status FROM contestants
|
||||
WHERE battle_id = ${battleId} AND status NOT IN ('done', 'error')
|
||||
`;
|
||||
|
||||
if (contestants.length > 0) {
|
||||
await sql`
|
||||
UPDATE contestants
|
||||
SET status = 'error', error = 'battle cancelled', updated_at = clock_timestamp()
|
||||
WHERE battle_id = ${battleId} AND status NOT IN ('done', 'error')
|
||||
`;
|
||||
for (const c of contestants) {
|
||||
publishContestantFrame(battleId, c.id, {
|
||||
status: 'error',
|
||||
error: 'battle cancelled',
|
||||
battle_status: 'cancelled',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const taskIds = contestants
|
||||
.filter(
|
||||
(c): c is typeof c & { task_id: string } =>
|
||||
c.task_id !== null && c.status === 'running',
|
||||
)
|
||||
.map((c) => c.task_id);
|
||||
|
||||
log.info({ battleId }, 'arena-runner: battle cancelled by request');
|
||||
return { cancelled: true, taskIds };
|
||||
}
|
||||
|
||||
// ─── triggerAnalysis (Phase 5 seam) ──────────────────────────────────────
|
||||
|
||||
async function triggerAnalysis(battleId: string): Promise<{ triggered: boolean }> {
|
||||
const battle = await loadBattle(battleId);
|
||||
if (!battle) return { triggered: false };
|
||||
log.info({ battleId }, 'arena-runner: triggerAnalysis requested');
|
||||
// Calls the injected onBattleComplete seam — Phase 5 replaces this with the
|
||||
// real two-stage digest→judge analyzer (see ADR 0002 + plan Phase 5).
|
||||
onBattleComplete(battleId);
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
// ─── startCrossExam (Phase 5 seam) ───────────────────────────────────────
|
||||
|
||||
async function startCrossExam(
|
||||
battleId: string,
|
||||
opts: { identity: string; model: string },
|
||||
): Promise<{ crossExamId: string }> {
|
||||
const [row] = await sql<{ id: string }[]>`
|
||||
INSERT INTO cross_examinations (battle_id, identity, model)
|
||||
VALUES (${battleId}, ${opts.identity}, ${opts.model})
|
||||
RETURNING id
|
||||
`;
|
||||
const crossExamId = row!.id;
|
||||
log.info({ battleId, crossExamId, ...opts }, 'arena-runner: cross-exam inserted, triggering analyzer');
|
||||
if (onCrossExamStart) {
|
||||
try {
|
||||
onCrossExamStart({ battleId, crossExamId, identity: opts.identity, model: opts.model });
|
||||
} catch (err) {
|
||||
log.error({ err: err instanceof Error ? err.message : String(err), battleId, crossExamId }, 'arena-runner: onCrossExamStart threw');
|
||||
}
|
||||
}
|
||||
return { crossExamId };
|
||||
}
|
||||
|
||||
// ─── setWinner (user override) ────────────────────────────────────────────
|
||||
|
||||
async function setWinner(
|
||||
battleId: string,
|
||||
winnerId: string | null,
|
||||
): Promise<{ ok: boolean; notFound?: boolean; invalidContestant?: boolean }> {
|
||||
const [row] = await sql<{ id: string }[]>`SELECT id FROM battles WHERE id = ${battleId}`;
|
||||
if (!row) return { ok: false, notFound: true };
|
||||
|
||||
if (winnerId !== null) {
|
||||
const [c] = await sql<{ id: string }[]>`
|
||||
SELECT id FROM contestants WHERE id = ${winnerId} AND battle_id = ${battleId}
|
||||
`;
|
||||
if (!c) return { ok: false, invalidContestant: true };
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE battles SET winner_contestant_id = ${winnerId}, updated_at = clock_timestamp()
|
||||
WHERE id = ${battleId}
|
||||
`;
|
||||
publishUser({ type: 'battle_updated', battle_id: battleId, winner_contestant_id: winnerId });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return { startBattle, handleTaskTerminal, initResume, cancelBattle, triggerAnalysis, startCrossExam, setWinner };
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
@@ -12,12 +12,48 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { AgentCommand } from './provider-types.js';
|
||||
|
||||
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
|
||||
/**
|
||||
* Frontmatter reader between `---` fences. Handles single-line `key: value`
|
||||
* AND YAML block scalars (`key: >` folded / `key: |` literal) whose value
|
||||
* spans the following more-indented lines — the shape most plugin SKILL.md
|
||||
* descriptions use (`description: >`).
|
||||
*/
|
||||
function frontmatterField(content: string, field: string): string | undefined {
|
||||
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!block?.[1]) return undefined;
|
||||
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
|
||||
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
|
||||
const lines = block[1].split(/\r?\n/);
|
||||
const keyRe = new RegExp(`^(\\s*)${field}:\\s*(.*)$`);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i]?.match(keyRe);
|
||||
if (!m) continue;
|
||||
const keyIndent = (m[1] ?? '').length;
|
||||
const inline = (m[2] ?? '').trim();
|
||||
// Block scalar: `>` (folded) or `|` (literal), optional chomping `+`/`-`.
|
||||
if (/^[>|][+-]?$/.test(inline)) {
|
||||
const folded = inline[0] === '>';
|
||||
const body: string[] = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const line = lines[j] ?? '';
|
||||
if (line.trim() === '') {
|
||||
body.push('');
|
||||
continue;
|
||||
}
|
||||
const indent = line.length - line.trimStart().length;
|
||||
if (indent <= keyIndent) break; // dedent ends the block
|
||||
body.push(line.slice(keyIndent + 1));
|
||||
}
|
||||
const joined = folded
|
||||
? body
|
||||
.map((l) => l.trim())
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
: body.join('\n').replace(/\n+$/, '');
|
||||
return joined || undefined;
|
||||
}
|
||||
return inline.replace(/^["']|["']$/g, '').trim() || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readCommandDir(dir: string): AgentCommand[] {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||
import { asPermissionMode } from './tools/types.js';
|
||||
import { createCheckpoint } from './checkpoints.js';
|
||||
import { makeDcpStreamStripper } from './dcp-strip.js';
|
||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||
@@ -31,7 +32,13 @@ import {
|
||||
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
enqueue: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
user: string,
|
||||
permissionMode?: 'plan' | 'ask' | 'bypass',
|
||||
) => void;
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
@@ -305,10 +312,13 @@ export function createDispatcher(deps: Deps): {
|
||||
|
||||
// ─── Path A: Native Inference ───────────────────────────────────────────────
|
||||
|
||||
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
|
||||
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; mode_id: string | null; session_id: string | null }): Promise<void> {
|
||||
const taskId = task.id;
|
||||
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
|
||||
|
||||
// Declared before try so the catch block can write it back on the task row.
|
||||
let chatId: string | null = null;
|
||||
|
||||
try {
|
||||
// Mark running
|
||||
await sql`
|
||||
@@ -317,26 +327,29 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
|
||||
// Create session + chat for this task
|
||||
// Session setup: reuse a pre-created session (e.g. Q&A arena contestants
|
||||
// whose persona is stamped on the session via agent_id) or create a fresh one.
|
||||
const model = task.model ?? config.DEFAULT_MODEL;
|
||||
const sessionName = 'Task: ' + task.input.slice(0, 40);
|
||||
|
||||
const [session] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status)
|
||||
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
const sessionId = session!.id;
|
||||
let sessionId: string;
|
||||
if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
} else {
|
||||
const sessionName = 'Task: ' + task.input.slice(0, 40);
|
||||
const [session] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status)
|
||||
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
sessionId = session!.id;
|
||||
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||
}
|
||||
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'Task execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
const chatId = chat!.id;
|
||||
|
||||
// Link task to session
|
||||
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||
chatId = chat!.id;
|
||||
|
||||
// Create user message + streaming assistant
|
||||
await sql<{ id: string }[]>`
|
||||
@@ -351,8 +364,9 @@ export function createDispatcher(deps: Deps): {
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
|
||||
// Enqueue inference
|
||||
inference.enqueue(sessionId, chatId, assistantId, 'default');
|
||||
// Enqueue inference — pass the native permission gate (plan/ask/bypass)
|
||||
// through to the write-tool context. Non-unified mode ids → undefined.
|
||||
inference.enqueue(sessionId, chatId, assistantId, 'default', asPermissionMode(task.mode_id));
|
||||
|
||||
// Wait for inference to complete (poll message status)
|
||||
const finalStatus = await waitForCompletion(assistantId);
|
||||
@@ -381,7 +395,7 @@ export function createDispatcher(deps: Deps): {
|
||||
const summary = (msg?.content ?? '').slice(0, 500);
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
|
||||
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}, chat_id = ${chatId}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||
@@ -392,7 +406,7 @@ export function createDispatcher(deps: Deps): {
|
||||
const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}, chat_id = ${chatId}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
||||
@@ -402,7 +416,7 @@ export function createDispatcher(deps: Deps): {
|
||||
log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)');
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}, chat_id = ${chatId}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
}
|
||||
|
||||
47
apps/coder/src/services/edit-guards-imports.ts
Normal file
47
apps/coder/src/services/edit-guards-imports.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// edit-guards-imports — detects dropped imports in edited files.
|
||||
// Ported from opencode-morph-fast-apply (MIT).
|
||||
|
||||
export interface ImportCheckResult {
|
||||
ok: boolean;
|
||||
missingImports: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const IMPORT_PATTERNS = [
|
||||
/^import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||
/^import\s+['"][^'"]+['"]\s*;?$/m,
|
||||
/^export\s+.*\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||
/^require\s*\(\s*['"][^'"]+['"]\s*\)\s*;?$/m,
|
||||
/^import\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||
];
|
||||
|
||||
function extractImportLines(content: string): string[] {
|
||||
return content.split('\n').filter((line) =>
|
||||
IMPORT_PATTERNS.some((p) => p.test(line.trim())),
|
||||
);
|
||||
}
|
||||
|
||||
export function checkDroppedImports(
|
||||
original: string,
|
||||
updated: string,
|
||||
filePath: string,
|
||||
): ImportCheckResult {
|
||||
const originalImports = extractImportLines(original);
|
||||
const updatedImports = extractImportLines(updated);
|
||||
|
||||
if (originalImports.length === 0) {
|
||||
return { ok: true, missingImports: [] };
|
||||
}
|
||||
|
||||
const missing = originalImports.filter((imp) => !updatedImports.includes(imp));
|
||||
|
||||
if (missing.length > 0 && originalImports.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
missingImports: missing,
|
||||
reason: `Edit would drop ${missing.length} import(s) from ${filePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, missingImports: [] };
|
||||
}
|
||||
42
apps/coder/src/services/edit-guards.ts
Normal file
42
apps/coder/src/services/edit-guards.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// v2.8 Morph safety guards — prevents catastrophic truncation, marker leakage,
|
||||
// and accidental import deletion during native edit_file application.
|
||||
// Ported from opencode-morph-fast-apply (MIT) with threshold values preserved.
|
||||
|
||||
export interface GuardResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
charLoss?: number;
|
||||
lineLoss?: number;
|
||||
}
|
||||
|
||||
const TRUNCATION_CHAR_THRESHOLD = 0.6;
|
||||
const TRUNCATION_LINE_THRESHOLD = 0.5;
|
||||
|
||||
export function validateEditResult(
|
||||
original: string,
|
||||
updated: string,
|
||||
filePath: string,
|
||||
): GuardResult {
|
||||
// Check for catastrophic content truncation
|
||||
if (original.length > 0 && updated.length > 0) {
|
||||
const charLoss = 1 - updated.length / original.length;
|
||||
const originalLines = original.split('\n').length;
|
||||
const updatedLines = updated.split('\n').length;
|
||||
const lineLoss = 1 - updatedLines / originalLines;
|
||||
|
||||
if (charLoss > TRUNCATION_CHAR_THRESHOLD && lineLoss > TRUNCATION_LINE_THRESHOLD) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Edit would truncate ${Math.round(charLoss * 100)}% of characters and ${Math.round(lineLoss * 100)}% of lines`,
|
||||
charLoss,
|
||||
lineLoss,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function formatGuardError(guard: GuardResult, filePath: string): string {
|
||||
return `Edit guard rejected change to ${filePath}: ${guard.reason ?? 'unknown error'}`;
|
||||
}
|
||||
23
apps/coder/src/services/flow-artifacts.ts
Normal file
23
apps/coder/src/services/flow-artifacts.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const ARTIFACTS_ROOT = 'data/flow-artifacts';
|
||||
|
||||
export function getArtifactPath(flowRunId: string, stepId: string): string {
|
||||
return join(ARTIFACTS_ROOT, flowRunId, `${stepId}.md`);
|
||||
}
|
||||
|
||||
export async function writeFlowArtifact(
|
||||
flowRunId: string,
|
||||
stepId: string,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const dir = join(ARTIFACTS_ROOT, flowRunId);
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
const path = getArtifactPath(flowRunId, stepId);
|
||||
await writeFile(path, content, 'utf8');
|
||||
return path;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
* "Settled" = done ∪ skipped ∪ excluded. Only settled deps unblock a step;
|
||||
* an inFlight dep does NOT (the runner waits for its terminal callback).
|
||||
*/
|
||||
import type { Flow, Step, StepContext } from '../conductor/types.js';
|
||||
import type { Flow, Step, StepContext, TriggerRule } from '../conductor/types.js';
|
||||
|
||||
export interface SchedulerState {
|
||||
/** step ids that completed successfully (results available) */
|
||||
@@ -62,7 +62,7 @@ export function readySteps(flow: Flow, state: SchedulerState): Step[] {
|
||||
!state.skipped.has(s.id) &&
|
||||
!state.inFlight.has(s.id) &&
|
||||
!state.excluded.has(s.id) &&
|
||||
(s.deps ?? []).every((d) => isSatisfied(state, d)),
|
||||
((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,32 @@ export function shouldFailOnMissingAgent(agent: string, modeId: string | null):
|
||||
return agent === 'qwen' && modeId === 'plan';
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a trigger rule against dependency results.
|
||||
* - all_success: every dep must be done (not skipped/failed)
|
||||
* - one_success: at least one dep must be done
|
||||
* - all_done: every dep must be settled regardless of outcome
|
||||
*/
|
||||
export function evaluateTriggerRule(
|
||||
deps: string[],
|
||||
done: ReadonlySet<string>,
|
||||
skipped: ReadonlySet<string>,
|
||||
excluded: ReadonlySet<string>,
|
||||
rule?: TriggerRule,
|
||||
): boolean {
|
||||
if (deps.length === 0) return true;
|
||||
const satisfied = new Set([...done, ...skipped, ...excluded]);
|
||||
|
||||
switch (rule ?? 'all_success') {
|
||||
case 'all_success':
|
||||
return deps.every((d) => done.has(d) || skipped.has(d) || excluded.has(d));
|
||||
case 'one_success':
|
||||
return deps.some((d) => done.has(d));
|
||||
case 'all_done':
|
||||
return deps.every((d) => satisfied.has(d));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile every step of an in-flight run for startup resume. Returns one
|
||||
* decision per step. Pure — no IO.
|
||||
|
||||
@@ -346,6 +346,20 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
continue; // re-evaluate — code output can unblock the next wave
|
||||
}
|
||||
|
||||
// Approval gate steps: pause and wait for human decision.
|
||||
const approvalReady = toRun.filter((s) => s.kind === 'approval');
|
||||
if (approvalReady.length > 0) {
|
||||
for (const s of approvalReady) {
|
||||
await sql`
|
||||
UPDATE flow_steps SET status = 'blocked', updated_at = clock_timestamp()
|
||||
WHERE run_id = ${runId} AND step_id = ${s.id}
|
||||
`;
|
||||
await appendStepEvent(sql, runId, s.id, 'paused', { reason: 'awaiting approval' });
|
||||
publishStep(runId, s.id, 'blocked');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only agent steps remain ready → dispatch the whole parallel wave, then wait.
|
||||
for (const s of toRun) {
|
||||
await dispatchAgentStep(runId, run.project_id, model, s, ctx);
|
||||
@@ -378,7 +392,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
// flow's step.run already bakes in the evidence/YAGNI contracts.
|
||||
const persona = step.agent ? await loadPersona(step.agent) : '';
|
||||
const taskPrompt = await step.run(ctx);
|
||||
const fullPrompt = persona ? `${persona}\n\n---\n\n${taskPrompt}` : taskPrompt;
|
||||
const resolvedPrompt = resolveVariables(taskPrompt, ctx.results);
|
||||
const fullPrompt = persona ? `${persona}\n\n---\n\n${resolvedPrompt}` : resolvedPrompt;
|
||||
|
||||
// READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never
|
||||
// user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate.
|
||||
@@ -392,6 +407,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp()
|
||||
WHERE run_id = ${runId} AND step_id = ${step.id}
|
||||
`;
|
||||
await appendStepEvent(sql, runId, step.id, 'started', { taskId: task!.id });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,6 +454,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
WHERE run_id = ${runId} AND step_id = ${stepId}
|
||||
`;
|
||||
}
|
||||
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
|
||||
}
|
||||
|
||||
// ─── run completion ─────────────────────────────────────────────────────────
|
||||
@@ -483,6 +500,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
if (updated.count === 0) return;
|
||||
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
|
||||
log.warn({ runId, error }, 'flow-runner: run failed');
|
||||
await appendStepEvent(sql, runId, stepId, 'failed', { error });
|
||||
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
|
||||
}
|
||||
|
||||
@@ -522,7 +540,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
function publishStep(
|
||||
runId: string,
|
||||
stepId: string,
|
||||
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled',
|
||||
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked',
|
||||
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
|
||||
): void {
|
||||
publishUser({
|
||||
@@ -763,3 +781,40 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// ─── Event log ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function appendStepEvent(
|
||||
sql: Sql,
|
||||
runId: string,
|
||||
stepId: string,
|
||||
event: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await sql`
|
||||
INSERT INTO flow_step_events (run_id, step_id, event, payload)
|
||||
VALUES (${runId}, ${stepId}, ${event}, ${payload ? sql.json(payload as never) : null})
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Variable substitution ───────────────────────────────────────────────────
|
||||
|
||||
const VAR_PATTERN = /\$(\w+)\.output(?:\.(\w+(?:\.\w+)*))?/g;
|
||||
|
||||
export function resolveVariables(prompt: string, results: Record<string, string>): string {
|
||||
return prompt.replace(VAR_PATTERN, (match, stepId, fieldPath) => {
|
||||
const output = results[stepId];
|
||||
if (!output) return match;
|
||||
if (!fieldPath) return output;
|
||||
try {
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
const parsed = line.match(new RegExp(`^${fieldPath}:\\s*(.+)$`, 'i'));
|
||||
if (parsed) return parsed[1]!.trim();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,16 @@
|
||||
// punctuation to ASCII on both sides; the match is
|
||||
// mapped back to original offsets.
|
||||
// 4. levenshtein — best line-window by normalized edit-distance
|
||||
// similarity; accepted only at >= SIMILARITY_THRESHOLD.
|
||||
// similarity; accepted only at >= SIMILARITY_THRESHOLD,
|
||||
// anchored on an exact first+last line for multi-line
|
||||
// needles, and REFUSED (ambiguous) when a second window
|
||||
// scores within AMBIGUITY_EPSILON of the best. Like the
|
||||
// exact/whitespace tiers, this tier fails CLOSED — it
|
||||
// never splices over a merely-plausible guess, because a
|
||||
// wrong-window splice corrupts the file (it leaves the
|
||||
// real target intact and duplicates it). This mirrors
|
||||
// opencode/cline/qwen, whose fuzzy tiers all keep the
|
||||
// unique-match requirement rather than picking a winner.
|
||||
//
|
||||
// Pure and dependency-free (Levenshtein is the standard iterative two-row DP),
|
||||
// reimplemented from the general technique — no vendored source.
|
||||
@@ -31,8 +40,31 @@ export type MatchResult =
|
||||
| { kind: 'ambiguous'; count: number }
|
||||
| { kind: 'not_found' };
|
||||
|
||||
/** Levenshtein similarity floor for the final fuzzy fallback (strategy 4). */
|
||||
export const SIMILARITY_THRESHOLD = 0.66;
|
||||
/**
|
||||
* Levenshtein similarity floor for the final fuzzy fallback (strategy 4).
|
||||
* 0.66 was far too low — at two-thirds similarity a structurally-wrong window
|
||||
* (e.g. one of three near-identical form blocks) clears the bar and gets spliced
|
||||
* over, leaving the real target intact and duplicated. Competent agents anchor
|
||||
* far tighter (opencode's BlockAnchor needs an exact anchor; cline needs exact
|
||||
* first+last lines). 0.85 keeps genuine quantized-model drift (a typo, an indent
|
||||
* shift) while refusing a different block.
|
||||
*/
|
||||
export const SIMILARITY_THRESHOLD = 0.85;
|
||||
|
||||
/**
|
||||
* If a second candidate window scores within this of the best, the match is
|
||||
* ambiguous and tier 4 refuses rather than guessing — the same fail-closed
|
||||
* stance the exact and whitespace tiers take on multiple hits. Repetitive files
|
||||
* (the duplicate-block corruption case) produce near-tied windows; this is what
|
||||
* turns that into a clean "add more context" error instead of a wrong splice.
|
||||
*/
|
||||
export const AMBIGUITY_EPSILON = 0.05;
|
||||
|
||||
/** Multi-line needles at or above this length must anchor on an exact (after
|
||||
* trim + unicode-fold) first AND last line before similarity is even scored —
|
||||
* the cline/opencode block-anchor rule. Below it, threshold + uniqueness alone
|
||||
* guard the match. */
|
||||
const ANCHOR_MIN_LINES = 3;
|
||||
|
||||
export function locateMatch(content: string, needle: string): MatchResult {
|
||||
// Empty needle has no meaningful match.
|
||||
@@ -252,20 +284,39 @@ function locateByLevenshtein(content: string, needle: string): MatchResult | nul
|
||||
|
||||
const needleJoined = needleLines.map((l) => l.trim()).join('\n');
|
||||
|
||||
let best = -1;
|
||||
let bestSpan: { start: number; end: number } | null = null;
|
||||
// Block-anchor gate for multi-line needles: the first and last lines must match
|
||||
// exactly (after trim + unicode-fold) or the window is not even scored. This
|
||||
// stops a high interior-similarity from dragging a structurally-wrong window
|
||||
// over the threshold — the failure that duplicates blocks in repetitive files.
|
||||
const anchored = n >= ANCHOR_MIN_LINES;
|
||||
const needleFirst = canonicalize(needleLines[0]!.trim());
|
||||
const needleLast = canonicalize(needleLines[n - 1]!.trim());
|
||||
|
||||
const scored: Array<{ score: number; start: number; end: number }> = [];
|
||||
for (let i = 0; i + n <= contentLines.length; i++) {
|
||||
const window = contentLines.slice(i, i + n);
|
||||
const windowJoined = window.map((l) => l.text.trim()).join('\n');
|
||||
const score = similarity(windowJoined, needleJoined);
|
||||
if (score > best) {
|
||||
best = score;
|
||||
bestSpan = { start: window[0]!.start, end: window[n - 1]!.end };
|
||||
if (anchored) {
|
||||
const winFirst = canonicalize(window[0]!.text.trim());
|
||||
const winLast = canonicalize(window[n - 1]!.text.trim());
|
||||
if (winFirst !== needleFirst || winLast !== needleLast) continue;
|
||||
}
|
||||
const windowJoined = window.map((l) => l.text.trim()).join('\n');
|
||||
scored.push({
|
||||
score: similarity(windowJoined, needleJoined),
|
||||
start: window[0]!.start,
|
||||
end: window[n - 1]!.end,
|
||||
});
|
||||
}
|
||||
|
||||
if (bestSpan && best >= SIMILARITY_THRESHOLD) {
|
||||
return { kind: 'fuzzy', start: bestSpan.start, end: bestSpan.end };
|
||||
}
|
||||
return null;
|
||||
if (scored.length === 0) return null;
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const best = scored[0]!;
|
||||
if (best.score < SIMILARITY_THRESHOLD) return null;
|
||||
|
||||
// Uniqueness guard: refuse when a second window is within epsilon of the best.
|
||||
// Fail closed (ambiguous) rather than silently splicing one of several lookalikes.
|
||||
const tied = scored.filter((s) => s.score >= best.score - AMBIGUITY_EPSILON);
|
||||
if (tied.length > 1) return { kind: 'ambiguous', count: tied.length };
|
||||
|
||||
return { kind: 'fuzzy', start: best.start, end: best.end };
|
||||
}
|
||||
|
||||
75
apps/coder/src/services/lsp/client.ts
Normal file
75
apps/coder/src/services/lsp/client.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
interface RpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
interface RpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
export class LspClient {
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
|
||||
private buffer = '';
|
||||
|
||||
constructor(
|
||||
private stdin: Writable,
|
||||
private stdout: Readable,
|
||||
) {
|
||||
const rl = createInterface({ input: stdout, crlfDelay: Infinity });
|
||||
rl.on('line', (line) => this.handleLine(line));
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
this.buffer += line + '\n';
|
||||
const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/);
|
||||
if (!match || !match[1]) return;
|
||||
const len = parseInt(match[1], 10);
|
||||
const headerEnd = match.index! + match[0].length;
|
||||
const body = this.buffer.slice(headerEnd, headerEnd + len);
|
||||
if (body.length < len) return;
|
||||
this.buffer = this.buffer.slice(headerEnd + len);
|
||||
try {
|
||||
const msg: RpcResponse = JSON.parse(body);
|
||||
const cb = this.pending.get(msg.id);
|
||||
if (cb) {
|
||||
this.pending.delete(msg.id);
|
||||
cb.resolve(msg);
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async request(method: string, params?: unknown): Promise<unknown> {
|
||||
const id = this.nextId++;
|
||||
const req: RpcRequest = { jsonrpc: '2.0', id, method, params };
|
||||
const body = JSON.stringify(req);
|
||||
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, {
|
||||
resolve: (resp) => {
|
||||
if (resp.error) reject(new Error(resp.error.message));
|
||||
else resolve(resp.result);
|
||||
},
|
||||
reject,
|
||||
});
|
||||
this.stdin.write(header + body);
|
||||
});
|
||||
}
|
||||
|
||||
async notify(method: string, params?: unknown): Promise<void> {
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', method, params });
|
||||
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||
this.stdin.write(header + body);
|
||||
}
|
||||
}
|
||||
19
apps/coder/src/services/lsp/config.ts
Normal file
19
apps/coder/src/services/lsp/config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface LspServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
rootPatterns: string[];
|
||||
}
|
||||
|
||||
const TS_CONFIG: LspServerConfig = {
|
||||
command: 'typescript-language-server',
|
||||
args: ['--stdio'],
|
||||
rootPatterns: ['package.json', 'tsconfig.json'],
|
||||
};
|
||||
|
||||
const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']);
|
||||
|
||||
export function getServerConfig(filePath: string): LspServerConfig | null {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG;
|
||||
return null;
|
||||
}
|
||||
86
apps/coder/src/services/lsp/operations.ts
Normal file
86
apps/coder/src/services/lsp/operations.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { LspClient } from './client.js';
|
||||
import type { Diagnostic, Location } from './types.js';
|
||||
|
||||
function fileUri(filePath: string): string {
|
||||
return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`;
|
||||
}
|
||||
|
||||
export async function openDocument(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
version: number = 1,
|
||||
): Promise<void> {
|
||||
const uri = fileUri(filePath);
|
||||
await client.notify('textDocument/didOpen', {
|
||||
textDocument: { uri, languageId: 'typescript', version, text: content },
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeDocument(client: LspClient, filePath: string): Promise<void> {
|
||||
await client.notify('textDocument/didClose', {
|
||||
textDocument: { uri: fileUri(filePath) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDiagnostics(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<Diagnostic[]> {
|
||||
const uri = fileUri(filePath);
|
||||
await openDocument(client, filePath, content);
|
||||
const result: any = await client.request('textDocument/diagnostic', {
|
||||
textDocument: { uri },
|
||||
});
|
||||
await closeDocument(client, filePath);
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
if (result?.diagnostics) {
|
||||
for (const d of result.diagnostics) {
|
||||
diagnostics.push({
|
||||
range: d.range,
|
||||
severity: d.severity ?? 1,
|
||||
message: d.message,
|
||||
source: d.source,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export async function gotoDefinition(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
line: number,
|
||||
character: number,
|
||||
): Promise<Location | null> {
|
||||
const uri = fileUri(filePath);
|
||||
await openDocument(client, filePath, content);
|
||||
const result: any = await client.request('textDocument/definition', {
|
||||
textDocument: { uri },
|
||||
position: { line, character },
|
||||
});
|
||||
await closeDocument(client, filePath);
|
||||
if (!result) return null;
|
||||
const loc = Array.isArray(result) ? result[0] : result;
|
||||
return loc ? { uri: loc.uri, range: loc.range } : null;
|
||||
}
|
||||
|
||||
export async function findReferences(
|
||||
client: LspClient,
|
||||
filePath: string,
|
||||
content: string,
|
||||
line: number,
|
||||
character: number,
|
||||
): Promise<Location[]> {
|
||||
const uri = fileUri(filePath);
|
||||
await openDocument(client, filePath, content);
|
||||
const result: any = await client.request('textDocument/references', {
|
||||
textDocument: { uri },
|
||||
position: { line, character },
|
||||
context: { includeDeclaration: true },
|
||||
});
|
||||
await closeDocument(client, filePath);
|
||||
return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range }));
|
||||
}
|
||||
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { LspClient } from './client.js';
|
||||
import { getServerConfig } from './config.js';
|
||||
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const SWEEP_INTERVAL_MS = 30_000;
|
||||
|
||||
interface LspInstance {
|
||||
client: LspClient;
|
||||
proc: ChildProcess;
|
||||
lastUsed: number;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class LspServerManager {
|
||||
private instances = new Map<string, LspInstance>();
|
||||
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.startSweeper();
|
||||
}
|
||||
|
||||
private startSweeper(): void {
|
||||
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
||||
this.sweepTimer.unref?.();
|
||||
}
|
||||
|
||||
private findProjectRoot(filePath: string): string | null {
|
||||
let dir = filePath;
|
||||
const config = getServerConfig(filePath);
|
||||
if (!config) return null;
|
||||
while (true) {
|
||||
for (const pattern of config.rootPatterns) {
|
||||
if (existsSync(join(dir, pattern))) return dir;
|
||||
}
|
||||
const parent = join(dir, '..');
|
||||
if (parent === dir) return dir;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
async getClient(filePath: string): Promise<LspClient | null> {
|
||||
const config = getServerConfig(filePath);
|
||||
if (!config) return null;
|
||||
const projectRoot = this.findProjectRoot(filePath);
|
||||
if (!projectRoot) return null;
|
||||
|
||||
const existing = this.instances.get(projectRoot);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
clearTimeout(existing.timer);
|
||||
existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||
existing.timer.unref?.();
|
||||
return existing.client;
|
||||
}
|
||||
|
||||
return this.spawn(projectRoot, config.command, config.args);
|
||||
}
|
||||
|
||||
private async spawn(projectRoot: string, command: string, args: string[]): Promise<LspClient> {
|
||||
const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot });
|
||||
const client = new LspClient(proc.stdin!, proc.stdout!);
|
||||
|
||||
await client.request('initialize', {
|
||||
processId: process.pid,
|
||||
rootUri: `file://${projectRoot}`,
|
||||
capabilities: {
|
||||
textDocument: {
|
||||
diagnostic: { dynamicRegistration: false },
|
||||
definition: { dynamicRegistration: false },
|
||||
references: { dynamicRegistration: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.notify('initialized', {});
|
||||
|
||||
const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
|
||||
this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer });
|
||||
proc.on('exit', () => this.instances.delete(projectRoot));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private kill(projectRoot: string): void {
|
||||
const inst = this.instances.get(projectRoot);
|
||||
if (!inst) return;
|
||||
this.instances.delete(projectRoot);
|
||||
inst.proc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private sweep(): void {
|
||||
const now = Date.now();
|
||||
for (const [root, inst] of this.instances) {
|
||||
if (now - inst.lastUsed > IDLE_TIMEOUT_MS) {
|
||||
this.kill(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
||||
for (const root of [...this.instances.keys()]) {
|
||||
this.kill(root);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveCount(): number {
|
||||
return this.instances.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const lspManager = new LspServerManager();
|
||||
28
apps/coder/src/services/lsp/types.ts
Normal file
28
apps/coder/src/services/lsp/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface Position {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
export interface Range {
|
||||
start: Position;
|
||||
end: Position;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
uri: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
range: Range;
|
||||
severity: number;
|
||||
message: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface TextDocumentItem {
|
||||
uri: string;
|
||||
languageId: string;
|
||||
version: number;
|
||||
text: string;
|
||||
}
|
||||
@@ -1,9 +1,120 @@
|
||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { readFile, writeFile, unlink, mkdir, rename, realpath } from 'node:fs/promises';
|
||||
import { dirname, join, basename } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { Sql } from '../db.js';
|
||||
import { resolveWritePath } from './write_guard.js';
|
||||
import { locateMatch } from './fuzzy-match.js';
|
||||
|
||||
/**
|
||||
* Write a file atomically: stage to a sibling temp file, then rename over the
|
||||
* target. rename(2) on the same filesystem is atomic, so a crash mid-write can
|
||||
* never leave a half-written (truncated/corrupt) source file — readers see
|
||||
* either the old content or the complete new content. The temp lives in the same
|
||||
* directory to guarantee a same-filesystem rename.
|
||||
*
|
||||
* Symlinks: a plain writeFile FOLLOWS a symlink and writes through to its target;
|
||||
* a bare rename would REPLACE the link with a regular file. We realpath an
|
||||
* existing target first so the rename lands on the real file and the link
|
||||
* survives — preserving the prior follow-through behavior. A missing target
|
||||
* (create, or a broken link) just writes the literal path.
|
||||
*/
|
||||
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
|
||||
let target = filePath;
|
||||
try {
|
||||
target = await realpath(filePath);
|
||||
} catch {
|
||||
// ENOENT (new file) or broken link — write the literal path.
|
||||
}
|
||||
const tmp = join(dirname(target), `.${basename(target)}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`);
|
||||
await writeFile(tmp, content, 'utf8');
|
||||
try {
|
||||
await rename(tmp, target);
|
||||
} catch (err) {
|
||||
await unlink(tmp).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect a file's dominant line ending so an edit can preserve it. */
|
||||
function detectEol(text: string): '\r\n' | '\n' {
|
||||
return text.includes('\r\n') ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the read-modify-write of a single file so two concurrent applies
|
||||
* (e.g. two chat tabs sharing one worktree, or a Bypass write racing an
|
||||
* apply_pending) can't lose an update. In-process keying is sufficient —
|
||||
* BooCoder is a single Fastify process. One Map entry per distinct path.
|
||||
*/
|
||||
const fileLocks = new Map<string, Promise<void>>();
|
||||
async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = fileLocks.get(filePath) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((r) => { release = r; });
|
||||
fileLocks.set(filePath, prev.then(() => current));
|
||||
await prev.catch(() => {});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit-apply planning (pure, unit-tested) ---------------------------------
|
||||
|
||||
/**
|
||||
* Decision for applying one queued edit to a file's current content. Pulled out
|
||||
* of `applyOne` so the splice — the part that actually corrupted files — is pure
|
||||
* and testable without a DB or filesystem. Mirrors how opencode/cline/qwen keep
|
||||
* their matchers fail-closed and idempotent.
|
||||
*/
|
||||
export type EditPlan =
|
||||
| { kind: 'apply'; updated: string }
|
||||
| { kind: 'noop'; reason: 'identical' | 'already-applied' }
|
||||
| { kind: 'ambiguous'; count: number }
|
||||
| { kind: 'not_found' };
|
||||
|
||||
/**
|
||||
* Decide how (or whether) to apply an `old → new` edit to `content`.
|
||||
*
|
||||
* Idempotency is the whole point here: a queued edit can legitimately be
|
||||
* re-applied (a local model re-emits the same tool call; a turn is retried; the
|
||||
* same change sits in the queue twice). A naive splice stamps the new text again
|
||||
* each time — the 2–3× block duplication. Two guards make re-application a no-op:
|
||||
*
|
||||
* - already-applied (anchored insert): when `new` is `old` + an appended block
|
||||
* (`old="anchor"`, `new="anchor\n<block>"`), `old` still matches uniquely after
|
||||
* the first apply, so a second apply would duplicate `<block>`. If the full
|
||||
* `new` text is already present at the match site, the edit is already applied.
|
||||
* - already-applied (old gone): if `old` can't be located but `new` is already
|
||||
* in the file, the change landed on a prior pass — treat as a no-op, not an error.
|
||||
* - identical: the splice would not change the file.
|
||||
*
|
||||
* Anything ambiguous or genuinely absent fails CLOSED so the caller surfaces a
|
||||
* correctable error instead of writing a guess.
|
||||
*/
|
||||
export function planEdit(content: string, oldStr: string, newStr: string): EditPlan {
|
||||
const match = locateMatch(content, oldStr);
|
||||
|
||||
if (match.kind === 'ambiguous') return { kind: 'ambiguous', count: match.count };
|
||||
|
||||
if (match.kind === 'not_found') {
|
||||
if (newStr.length > 0 && content.includes(newStr)) {
|
||||
return { kind: 'noop', reason: 'already-applied' };
|
||||
}
|
||||
return { kind: 'not_found' };
|
||||
}
|
||||
|
||||
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
|
||||
// No-change splice first (covers old === new), then the anchored re-stamp guard:
|
||||
// the full replacement already sits at the match site (re-emitted anchored insert).
|
||||
if (updated === content) return { kind: 'noop', reason: 'identical' };
|
||||
if (content.slice(match.start, match.start + newStr.length) === newStr) {
|
||||
return { kind: 'noop', reason: 'already-applied' };
|
||||
}
|
||||
return { kind: 'apply', updated };
|
||||
}
|
||||
|
||||
// --- Types -------------------------------------------------------------------
|
||||
|
||||
export interface PendingChange {
|
||||
@@ -47,6 +158,13 @@ export async function queueEdit(
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
const diff = JSON.stringify({ old: oldString, new: newString });
|
||||
|
||||
// Idempotent queue: collapse an identical edit that is still pending. Local
|
||||
// quantized models re-emit the same edit_file call within a turn, and a retried
|
||||
// turn re-queues — each duplicate row would apply and stamp another copy. One
|
||||
// pending row per (session, file, operation, diff) is enough.
|
||||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'edit', diff);
|
||||
if (existing) return existing;
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
|
||||
@@ -55,6 +173,28 @@ export async function queueEdit(
|
||||
return row!;
|
||||
}
|
||||
|
||||
/** Return an identical still-pending change for this (session, file, op, diff),
|
||||
* or undefined. Used to keep the queue idempotent against re-emitted edits. */
|
||||
async function findPendingDuplicate(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
resolvedPath: string,
|
||||
operation: 'create' | 'edit' | 'delete',
|
||||
diff: string,
|
||||
): Promise<PendingChange | undefined> {
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
SELECT * FROM pending_changes
|
||||
WHERE session_id = ${sessionId}
|
||||
AND file_path = ${resolvedPath}
|
||||
AND operation = ${operation}
|
||||
AND diff = ${diff}
|
||||
AND status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function queueCreate(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
@@ -68,6 +208,9 @@ export async function queueCreate(
|
||||
): Promise<PendingChange> {
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
|
||||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'create', content);
|
||||
if (existing) return existing;
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
|
||||
@@ -87,6 +230,9 @@ export async function queueDelete(
|
||||
): Promise<PendingChange> {
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
|
||||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'delete', '');
|
||||
if (existing) return existing;
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
|
||||
@@ -110,48 +256,60 @@ export async function applyOne(
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-validate path in case projectRoot has shifted
|
||||
resolveWritePath(projectRoot, change.file_path);
|
||||
return await withFileLock(change.file_path, async () => {
|
||||
// Re-validate path in case projectRoot has shifted
|
||||
resolveWritePath(projectRoot, change.file_path);
|
||||
|
||||
switch (change.operation) {
|
||||
case 'create': {
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFile(change.file_path, change.diff, 'utf8');
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||
const content = await readFile(change.file_path, 'utf8');
|
||||
const match = locateMatch(content, oldStr);
|
||||
if (match.kind === 'ambiguous') {
|
||||
throw new Error(
|
||||
`old_string matches ${match.count} locations — add surrounding context to disambiguate`,
|
||||
);
|
||||
switch (change.operation) {
|
||||
case 'create': {
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFileAtomic(change.file_path, change.diff);
|
||||
break;
|
||||
}
|
||||
if (match.kind === 'not_found') {
|
||||
throw new Error(
|
||||
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
|
||||
);
|
||||
case 'edit': {
|
||||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||
const raw = await readFile(change.file_path, 'utf8');
|
||||
// Normalize to LF for matching, then write back in the file's native EOL
|
||||
// so an LF-emitting model doesn't leave a CRLF file with mixed endings.
|
||||
const eol = detectEol(raw);
|
||||
const toLf = (t: string) => t.replaceAll('\r\n', '\n');
|
||||
const plan = planEdit(toLf(raw), toLf(oldStr), toLf(newStr));
|
||||
if (plan.kind === 'ambiguous') {
|
||||
throw new Error(
|
||||
`old_string matches ${plan.count} locations — add surrounding context to disambiguate`,
|
||||
);
|
||||
}
|
||||
if (plan.kind === 'not_found') {
|
||||
throw new Error(
|
||||
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
|
||||
);
|
||||
}
|
||||
if (plan.kind === 'apply') {
|
||||
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
|
||||
await writeFileAtomic(change.file_path, out);
|
||||
} else {
|
||||
// noop: the edit is already applied (re-emitted / retried) or a no-change.
|
||||
// Mark it applied without rewriting so it can't stamp a duplicate.
|
||||
console.log(`[pending] edit ${change.file_path} is a no-op (${plan.reason}) — not rewriting`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
|
||||
await writeFile(change.file_path, updated, 'utf8');
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Stash current content in diff for potential rewind
|
||||
try {
|
||||
const existing = await readFile(change.file_path, 'utf8');
|
||||
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
||||
} catch {
|
||||
// File may already be gone — proceed with status update
|
||||
case 'delete': {
|
||||
// Stash current content in diff for potential rewind
|
||||
try {
|
||||
const existing = await readFile(change.file_path, 'utf8');
|
||||
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
||||
} catch {
|
||||
// File may already be gone — proceed with status update
|
||||
}
|
||||
await unlink(change.file_path);
|
||||
break;
|
||||
}
|
||||
await unlink(change.file_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||
@@ -220,13 +378,13 @@ export async function rewindOne(
|
||||
);
|
||||
}
|
||||
const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end);
|
||||
await writeFile(change.file_path, reverted, 'utf8');
|
||||
await writeFileAtomic(change.file_path, reverted);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFile(change.file_path, change.diff, 'utf8');
|
||||
await writeFileAtomic(change.file_path, change.diff);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,18 @@ const QWEN_PTY_MODES: ProviderMode[] = [
|
||||
{ id: 'yolo', label: 'YOLO', description: 'Auto-approve all tools', isUnattended: true },
|
||||
];
|
||||
|
||||
// Native BooCode (llama-swap) has no agent-native mode vocabulary, so we define
|
||||
// one that matches the unified permission ladder. `bypass` is the only mode that
|
||||
// changes behavior (auto-apply staged edits after the turn — dispatcher.ts);
|
||||
// `plan` falls back to `ask` semantics for native (writes still stage to the
|
||||
// pending-changes queue). External agents map the same three unified modes onto
|
||||
// THEIR native ids via the `plan`-id / default / `isUnattended` shape.
|
||||
const BOOCODE_MODES: ProviderMode[] = [
|
||||
{ id: 'plan', label: 'Plan', description: 'Read-only analysis (native BooCode falls back to Ask)' },
|
||||
{ id: 'ask', label: 'Ask Permission', description: 'Stage edits to the pending-changes queue for review' },
|
||||
{ id: 'bypass', label: 'Bypass', description: 'Auto-apply edits to disk after the turn', isUnattended: true },
|
||||
];
|
||||
|
||||
const CLAUDE_THINKING = [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
@@ -41,6 +53,10 @@ const CLAUDE_THINKING = [
|
||||
];
|
||||
|
||||
export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
|
||||
boocode: {
|
||||
defaultModeId: 'ask',
|
||||
modes: BOOCODE_MODES,
|
||||
},
|
||||
claude: {
|
||||
defaultModeId: 'default',
|
||||
modes: CLAUDE_MODES,
|
||||
|
||||
@@ -122,12 +122,14 @@ async function buildProviderEntry(
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Native boocode → always ready (llama-swap models).
|
||||
// 2. Native boocode → always ready (llama-swap models). Exposes the unified
|
||||
// permission modes (plan/ask/bypass) so the composer's permission picker works
|
||||
// for native BooCode too; `bypass` auto-applies staged edits (dispatcher.ts).
|
||||
if (isNative) {
|
||||
return {
|
||||
name, label: resolved.label, transport, status: 'ready',
|
||||
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
|
||||
defaultModeId: null, commands: manifestCommands,
|
||||
enabled: true, installed: true, models: withConfigModels(llamaModels),
|
||||
modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { analyzeMessages } from '../analyzer.js';
|
||||
|
||||
describe('analyzeMessages', () => {
|
||||
it('classifies user messages', () => {
|
||||
const breakdown = analyzeMessages([{ role: 'user', content: 'hello world' }]);
|
||||
expect(breakdown.user).toBeGreaterThan(0);
|
||||
expect(breakdown.total).toBe(breakdown.user);
|
||||
});
|
||||
|
||||
it('counts tool calls', () => {
|
||||
const parts = [
|
||||
{ role: 'assistant', content: 'using grep', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: '{"files":[]}', tool_call_id: '1' },
|
||||
];
|
||||
const breakdown = analyzeMessages(parts);
|
||||
expect(breakdown.tools).toBeGreaterThan(0);
|
||||
expect(breakdown.assistant).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('separates reasoning tokens', () => {
|
||||
const parts = [
|
||||
{ role: 'assistant', content: 'short answer', reasoning_parts: [{ text: 'long chain of thought reasoning here' }] },
|
||||
];
|
||||
const breakdown = analyzeMessages(parts);
|
||||
expect(breakdown.reasoning).toBeGreaterThan(0);
|
||||
expect(breakdown.assistant).toBeLessThan(breakdown.reasoning);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('persistTaskBreakdown', () => {
|
||||
it('exports functions', async () => {
|
||||
const mod = await import('../persist.js');
|
||||
expect(typeof mod.persistTaskBreakdown).toBe('function');
|
||||
expect(typeof mod.getTaskBreakdown).toBe('function');
|
||||
expect(typeof mod.analyzeAndPersistTaskBreakdown).toBe('function');
|
||||
});
|
||||
});
|
||||
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal file
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// TokenScope analyzer — classifies message parts into category breakdown.
|
||||
// Ported from opencode-tokenscope (MIT).
|
||||
|
||||
export interface TokenBreakdown {
|
||||
system: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
tools: number;
|
||||
reasoning: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
export function analyzeMessages(parts: any[]): TokenBreakdown {
|
||||
const breakdown: TokenBreakdown = { system: 0, user: 0, assistant: 0, tools: 0, reasoning: 0, total: 0 };
|
||||
|
||||
for (const part of parts) {
|
||||
const role = part.role ?? '';
|
||||
const content = part.content ?? '';
|
||||
const tokens = estimateTokens(content);
|
||||
|
||||
switch (role) {
|
||||
case 'system':
|
||||
breakdown.system += tokens;
|
||||
break;
|
||||
case 'user':
|
||||
breakdown.user += tokens;
|
||||
break;
|
||||
case 'assistant':
|
||||
breakdown.assistant += tokens;
|
||||
if (part.tool_calls) {
|
||||
for (const tc of part.tool_calls) {
|
||||
breakdown.tools += estimateTokens(JSON.stringify(tc));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'tool':
|
||||
breakdown.tools += tokens;
|
||||
break;
|
||||
default:
|
||||
breakdown.assistant += tokens;
|
||||
}
|
||||
|
||||
if (part.reasoning_parts) {
|
||||
for (const rp of part.reasoning_parts) {
|
||||
const rTokens = estimateTokens(rp.text ?? '');
|
||||
breakdown.reasoning += rTokens;
|
||||
breakdown.assistant -= rTokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
breakdown.total = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools + breakdown.reasoning;
|
||||
return breakdown;
|
||||
}
|
||||
35
apps/coder/src/services/token-analysis/persist.ts
Normal file
35
apps/coder/src/services/token-analysis/persist.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// TokenScope persistence — writes breakdown to task records.
|
||||
import type { Sql } from '../../db.js';
|
||||
import type { TokenBreakdown } from './analyzer.js';
|
||||
|
||||
export async function persistTaskBreakdown(
|
||||
sql: Sql,
|
||||
taskId: string,
|
||||
breakdown: TokenBreakdown,
|
||||
): Promise<void> {
|
||||
await sql`
|
||||
UPDATE tasks SET token_breakdown = ${sql.json(breakdown as never)}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getTaskBreakdown(
|
||||
sql: Sql,
|
||||
taskId: string,
|
||||
): Promise<TokenBreakdown | null> {
|
||||
const rows = await sql<{ token_breakdown: any }[]>`
|
||||
SELECT token_breakdown FROM tasks WHERE id = ${taskId}
|
||||
`;
|
||||
return rows[0]?.token_breakdown ?? null;
|
||||
}
|
||||
|
||||
export async function analyzeAndPersistTaskBreakdown(
|
||||
sql: Sql,
|
||||
taskId: string,
|
||||
parts: any[],
|
||||
): Promise<TokenBreakdown> {
|
||||
const { analyzeMessages } = await import('./analyzer.js');
|
||||
const breakdown = analyzeMessages(parts);
|
||||
await persistTaskBreakdown(sql, taskId, breakdown);
|
||||
return breakdown;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runWithInferenceContext, getInferenceContext } from '../inference_context.js';
|
||||
import type { Sql } from '../../../db.js';
|
||||
|
||||
const fakeSql = {} as unknown as Sql;
|
||||
|
||||
describe('inference context (AsyncLocalStorage isolation)', () => {
|
||||
it('throws when read outside a run', () => {
|
||||
expect(() => getInferenceContext()).toThrow(/outside inference context/);
|
||||
});
|
||||
|
||||
it('keeps each run its own context across overlapping awaits', async () => {
|
||||
// The race the global `let current` had: run B starts (and would overwrite a
|
||||
// shared global) while run A is awaiting. After A resumes it must still read
|
||||
// its OWN sessionId, not B's.
|
||||
const run = (id: string, delay: number) =>
|
||||
runWithInferenceContext({ sql: fakeSql, sessionId: id, taskId: null }, async () => {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return getInferenceContext().sessionId;
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([run('A', 20), run('B', 5)]);
|
||||
expect(a).toBe('A');
|
||||
expect(b).toBe('B');
|
||||
});
|
||||
|
||||
it('carries permissionMode and taskId per run', async () => {
|
||||
const result = await runWithInferenceContext(
|
||||
{ sql: fakeSql, sessionId: 's1', taskId: 't1', permissionMode: 'bypass' },
|
||||
async () => {
|
||||
await Promise.resolve();
|
||||
const ctx = getInferenceContext();
|
||||
return { taskId: ctx.taskId, mode: ctx.permissionMode };
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ taskId: 't1', mode: 'bypass' });
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,15 @@ export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
// Under Ask (and Plan) the human approves via the Pending Changes panel — the
|
||||
// agent must not auto-apply. Bypass and legacy (undefined) may apply.
|
||||
if (context.permissionMode === 'ask' || context.permissionMode === 'plan') {
|
||||
return {
|
||||
status: 'denied',
|
||||
message:
|
||||
'Permission mode is Ask — staged changes must be approved by the user in the Pending Changes panel, not applied by the agent.',
|
||||
};
|
||||
}
|
||||
const results = await applyAll(context.sql, context.sessionId, projectRoot);
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueCreate } from '../pending_changes.js';
|
||||
import { denyReadOnly, finalizeWrite } from './write-gate.js';
|
||||
|
||||
const CreateFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -32,6 +33,7 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (context.permissionMode === 'plan') return denyReadOnly('create_file');
|
||||
const change = await queueCreate(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
@@ -40,12 +42,11 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||
input.content,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'create',
|
||||
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
return finalizeWrite(
|
||||
context,
|
||||
projectRoot,
|
||||
change,
|
||||
`File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueDelete } from '../pending_changes.js';
|
||||
import { denyReadOnly, finalizeWrite } from './write-gate.js';
|
||||
|
||||
const DeleteFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -30,6 +31,7 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (context.permissionMode === 'plan') return denyReadOnly('delete_file');
|
||||
const change = await queueDelete(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
@@ -37,12 +39,11 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||
input.file_path,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'delete',
|
||||
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
return finalizeWrite(
|
||||
context,
|
||||
projectRoot,
|
||||
change,
|
||||
`File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueEdit } from '../pending_changes.js';
|
||||
import { denyReadOnly, finalizeWrite } from './write-gate.js';
|
||||
|
||||
const EditFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -34,6 +35,7 @@ export const editFileTool: ToolDef<EditFileInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (context.permissionMode === 'plan') return denyReadOnly('edit_file');
|
||||
const change = await queueEdit(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
@@ -43,12 +45,11 @@ export const editFileTool: ToolDef<EditFileInputT> = {
|
||||
input.new_string,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'edit',
|
||||
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
return finalizeWrite(
|
||||
context,
|
||||
projectRoot,
|
||||
change,
|
||||
`Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js';
|
||||
import { newTaskTool } from './new_task.js';
|
||||
import { listTasksTool } from './list_tasks.js';
|
||||
import { checkTaskStatusTool } from './check_task_status.js';
|
||||
import { lspDiagnosticsTool } from './lsp_diagnostics.js';
|
||||
import { lspGotoDefinitionTool } from './lsp_goto_definition.js';
|
||||
import { lspFindReferencesTool } from './lsp_find_references.js';
|
||||
|
||||
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
||||
|
||||
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
||||
checkTaskStatusTool,
|
||||
];
|
||||
|
||||
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };
|
||||
// Read-only agent tools for code intelligence.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const READ_TOOLS: readonly ToolDef<any>[] = [
|
||||
lspDiagnosticsTool,
|
||||
lspGotoDefinitionTool,
|
||||
lspFindReferencesTool,
|
||||
];
|
||||
|
||||
export {
|
||||
editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool,
|
||||
newTaskTool, listTasksTool, checkTaskStatusTool,
|
||||
lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool,
|
||||
};
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { Sql } from '../../db.js';
|
||||
import type { PermissionMode } from './types.js';
|
||||
|
||||
/**
|
||||
* Module-level inference context for write tools.
|
||||
* Per-run inference context for write tools.
|
||||
*
|
||||
* Set via `setInferenceContext()` before each inference run starts.
|
||||
* Write tools read it via `getInferenceContext()` during execute.
|
||||
* Same pattern as BooChat's `loadConfig()` singleton — tools need
|
||||
* ambient state that can't be threaded through the tool-phase execute
|
||||
* signature (which is `execute(input, projectRoot, extraRoots?)`).
|
||||
* Write tools need ambient state (sql, sessionId, the permission gate) that the
|
||||
* BooChat tool-phase `execute(input, projectRoot, extraRoots?)` signature can't
|
||||
* carry. This used to be a single module-level `let current` — but the inference
|
||||
* runner's `enqueue()` is fire-and-forget, so two overlapping runs (a user
|
||||
* message racing a dispatcher-polled native task; two chat tabs streaming) would
|
||||
* clobber each other's context, and `cancel()` cleared it for ALL in-flight runs.
|
||||
*
|
||||
* AsyncLocalStorage gives each run its own context: `enqueue()` starts its async
|
||||
* loop synchronously inside `runWithInferenceContext`, so the store propagates
|
||||
* through every awaited tool execution in that run — and only that run.
|
||||
*/
|
||||
|
||||
export interface InferenceContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
/** Native-BooCode permission gate, set per run from the request/task mode. */
|
||||
permissionMode?: PermissionMode;
|
||||
}
|
||||
|
||||
let current: InferenceContext | null = null;
|
||||
const storage = new AsyncLocalStorage<InferenceContext>();
|
||||
|
||||
export function setInferenceContext(ctx: InferenceContext): void {
|
||||
current = ctx;
|
||||
}
|
||||
|
||||
export function clearInferenceContext(): void {
|
||||
current = null;
|
||||
/**
|
||||
* Bind `ctx` for the duration of the (possibly detached) async chain `fn` starts.
|
||||
* The inference runner kicks off its loop synchronously within this call, so all
|
||||
* downstream `await`s — including write-tool `execute` via the adapter — read the
|
||||
* same store. Concurrent runs each get their own; nothing is shared or cleared
|
||||
* out from under an in-flight run.
|
||||
*/
|
||||
export function runWithInferenceContext<T>(ctx: InferenceContext, fn: () => T): T {
|
||||
return storage.run(ctx, fn);
|
||||
}
|
||||
|
||||
export function getInferenceContext(): InferenceContext {
|
||||
if (!current) {
|
||||
const ctx = storage.getStore();
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'Write tool called outside inference context — setInferenceContext() was not called before this run',
|
||||
'Write tool called outside inference context — runWithInferenceContext() did not wrap this run',
|
||||
);
|
||||
}
|
||||
return current;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal file
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { resolveWritePath } from '../write_guard.js';
|
||||
import { lspManager } from '../lsp/server-manager.js';
|
||||
import { getDiagnostics } from '../lsp/operations.js';
|
||||
|
||||
const LspDiagnosticsInput = z.object({
|
||||
file_path: z.string().describe('Path to the file to check for diagnostics'),
|
||||
});
|
||||
|
||||
type InputT = z.infer<typeof LspDiagnosticsInput>;
|
||||
|
||||
export const lspDiagnosticsTool: ToolDef<InputT> = {
|
||||
name: 'lsp_diagnostics',
|
||||
description: 'Get TypeScript/JavaScript diagnostics (errors, warnings) for a file. Returns diagnostic messages with severity and location.',
|
||||
inputSchema: LspDiagnosticsInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lsp_diagnostics',
|
||||
description: 'Get TypeScript/JavaScript diagnostics for a file',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path to the file' },
|
||||
},
|
||||
required: ['file_path'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||
const content = await readFile(resolved, 'utf8');
|
||||
const client = await lspManager.getClient(resolved);
|
||||
if (!client) return { error: 'Unsupported file type for LSP diagnostics' };
|
||||
|
||||
const diagnostics = await getDiagnostics(client, resolved, content);
|
||||
if (diagnostics.length === 0) return { result: 'No diagnostics found.' };
|
||||
|
||||
const lines = diagnostics.map((d) => {
|
||||
const sev = ['', 'error', 'warning', 'info', 'hint'][d.severity] ?? 'unknown';
|
||||
return `[${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1} - ${d.message}`;
|
||||
});
|
||||
return { result: lines.join('\n') };
|
||||
},
|
||||
};
|
||||
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal file
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { resolveWritePath } from '../write_guard.js';
|
||||
import { lspManager } from '../lsp/server-manager.js';
|
||||
import { findReferences } from '../lsp/operations.js';
|
||||
|
||||
const LspFindReferencesInput = z.object({
|
||||
file_path: z.string().describe('Path to the source file'),
|
||||
line: z.number().int().nonnegative().describe('0-based line number'),
|
||||
character: z.number().int().nonnegative().describe('0-based character offset'),
|
||||
});
|
||||
|
||||
type InputT = z.infer<typeof LspFindReferencesInput>;
|
||||
|
||||
export const lspFindReferencesTool: ToolDef<InputT> = {
|
||||
name: 'lsp_find_references',
|
||||
description: 'Find all references to a symbol at a given position in a file.',
|
||||
inputSchema: LspFindReferencesInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lsp_find_references',
|
||||
description: 'Find all references to symbol at position',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string' },
|
||||
line: { type: 'number' },
|
||||
character: { type: 'number' },
|
||||
},
|
||||
required: ['file_path', 'line', 'character'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||
const content = await readFile(resolved, 'utf8');
|
||||
const client = await lspManager.getClient(resolved);
|
||||
if (!client) return { error: 'Unsupported file type' };
|
||||
|
||||
const refs = await findReferences(client, resolved, content, input.line, input.character);
|
||||
if (refs.length === 0) return { result: 'No references found.' };
|
||||
|
||||
const lines = refs.map((r) => `${r.uri}:${r.range.start.line + 1}:${r.range.start.character + 1}`);
|
||||
return { result: `Found ${refs.length} reference(s):\n${lines.join('\n')}` };
|
||||
},
|
||||
};
|
||||
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal file
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { resolveWritePath } from '../write_guard.js';
|
||||
import { lspManager } from '../lsp/server-manager.js';
|
||||
import { gotoDefinition } from '../lsp/operations.js';
|
||||
|
||||
const LspGotoDefinitionInput = z.object({
|
||||
file_path: z.string().describe('Path to the source file'),
|
||||
line: z.number().int().nonnegative().describe('0-based line number'),
|
||||
character: z.number().int().nonnegative().describe('0-based character offset'),
|
||||
});
|
||||
|
||||
type InputT = z.infer<typeof LspGotoDefinitionInput>;
|
||||
|
||||
export const lspGotoDefinitionTool: ToolDef<InputT> = {
|
||||
name: 'lsp_goto_definition',
|
||||
description: 'Find the definition of a symbol at a given position in a file.',
|
||||
inputSchema: LspGotoDefinitionInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lsp_goto_definition',
|
||||
description: 'Find definition of symbol at position',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string' },
|
||||
line: { type: 'number' },
|
||||
character: { type: 'number' },
|
||||
},
|
||||
required: ['file_path', 'line', 'character'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||
const content = await readFile(resolved, 'utf8');
|
||||
const client = await lspManager.getClient(resolved);
|
||||
if (!client) return { error: 'Unsupported file type' };
|
||||
|
||||
const loc = await gotoDefinition(client, resolved, content, input.line, input.character);
|
||||
if (!loc) return { result: 'No definition found.' };
|
||||
|
||||
return { result: `Defined at ${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}` };
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ const NewTaskInput = z.object({
|
||||
input: z.string().min(1).describe('Task description for the child subtask'),
|
||||
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
|
||||
model: z.string().optional().describe('Optional: model override for the subtask'),
|
||||
background: z.boolean().optional().describe('If true, return immediately without blocking on completion'),
|
||||
});
|
||||
|
||||
type NewTaskInputT = z.infer<typeof NewTaskInput>;
|
||||
@@ -30,6 +31,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
input: { type: 'string', description: 'Task description for the child subtask' },
|
||||
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
|
||||
model: { type: 'string', description: 'Optional: model override for the subtask' },
|
||||
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
|
||||
},
|
||||
required: ['input'],
|
||||
},
|
||||
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
return { error: 'Cannot determine project_id from current session' };
|
||||
}
|
||||
|
||||
const isBg = input.background === true;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
|
||||
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
|
||||
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
`;
|
||||
|
||||
return {
|
||||
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
|
||||
message: isBg
|
||||
? `Background subtask created (id: ${task!.id}). It will continue independently.`
|
||||
: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
|
||||
task_id: task!.id,
|
||||
state: task!.state,
|
||||
background: isBg,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { z } from 'zod';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
/**
|
||||
* Unified permission ladder for native BooCode inference. Gates the write tools:
|
||||
* plan — read-only: create/edit/delete are denied (no staging).
|
||||
* ask — stage to the pending-changes queue; `apply_pending` is denied so the
|
||||
* agent cannot self-apply (the human approves via the Diff panel).
|
||||
* bypass — apply each write immediately (no queue, no approval).
|
||||
* Undefined preserves the historical behavior (stage + `apply_pending` allowed).
|
||||
*/
|
||||
export type PermissionMode = 'plan' | 'ask' | 'bypass';
|
||||
|
||||
/** Narrow a raw task/request mode id to a unified PermissionMode, else undefined
|
||||
* (e.g. an external agent's native mode id, or null). */
|
||||
export function asPermissionMode(id: string | null | undefined): PermissionMode | undefined {
|
||||
return id === 'plan' || id === 'ask' || id === 'bypass' ? id : undefined;
|
||||
}
|
||||
|
||||
export interface ToolJsonSchema {
|
||||
type: 'function';
|
||||
function: {
|
||||
@@ -21,6 +37,8 @@ export interface ToolContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
/** Native-BooCode permission gate for write tools (undefined = legacy behavior). */
|
||||
permissionMode?: PermissionMode;
|
||||
}
|
||||
|
||||
export interface ToolDef<TInput> {
|
||||
|
||||
53
apps/coder/src/services/tools/write-gate.ts
Normal file
53
apps/coder/src/services/tools/write-gate.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Permission-gate helpers for native BooCode write tools. The gate comes from
|
||||
* the per-run inference context (`ToolContext.permissionMode`):
|
||||
* plan — deny the write (read-only); nothing is staged.
|
||||
* bypass — apply the staged change immediately (no queue, no approval).
|
||||
* ask / undefined — leave it in the pending-changes queue for review.
|
||||
*/
|
||||
import type { ToolContext } from './types.js';
|
||||
import { applyOne } from '../pending_changes.js';
|
||||
|
||||
/** Result returned when a write is denied under Plan (read-only) mode. */
|
||||
export function denyReadOnly(operation: string): unknown {
|
||||
return {
|
||||
status: 'denied',
|
||||
operation,
|
||||
message: `Read-only (Plan) permission mode — ${operation} is not permitted. Switch to Ask or Bypass to make changes.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Finalize a just-staged change per the permission gate: apply now under Bypass,
|
||||
* otherwise return it as queued for the human to approve. */
|
||||
export async function finalizeWrite(
|
||||
context: ToolContext,
|
||||
projectRoot: string,
|
||||
change: { id: string; file_path: string; operation: string },
|
||||
queuedHint: string,
|
||||
): Promise<unknown> {
|
||||
if (context.permissionMode === 'bypass') {
|
||||
const res = await applyOne(context.sql, change.id, projectRoot);
|
||||
console.log(
|
||||
`[write-gate] bypass apply ${change.operation} ${change.file_path} -> ${res.success ? 'applied' : 'FAILED: ' + (res.error ?? '?')}`,
|
||||
);
|
||||
return {
|
||||
status: res.success ? 'applied' : 'failed',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: change.operation,
|
||||
message: res.success
|
||||
? `${change.operation} applied to ${change.file_path}.`
|
||||
: `Apply failed for ${change.file_path}: ${res.error ?? 'unknown error'}. Left in the pending queue.`,
|
||||
};
|
||||
}
|
||||
console.log(
|
||||
`[write-gate] ${context.permissionMode ?? 'legacy'} queued ${change.operation} ${change.file_path}`,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: change.operation,
|
||||
message: queuedHint,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`.
|
||||
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor.
|
||||
- **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue).
|
||||
- **`services/inference/tool-shim.ts`** — Recovers structured tool calls from plain-text model output. Some models (notably Qwen) emit `<tool_call><name>...</name><arguments>...</arguments></tool_call>` inline text instead of structured JSON. `extractToolCalls(text)` parses both XML and JSON inline formats. `hasToolCallMarkup(text)` is a fast pre-check. Used as a fallback in the stream phase when structured `tool_calls` parse fails. Does NOT require `FAST_MODEL` — operates on the existing turn's output text.
|
||||
- **`services/inference/loop-detectors.ts`** — Six detectors that catch repetitive model behavior: `detectContentRepeat` (same content N times), `detectToolLoop` (same tool called consecutively). `detectDoomLoop` combines both. These are additive to the existing `sentinels.ts` doom-loop detection.
|
||||
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array.
|
||||
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
|
||||
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.
|
||||
|
||||
33
apps/server/src/routes/analytics.ts
Normal file
33
apps/server/src/routes/analytics.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
// token-analyzer-ui: context window utilization and token breakdown data.
|
||||
// v1 — global aggregates only.
|
||||
|
||||
export interface ContextWindowStats {
|
||||
avg_ctx_used: number | null;
|
||||
avg_ctx_max: number | null;
|
||||
avg_utilization_pct: number | null;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// GET /api/analytics/context — average context window utilization across
|
||||
// completed assistant messages that carry ctx_used/ctx_max.
|
||||
app.get('/api/analytics/context', async () => {
|
||||
const [row] = await sql<ContextWindowStats[]>`
|
||||
SELECT
|
||||
AVG(ctx_used)::DOUBLE PRECISION AS avg_ctx_used,
|
||||
AVG(ctx_max)::DOUBLE PRECISION AS avg_ctx_max,
|
||||
AVG(ctx_used::float / NULLIF(ctx_max, 0))::DOUBLE PRECISION AS avg_utilization_pct,
|
||||
COUNT(*)::INT AS message_count
|
||||
FROM messages
|
||||
WHERE role = 'assistant'
|
||||
AND status = 'complete'
|
||||
AND ctx_used IS NOT NULL
|
||||
AND ctx_max IS NOT NULL
|
||||
AND ctx_max > 0
|
||||
`;
|
||||
return row ?? { avg_ctx_used: null, avg_ctx_max: null, avg_utilization_pct: null, message_count: 0 };
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { realpath, stat, readdir, access } from 'node:fs/promises';
|
||||
import { realpath, stat, readdir, access, writeFile, rename } from 'node:fs/promises';
|
||||
import { basename, resolve, sep } from 'node:path';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
@@ -473,7 +473,7 @@ export function registerProjectRoutes(
|
||||
// Always includes auto_mode (the dirty-state-derived mode) so the client can
|
||||
// show a suggestion when a pinned mode diverges from what would be auto-selected.
|
||||
// Returns { git_repo: false } when the path is not a git repository.
|
||||
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>(
|
||||
app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>(
|
||||
'/api/projects/:id/git/diff',
|
||||
async (req, reply) => {
|
||||
const { id } = req.params;
|
||||
@@ -504,7 +504,8 @@ export function registerProjectRoutes(
|
||||
rawMode === 'uncommitted' ? 'uncommitted' :
|
||||
auto_mode; // no mode param → auto-select (FIX 1)
|
||||
|
||||
const result = await getGitDiff(projectRoot, mode);
|
||||
const ignoreWhitespace = req.query.whitespace === '1';
|
||||
const result = await getGitDiff(projectRoot, mode, ignoreWhitespace);
|
||||
if (result === null) {
|
||||
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
|
||||
}
|
||||
@@ -541,6 +542,11 @@ export function registerProjectRoutes(
|
||||
).min(1),
|
||||
});
|
||||
|
||||
const WriteFileBody = z.object({
|
||||
path: z.string().min(1),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/git/stage — stage whole files
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/git/stage',
|
||||
@@ -637,6 +643,38 @@ export function registerProjectRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/projects/:id/write_file — write a file atomically
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/write_file',
|
||||
async (req, reply) => {
|
||||
const body = WriteFileBody.safeParse(req.body);
|
||||
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||
const { id } = req.params;
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||
let root: string;
|
||||
try { root = await resolveProjectRoot(projectPath); }
|
||||
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||
const target = body.data.path.startsWith('/') ? body.data.path : resolve(root, body.data.path);
|
||||
// Validate path stays within project root
|
||||
const realTarget = await realpath(target).catch(() => target);
|
||||
if (!realTarget.startsWith(root + sep) && realTarget !== root) {
|
||||
reply.code(403);
|
||||
return { error: 'path escapes project root' };
|
||||
}
|
||||
const tmp = target + '.tmp';
|
||||
try {
|
||||
await writeFile(tmp, body.data.content, 'utf-8');
|
||||
await rename(tmp, target);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
// Clean up tmp on failure
|
||||
await access(tmp).then(() => rename(tmp, target + '.bak').catch(() => {})).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/projects/:id/files
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/files',
|
||||
|
||||
@@ -372,13 +372,12 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES mess
|
||||
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
||||
|
||||
-- tasks table (provider dispatch, arena)
|
||||
-- tasks table (provider dispatch)
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
parent_task_id UUID REFERENCES tasks(id),
|
||||
arena_id UUID,
|
||||
state TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')),
|
||||
input TEXT NOT NULL,
|
||||
@@ -405,3 +404,6 @@ DO $$ BEGIN
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Remove the v2.0.5 arena_id column (replaced by the new Arena feature).
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS arena_id;
|
||||
|
||||
@@ -271,7 +271,9 @@ function buildNumstatMap(
|
||||
async function getUncommittedDiff(
|
||||
gitRoot: string,
|
||||
inProgress: string | null,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<GitDiffResult> {
|
||||
const ws = ignoreWhitespace ? ['-w'] : [];
|
||||
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
|
||||
|
||||
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
|
||||
@@ -284,10 +286,10 @@ async function getUncommittedDiff(
|
||||
: runGit(['diff', '--cached', '--name-status'], gitRoot),
|
||||
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
|
||||
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||
hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||
hasCommits
|
||||
? runGit(['diff', '--cached', 'HEAD'], gitRoot)
|
||||
: runGit(['diff', '--cached'], gitRoot),
|
||||
? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot)
|
||||
: runGit(['diff', ...ws, '--cached'], gitRoot),
|
||||
]);
|
||||
|
||||
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
||||
@@ -347,11 +349,13 @@ async function getCommittedDiff(
|
||||
base: string,
|
||||
label: string,
|
||||
inProgress: string | null,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<GitDiffResult> {
|
||||
const ws = ignoreWhitespace ? ['-w'] : [];
|
||||
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
|
||||
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
|
||||
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
|
||||
runGit(['diff', base, 'HEAD'], gitRoot),
|
||||
runGit(['diff', ...ws, base, 'HEAD'], gitRoot),
|
||||
]);
|
||||
|
||||
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
||||
@@ -383,23 +387,23 @@ async function getCommittedDiff(
|
||||
* the directory is not a git repository. On a null committed-mode base, falls
|
||||
* back to uncommitted and labels the result accordingly.
|
||||
*/
|
||||
export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise<GitDiffResult | null> {
|
||||
export async function getGitDiff(cwd: string, mode: GitDiffMode, ignoreWhitespace?: boolean): Promise<GitDiffResult | null> {
|
||||
const gitRoot = await resolveGitRoot(cwd);
|
||||
if (!gitRoot) return null;
|
||||
|
||||
const inProgress = await detectInProgress(gitRoot);
|
||||
|
||||
if (mode === 'uncommitted') {
|
||||
return getUncommittedDiff(gitRoot, inProgress);
|
||||
return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
|
||||
}
|
||||
|
||||
const { base, label } = await resolveCommittedBase(gitRoot);
|
||||
if (!base) {
|
||||
// Fall back to uncommitted with a descriptive label
|
||||
const result = await getUncommittedDiff(gitRoot, inProgress);
|
||||
const result = await getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false);
|
||||
return { ...result, base_label: label };
|
||||
}
|
||||
return getCommittedDiff(gitRoot, base, label, inProgress);
|
||||
return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false);
|
||||
}
|
||||
|
||||
// ── Phase 2: Write helpers ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deduplicate } from '../strategies/deduplication.js';
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
describe('deduplicate', () => {
|
||||
it('removes consecutive identical tool_call+tool_result pairs', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'user', content: 'search for x' },
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result1', tool_call_id: '1' },
|
||||
// Duplicate pair
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result1', tool_call_id: '2' },
|
||||
];
|
||||
|
||||
const { messages: result, stats } = deduplicate(messages);
|
||||
expect(result).toHaveLength(3); // user + first pair
|
||||
expect(stats.removedCount).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves non-duplicate content', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result1', tool_call_id: '1' },
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result2', tool_call_id: '2' }, // Different result
|
||||
];
|
||||
|
||||
const { messages: result, stats } = deduplicate(messages);
|
||||
expect(result).toHaveLength(4);
|
||||
expect(stats.removedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toDcpMessages, fromDcpMessages } from '../messages.js';
|
||||
|
||||
describe('toDcpMessages', () => {
|
||||
it('converts user messages', () => {
|
||||
const result = toDcpMessages([{ role: 'user', content: 'hello' }]);
|
||||
expect(result[0].role).toBe('user');
|
||||
expect(result[0].content).toBe('hello');
|
||||
});
|
||||
|
||||
it('marks Error: content as isError', () => {
|
||||
const result = toDcpMessages([{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' }]);
|
||||
expect(result[0].isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromDcpMessages', () => {
|
||||
it('round-trips messages', () => {
|
||||
const original = [{ role: 'user', content: 'hello' }];
|
||||
expect(fromDcpMessages(toDcpMessages(original))).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { purgeErrors } from '../strategies/purge-errors.js';
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
describe('purgeErrors', () => {
|
||||
it('removes tool results where content starts with Error:', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' },
|
||||
{ role: 'tool', content: '{"files":[]}', tool_call_id: '2' },
|
||||
];
|
||||
const { messages: result, stats } = purgeErrors(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(stats.removedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('removes empty tool results', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'tool', content: '', tool_call_id: '1' },
|
||||
];
|
||||
const { messages: result, stats } = purgeErrors(messages);
|
||||
expect(result).toHaveLength(0);
|
||||
expect(stats.removedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves valid tool results', () => {
|
||||
const messages: DcpMessage[] = [
|
||||
{ role: 'tool', content: '{"files":["a.ts"]}', tool_call_id: '1' },
|
||||
];
|
||||
const { messages: result, stats } = purgeErrors(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(stats.removedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { transformMessages } from '../transform.js';
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
describe('transformMessages', () => {
|
||||
it('applies dedup then purge in order', () => {
|
||||
const input: DcpMessage[] = [
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result', tool_call_id: '1' },
|
||||
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: 'result', tool_call_id: '2' }, // Dup
|
||||
];
|
||||
|
||||
const { messages, stats } = transformMessages('test-chat', input);
|
||||
expect(stats.removedCount).toBeGreaterThan(0);
|
||||
expect(messages.length).toBeLessThan(input.length);
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
const { messages, stats } = transformMessages('empty', []);
|
||||
expect(messages).toHaveLength(0);
|
||||
expect(stats.removedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
4
apps/server/src/services/inference/dcp/index.ts
Normal file
4
apps/server/src/services/inference/dcp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { transformMessages } from './transform.js';
|
||||
export type { DcpMessage } from './messages.js';
|
||||
export { toDcpMessages, fromDcpMessages } from './messages.js';
|
||||
export { getDcpState, clearDcpState } from './state.js';
|
||||
34
apps/server/src/services/inference/dcp/messages.ts
Normal file
34
apps/server/src/services/inference/dcp/messages.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// DCP message shape adapter.
|
||||
// Converts between BooCode MessagePart[] and the DCP internal shape.
|
||||
// Clean-room implementation — no AGPL source copied.
|
||||
|
||||
export interface DcpMessage {
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export function toDcpMessages(parts: any[]): DcpMessage[] {
|
||||
return parts.map((p: any) => {
|
||||
const msg: DcpMessage = { role: p.role, content: p.content ?? '' };
|
||||
if (p.tool_call_id) msg.tool_call_id = p.tool_call_id;
|
||||
if (p.tool_calls) msg.tool_calls = p.tool_calls;
|
||||
if (p.isError) msg.isError = true;
|
||||
if (p.role === 'tool' && p.content && p.content.startsWith('Error:')) {
|
||||
msg.isError = true;
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
|
||||
export function fromDcpMessages(msgs: DcpMessage[]): any[] {
|
||||
return msgs.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
||||
...(m.isError ? { isError: true } : {}),
|
||||
}));
|
||||
}
|
||||
27
apps/server/src/services/inference/dcp/state.ts
Normal file
27
apps/server/src/services/inference/dcp/state.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Per-chat session state for DCP.
|
||||
// Tracks last transform timestamp and message count to avoid re-processing.
|
||||
|
||||
interface ChatDcpState {
|
||||
lastTransformAt: number;
|
||||
lastMessageCount: number;
|
||||
}
|
||||
|
||||
const chatStates = new Map<string, ChatDcpState>();
|
||||
|
||||
export function getDcpState(chatId: string): ChatDcpState | undefined {
|
||||
return chatStates.get(chatId);
|
||||
}
|
||||
|
||||
export function setDcpState(chatId: string, messageCount: number): void {
|
||||
chatStates.set(chatId, { lastTransformAt: Date.now(), lastMessageCount: messageCount });
|
||||
}
|
||||
|
||||
export function clearDcpState(chatId: string): void {
|
||||
chatStates.delete(chatId);
|
||||
}
|
||||
|
||||
export function shouldTransform(chatId: string, messageCount: number): boolean {
|
||||
const state = chatStates.get(chatId);
|
||||
if (!state) return true;
|
||||
return state.lastMessageCount !== messageCount;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
export function deduplicate(messages: DcpMessage[]): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
|
||||
const result: DcpMessage[] = [];
|
||||
let removedCount = 0;
|
||||
let freedTokens = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < messages.length) {
|
||||
const current: DcpMessage = messages[i]!;
|
||||
const next = messages[i + 1];
|
||||
|
||||
if (
|
||||
current.role === 'assistant' &&
|
||||
current.tool_calls &&
|
||||
next &&
|
||||
next.role === 'tool' &&
|
||||
next.tool_call_id === current.tool_calls[0]?.id
|
||||
) {
|
||||
const nextNext = messages[i + 2];
|
||||
const nextNextNext = messages[i + 3];
|
||||
|
||||
if (
|
||||
nextNext &&
|
||||
nextNext.role === 'assistant' &&
|
||||
nextNext.tool_calls &&
|
||||
nextNextNext &&
|
||||
nextNextNext.role === 'tool' &&
|
||||
nextNextNext.tool_call_id === nextNext.tool_calls[0]?.id &&
|
||||
nextNext.tool_calls[0]?.name === current.tool_calls[0]?.name &&
|
||||
nextNext.tool_calls[0]?.arguments === current.tool_calls[0]?.arguments &&
|
||||
nextNextNext.content === next.content
|
||||
) {
|
||||
result.push(current, next);
|
||||
i += 4;
|
||||
removedCount += 2;
|
||||
freedTokens += Math.ceil(nextNext.content.length / 4);
|
||||
freedTokens += Math.ceil(current.content.length / 4);
|
||||
} else {
|
||||
result.push(current);
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
result.push(current);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { messages: result, stats: { removedCount, freedTokens } };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Purge-errors strategy — removes failed/empty tool_result entries.
|
||||
// Clean-room implementation.
|
||||
|
||||
import type { DcpMessage } from '../messages.js';
|
||||
|
||||
const ERROR_PREFIXES = ['Error:', 'error:', 'Error: '];
|
||||
const DEFAULT_WINDOW = 5;
|
||||
|
||||
export function purgeErrors(
|
||||
messages: DcpMessage[],
|
||||
windowSize: number = DEFAULT_WINDOW,
|
||||
): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
|
||||
const result: DcpMessage[] = [];
|
||||
let removedCount = 0;
|
||||
let freedTokens = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'tool') {
|
||||
const shouldRemove =
|
||||
msg.isError ||
|
||||
ERROR_PREFIXES.some((p) => msg.content.startsWith(p)) ||
|
||||
msg.content.trim() === '';
|
||||
|
||||
if (shouldRemove) {
|
||||
removedCount++;
|
||||
freedTokens += Math.ceil(msg.content.length / 4);
|
||||
continue; // Skip this message
|
||||
}
|
||||
}
|
||||
result.push(msg);
|
||||
}
|
||||
|
||||
return { messages: result, stats: { removedCount, freedTokens } };
|
||||
}
|
||||
52
apps/server/src/services/inference/dcp/transform.ts
Normal file
52
apps/server/src/services/inference/dcp/transform.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Transform orchestrator — runs DCP strategies in sequence.
|
||||
// Clean-room implementation.
|
||||
|
||||
import type { DcpMessage } from './messages.js';
|
||||
import { deduplicate } from './strategies/deduplication.js';
|
||||
import { purgeErrors } from './strategies/purge-errors.js';
|
||||
import { getDcpState, setDcpState, shouldTransform } from './state.js';
|
||||
|
||||
export interface TransformStats {
|
||||
removedCount: number;
|
||||
freedTokens: number;
|
||||
dedupRemoved: number;
|
||||
purgeRemoved: number;
|
||||
}
|
||||
|
||||
export interface TransformResult {
|
||||
messages: DcpMessage[];
|
||||
stats: TransformStats;
|
||||
}
|
||||
|
||||
export function transformMessages(chatId: string, messages: DcpMessage[]): TransformResult {
|
||||
if (!shouldTransform(chatId, messages.length)) {
|
||||
return { messages, stats: { removedCount: 0, freedTokens: 0, dedupRemoved: 0, purgeRemoved: 0 } };
|
||||
}
|
||||
|
||||
let m = messages;
|
||||
|
||||
// Step 1: Deduplicate
|
||||
const dedupResult = deduplicate(m);
|
||||
m = dedupResult.messages;
|
||||
const dedupRemoved = dedupResult.stats.removedCount;
|
||||
|
||||
// Step 2: Purge errors
|
||||
const purgeResult = purgeErrors(m);
|
||||
m = purgeResult.messages;
|
||||
const purgeRemoved = purgeResult.stats.removedCount;
|
||||
|
||||
const totalRemoved = dedupRemoved + purgeRemoved;
|
||||
const totalFreed = dedupResult.stats.freedTokens + purgeResult.stats.freedTokens;
|
||||
|
||||
setDcpState(chatId, messages.length);
|
||||
|
||||
return {
|
||||
messages: m,
|
||||
stats: {
|
||||
removedCount: totalRemoved,
|
||||
freedTokens: totalFreed,
|
||||
dedupRemoved,
|
||||
purgeRemoved,
|
||||
},
|
||||
};
|
||||
}
|
||||
68
apps/server/src/services/inference/loop-detectors.ts
Normal file
68
apps/server/src/services/inference/loop-detectors.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Loop detectors — detects repetitive patterns in assistant output
|
||||
// that indicate a model is stuck in a loop.
|
||||
|
||||
export interface LoopDetectionResult {
|
||||
isLoop: boolean;
|
||||
reason?: string;
|
||||
confidence: number; // 0-1
|
||||
}
|
||||
|
||||
const REPEATED_PHRASE_MIN_COUNT = 4;
|
||||
const REPEATED_TOOL_MIN_COUNT = 3;
|
||||
|
||||
export function detectContentRepeat(messages: string[]): LoopDetectionResult {
|
||||
if (messages.length < REPEATED_PHRASE_MIN_COUNT) {
|
||||
return { isLoop: false, confidence: 0 };
|
||||
}
|
||||
|
||||
const recent = messages.slice(-REPEATED_PHRASE_MIN_COUNT);
|
||||
const unique = new Set(recent);
|
||||
|
||||
if (unique.size === 1) {
|
||||
return {
|
||||
isLoop: true,
|
||||
reason: `Same content repeated ${REPEATED_PHRASE_MIN_COUNT} times`,
|
||||
confidence: 0.9,
|
||||
};
|
||||
}
|
||||
|
||||
if (unique.size <= 2 && recent.length >= 4) {
|
||||
return {
|
||||
isLoop: true,
|
||||
reason: 'Content oscillating between two variants',
|
||||
confidence: 0.7,
|
||||
};
|
||||
}
|
||||
|
||||
return { isLoop: false, confidence: 0 };
|
||||
}
|
||||
|
||||
export function detectToolLoop(toolNames: string[]): LoopDetectionResult {
|
||||
if (toolNames.length < REPEATED_TOOL_MIN_COUNT) return { isLoop: false, confidence: 0 };
|
||||
|
||||
const recent = toolNames.slice(-REPEATED_TOOL_MIN_COUNT);
|
||||
const unique = new Set(recent);
|
||||
|
||||
if (unique.size === 1) {
|
||||
return {
|
||||
isLoop: true,
|
||||
reason: `Same tool "${recent[0]}" called ${REPEATED_TOOL_MIN_COUNT} times consecutively`,
|
||||
confidence: 0.85,
|
||||
};
|
||||
}
|
||||
|
||||
return { isLoop: false, confidence: 0 };
|
||||
}
|
||||
|
||||
export function detectDoomLoop(
|
||||
messages: string[],
|
||||
toolNames: string[],
|
||||
): LoopDetectionResult {
|
||||
const contentResult = detectContentRepeat(messages);
|
||||
if (contentResult.isLoop) return contentResult;
|
||||
|
||||
const toolResult = detectToolLoop(toolNames);
|
||||
if (toolResult.isLoop) return toolResult;
|
||||
|
||||
return { isLoop: false, confidence: 0 };
|
||||
}
|
||||
45
apps/server/src/services/inference/tool-shim.ts
Normal file
45
apps/server/src/services/inference/tool-shim.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// ToolShim — recovers structured tool calls from plain-text model output.
|
||||
// When the model emits tool calls as plain text instead of structured JSON,
|
||||
// this shim attempts to parse and recover them.
|
||||
|
||||
export interface ParsedToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
const TOOL_CALL_PATTERN = /<tool_call>\s*<name>(.+?)<\/name>\s*<arguments>(.+?)<\/arguments>\s*<\/tool_call>/gs;
|
||||
const JSON_TOOL_PATTERN = /\{\s*"name":\s*"([^"]+)",\s*"arguments":\s*({.+?})\s*\}/gs;
|
||||
|
||||
export function extractToolCalls(text: string): ParsedToolCall[] {
|
||||
const calls: ParsedToolCall[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Try XML-style tool calls (common in Qwen output)
|
||||
const xmlRegex = new RegExp(TOOL_CALL_PATTERN);
|
||||
while ((match = xmlRegex.exec(text)) !== null) {
|
||||
calls.push({
|
||||
id: `call_${calls.length}`,
|
||||
name: match[1]!.trim(),
|
||||
arguments: match[2]!.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
if (calls.length > 0) return calls;
|
||||
|
||||
// Try JSON-style tool calls
|
||||
const jsonRegex = new RegExp(JSON_TOOL_PATTERN);
|
||||
while ((match = jsonRegex.exec(text)) !== null) {
|
||||
calls.push({
|
||||
id: `call_${calls.length}`,
|
||||
name: match[1]!.trim(),
|
||||
arguments: match[2]!.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
export function hasToolCallMarkup(text: string): boolean {
|
||||
return TOOL_CALL_PATTERN.test(text) || JSON_TOOL_PATTERN.test(text);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
buildMessagesPayload,
|
||||
loadContext,
|
||||
} from './payload.js';
|
||||
import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js';
|
||||
import {
|
||||
finalizeCompletion,
|
||||
finalizeEmpty,
|
||||
@@ -156,9 +157,20 @@ export async function runAssistantTurn(
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
||||
break;
|
||||
}
|
||||
const { session: iterSession, project: iterProject, history } = loaded;
|
||||
let { session: iterSession, project: iterProject, history } = loaded;
|
||||
const projectRoot = await resolveProjectRoot(iterProject.path);
|
||||
|
||||
try {
|
||||
const dcpMsgs = toDcpMessages(history);
|
||||
const { messages: pruned, stats } = transformMessages(chatId, dcpMsgs);
|
||||
if (stats.removedCount > 0) {
|
||||
ctx.log.info({ chatId, ...stats }, 'dcp: transform removed messages');
|
||||
history = fromDcpMessages(pruned) as typeof history;
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'dcp: transform skipped');
|
||||
}
|
||||
|
||||
// v1.14.0: log step boundary for instrumentation. step_start parts are in
|
||||
// the schema CHECK but not emitted here — writing to the assistant message
|
||||
// before the stream phase creates a sequence-0 collision with
|
||||
|
||||
@@ -44,7 +44,11 @@ export interface InferenceFrame {
|
||||
| 'chat_renamed'
|
||||
| 'error'
|
||||
| 'flow_run_started'
|
||||
| 'flow_run_step_updated';
|
||||
| 'flow_run_step_updated'
|
||||
// arena frames
|
||||
| 'battle_started'
|
||||
| 'contestant_updated'
|
||||
| 'battle_updated';
|
||||
message_id?: string;
|
||||
message_ids?: string[];
|
||||
chat_id?: string;
|
||||
@@ -84,6 +88,19 @@ export interface InferenceFrame {
|
||||
status?: string;
|
||||
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
report?: string;
|
||||
// arena frames
|
||||
battle_id?: string;
|
||||
battle_type?: 'coding' | 'qa';
|
||||
prompt?: string;
|
||||
contestants?: Array<{ id: string; identity: string; model: string; lane: 'local' | 'cloud' }>;
|
||||
contestant_id?: string;
|
||||
battle_status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
duration_ms?: number;
|
||||
tokens_per_sec?: number;
|
||||
winner_contestant_id?: string | null;
|
||||
analysis_ready?: boolean;
|
||||
cross_exam_id?: string;
|
||||
delta?: string;
|
||||
}
|
||||
|
||||
export type FramePublisher = (sessionId: string, frame: InferenceFrame) => void;
|
||||
|
||||
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal file
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Bm25Ranker } from '../bm25.js';
|
||||
|
||||
describe('Bm25Ranker', () => {
|
||||
it('scores documents by term frequency', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit(['the cat sat on the mat', 'the dog chased the cat', 'the bird flew over the mat']);
|
||||
const results = ranker.rank('cat mat');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]!.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns empty for no matches', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit(['aaa bbb', 'ccc ddd']);
|
||||
const results = ranker.rank('zzz');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles single document corpus', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit(['only document here']);
|
||||
const results = ranker.rank('document');
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ranks relevant docs higher', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit([
|
||||
'javascript is a programming language',
|
||||
'python is also a programming language',
|
||||
'the weather is nice today',
|
||||
]);
|
||||
const results = ranker.rank('javascript programming');
|
||||
expect(results[0]!.index).toBe(0);
|
||||
});
|
||||
});
|
||||
31
apps/server/src/services/memory/__tests__/entries.test.ts
Normal file
31
apps/server/src/services/memory/__tests__/entries.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMemoryEntries } from '../entries.js';
|
||||
|
||||
describe('parseMemoryEntries', () => {
|
||||
it('parses a single entry with tags', () => {
|
||||
const md = '## project: Indentation\n> tags: style\n\nUse two-space indentation\n';
|
||||
const entries = parseMemoryEntries('style.md', md);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].title).toBe('Indentation');
|
||||
expect(entries[0].topic).toBe('project');
|
||||
expect(entries[0].tags).toEqual(['style']);
|
||||
expect(entries[0].content).toContain('two-space');
|
||||
});
|
||||
|
||||
it('parses multiple entries', () => {
|
||||
const md = [
|
||||
'## project: Style',
|
||||
'',
|
||||
'Use tab indentation',
|
||||
'',
|
||||
'## user: Preference',
|
||||
'',
|
||||
'Prefer pnpm',
|
||||
'',
|
||||
].join('\n');
|
||||
const entries = parseMemoryEntries('mem.md', md);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].topic).toBe('project');
|
||||
expect(entries[1].topic).toBe('user');
|
||||
});
|
||||
});
|
||||
14
apps/server/src/services/memory/__tests__/paths.test.ts
Normal file
14
apps/server/src/services/memory/__tests__/paths.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getMemoryRoot, getTopicDir } from '../paths.js';
|
||||
|
||||
describe('getMemoryRoot', () => {
|
||||
it('returns .boocode/memory under project root', () => {
|
||||
expect(getMemoryRoot('/proj')).toBe('/proj/.boocode/memory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopicDir', () => {
|
||||
it('returns project/ under memory root', () => {
|
||||
expect(getTopicDir('/r/.boocode/memory', 'project')).toBe('/r/.boocode/memory/project');
|
||||
});
|
||||
});
|
||||
15
apps/server/src/services/memory/__tests__/prompt.test.ts
Normal file
15
apps/server/src/services/memory/__tests__/prompt.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatMemoryBlock } from '../prompt.js';
|
||||
|
||||
describe('formatMemoryBlock', () => {
|
||||
it('wraps entries in boocode-memory tags', () => {
|
||||
const block = formatMemoryBlock(['Use pnpm', 'Tests in vitest']);
|
||||
expect(block).toContain('<boocode-memory>');
|
||||
expect(block).toContain('Use pnpm');
|
||||
expect(block).toContain('</boocode-memory>');
|
||||
});
|
||||
|
||||
it('returns empty string for no entries', () => {
|
||||
expect(formatMemoryBlock([])).toBe('');
|
||||
});
|
||||
});
|
||||
27
apps/server/src/services/memory/__tests__/recall.test.ts
Normal file
27
apps/server/src/services/memory/__tests__/recall.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { rankByRelevance } from '../recall.js';
|
||||
import type { MemoryEntry } from '../entries.js';
|
||||
|
||||
describe('rankByRelevance', () => {
|
||||
it('returns entries matching query keywords', () => {
|
||||
const entries: MemoryEntry[] = [
|
||||
{ id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] },
|
||||
{ id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] },
|
||||
];
|
||||
const result = rankByRelevance('what indentation?', entries);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Style');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankByHybrid', () => {
|
||||
it('falls back to BM25 when embeddings unavailable', async () => {
|
||||
const entries: MemoryEntry[] = [
|
||||
{ id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] },
|
||||
{ id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] },
|
||||
];
|
||||
const { rankByHybrid } = await import('../recall.js');
|
||||
const result = await rankByHybrid('indentation style', entries);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
67
apps/server/src/services/memory/bm25.ts
Normal file
67
apps/server/src/services/memory/bm25.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// BM25 ranker — pure Okapi BM25 scoring. No external deps.
|
||||
|
||||
interface Bm25Config {
|
||||
k1?: number;
|
||||
b?: number;
|
||||
}
|
||||
|
||||
export class Bm25Ranker {
|
||||
private k1: number;
|
||||
private b: number;
|
||||
private corpus: string[];
|
||||
private avgDocLen: number;
|
||||
private idfCache: Map<string, number>;
|
||||
private docCount: number;
|
||||
|
||||
constructor(config?: Bm25Config) {
|
||||
this.k1 = config?.k1 ?? 1.5;
|
||||
this.b = config?.b ?? 0.75;
|
||||
this.corpus = [];
|
||||
this.avgDocLen = 0;
|
||||
this.idfCache = new Map();
|
||||
this.docCount = 0;
|
||||
}
|
||||
|
||||
fit(docs: string[]): void {
|
||||
this.corpus = docs;
|
||||
this.docCount = docs.length;
|
||||
const lengths = docs.map((d) => d.split(/\s+/).length);
|
||||
this.avgDocLen = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
||||
this.idfCache.clear();
|
||||
}
|
||||
|
||||
private tokenize(text: string): string[] {
|
||||
return text.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
private idf(term: string): number {
|
||||
const cached = this.idfCache.get(term);
|
||||
if (cached !== undefined) return cached;
|
||||
const docsWithTerm = this.corpus.filter((d) => this.tokenize(d).includes(term)).length;
|
||||
const idf = Math.log(1 + (this.docCount - docsWithTerm + 0.5) / (docsWithTerm + 0.5));
|
||||
this.idfCache.set(term, idf);
|
||||
return idf;
|
||||
}
|
||||
|
||||
score(query: string, docIndex: number): number {
|
||||
if (docIndex < 0 || docIndex >= this.corpus.length) return 0;
|
||||
const doc = this.corpus[docIndex]!;
|
||||
const queryTerms = this.tokenize(query);
|
||||
const docTokens = this.tokenize(doc);
|
||||
const docLen = docTokens.length;
|
||||
|
||||
let total = 0;
|
||||
for (const term of queryTerms) {
|
||||
const tf = docTokens.filter((t) => t === term).length;
|
||||
if (tf === 0) continue;
|
||||
const idfVal = this.idf(term);
|
||||
total += idfVal * ((tf * (this.k1 + 1)) / (tf + this.k1 * (1 - this.b + this.b * docLen / this.avgDocLen)));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
rank(query: string, topN: number = 10): Array<{ index: number; score: number }> {
|
||||
const scores = this.corpus.map((_, i) => ({ index: i, score: this.score(query, i) }));
|
||||
return scores.sort((a, b) => b.score - a.score).slice(0, topN).filter((s) => s.score > 0);
|
||||
}
|
||||
}
|
||||
55
apps/server/src/services/memory/embeddings.ts
Normal file
55
apps/server/src/services/memory/embeddings.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Embedding module — ONNX-based local embeddings.
|
||||
// Falls back gracefully when the model file is not available.
|
||||
|
||||
let model: any = null;
|
||||
let ortModule: any = null;
|
||||
|
||||
export function isEmbeddingAvailable(): boolean {
|
||||
return model !== null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const dynamicRequire = typeof require !== 'undefined' ? require : null;
|
||||
|
||||
export async function initEmbeddings(modelPath?: string): Promise<boolean> {
|
||||
try {
|
||||
if (dynamicRequire) {
|
||||
try { ortModule = dynamicRequire('onnxruntime-node'); } catch { ortModule = null; }
|
||||
}
|
||||
if (!ortModule) {
|
||||
try { ortModule = await import('onnxruntime-node' as any); } catch { ortModule = null; }
|
||||
}
|
||||
if (!ortModule) return false;
|
||||
const path = modelPath ?? process.env['EMBEDDING_MODEL_PATH'] ?? '';
|
||||
if (!path) return false;
|
||||
model = await ortModule.InferenceSession.create(path);
|
||||
return true;
|
||||
} catch {
|
||||
model = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function embed(texts: string[]): Promise<number[][] | null> {
|
||||
if (!model) return null;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const ort: { Tensor: new (...args: unknown[]) => unknown } | null = ortModule || null;
|
||||
if (!ort) return null;
|
||||
const input = new ort.Tensor('string', texts, [texts.length]);
|
||||
const feeds: Record<string, any> = {};
|
||||
feeds[model.inputNames[0]] = input;
|
||||
const results = await model.run(feeds);
|
||||
const output = results[model.outputNames[0]];
|
||||
if (!output || !output.data) return null;
|
||||
const dim = output.dims?.[1] ?? 384;
|
||||
const data = output.data as Float32Array;
|
||||
const vectors: number[][] = [];
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
vectors.push(Array.from(data.slice(i * dim, (i + 1) * dim)));
|
||||
}
|
||||
return vectors;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
54
apps/server/src/services/memory/entries.ts
Normal file
54
apps/server/src/services/memory/entries.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
topic: string;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export function parseMemoryEntries(fileName: string, markdown: string): MemoryEntry[] {
|
||||
const entries: MemoryEntry[] = [];
|
||||
const lines = markdown.split('\n');
|
||||
let currentEntry: Partial<MemoryEntry> | null = null;
|
||||
let currentContent: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(/^##\s+(.+):\s+(.+)$/);
|
||||
if (headingMatch && headingMatch[1] && headingMatch[2]) {
|
||||
if (currentEntry && currentEntry.title) {
|
||||
entries.push({
|
||||
id: `${fileName}-${entries.length}`,
|
||||
topic: currentEntry.topic ?? '',
|
||||
title: currentEntry.title,
|
||||
content: currentContent.join('\n').trim(),
|
||||
tags: currentEntry.tags ?? [],
|
||||
});
|
||||
}
|
||||
currentEntry = { topic: headingMatch[1].trim(), title: headingMatch[2].trim(), tags: [] };
|
||||
currentContent = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagsMatch = line.match(/^>\s*tags:\s*(.+)$/i);
|
||||
if (tagsMatch && tagsMatch[1] && currentEntry) {
|
||||
currentEntry.tags = tagsMatch[1].split(',').map((t) => t.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentEntry) {
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEntry && currentEntry.title) {
|
||||
entries.push({
|
||||
id: `${fileName}-${entries.length}`,
|
||||
topic: currentEntry.topic ?? '',
|
||||
title: currentEntry.title,
|
||||
content: currentContent.join('\n').trim(),
|
||||
tags: currentEntry.tags ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
6
apps/server/src/services/memory/index.ts
Normal file
6
apps/server/src/services/memory/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { loadMemoryForSession } from './recall.js';
|
||||
export { formatMemoryBlock } from './prompt.js';
|
||||
export { scanMemoryScopes } from './scan.js';
|
||||
export { parseMemoryEntries } from './entries.js';
|
||||
export { ensureMemoryScaffold, getMemoryRoot } from './paths.js';
|
||||
export type { MemoryEntry } from './entries.js';
|
||||
17
apps/server/src/services/memory/paths.ts
Normal file
17
apps/server/src/services/memory/paths.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
const TOPICS = ['project', 'user', 'reference'] as const;
|
||||
export type MemoryTopic = (typeof TOPICS)[number];
|
||||
|
||||
export function getMemoryRoot(projectRoot: string): string {
|
||||
return join(projectRoot, '.boocode', 'memory');
|
||||
}
|
||||
|
||||
export function getTopicDir(root: string, topic: MemoryTopic): string {
|
||||
return join(root, topic);
|
||||
}
|
||||
|
||||
export async function ensureMemoryScaffold(root: string): Promise<void> {
|
||||
await Promise.all(TOPICS.map((t) => mkdir(join(root, t), { recursive: true })));
|
||||
}
|
||||
5
apps/server/src/services/memory/prompt.ts
Normal file
5
apps/server/src/services/memory/prompt.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function formatMemoryBlock(entries: string[]): string {
|
||||
if (entries.length === 0) return '';
|
||||
const body = entries.map((e) => `- ${e}`).join('\n');
|
||||
return `<boocode-memory>\n${body}\n</boocode-memory>`;
|
||||
}
|
||||
100
apps/server/src/services/memory/recall.ts
Normal file
100
apps/server/src/services/memory/recall.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { MemoryEntry } from './entries.js';
|
||||
import { scanProjectMemory } from './scan.js';
|
||||
import { Bm25Ranker } from './bm25.js';
|
||||
import { embed, isEmbeddingAvailable } from './embeddings.js';
|
||||
|
||||
const SEARCH_MODE = process.env['MEMORY_SEARCH'] ?? 'hybrid';
|
||||
|
||||
function extractKeywords(query: string): string[] {
|
||||
return query
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, '')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 2);
|
||||
}
|
||||
|
||||
export function rankByRelevance(query: string, entries: MemoryEntry[]): MemoryEntry[] {
|
||||
const keywords = extractKeywords(query);
|
||||
if (keywords.length === 0) return entries.slice(0, 5);
|
||||
|
||||
const scored = entries.map((entry) => {
|
||||
let score = 0;
|
||||
const searchText = `${entry.title} ${entry.content} ${entry.tags.join(' ')}`.toLowerCase();
|
||||
for (const kw of keywords) {
|
||||
if (entry.title.toLowerCase().includes(kw)) score += 3;
|
||||
if (entry.tags.some((t) => t.toLowerCase().includes(kw))) score += 2;
|
||||
if (entry.content.toLowerCase().includes(kw)) score += 1;
|
||||
}
|
||||
return { entry, score };
|
||||
});
|
||||
|
||||
return scored
|
||||
.filter((s) => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 10)
|
||||
.map((s) => s.entry);
|
||||
}
|
||||
|
||||
export async function rankByHybrid(
|
||||
query: string,
|
||||
entries: MemoryEntry[],
|
||||
): Promise<MemoryEntry[]> {
|
||||
if (entries.length === 0) return [];
|
||||
const texts = entries.map((e) => `${e.title} ${e.content} ${e.tags.join(' ')}`);
|
||||
|
||||
const bm25 = new Bm25Ranker();
|
||||
bm25.fit(texts);
|
||||
const bm25Scores = texts.map((_, i) => bm25.score(query, i));
|
||||
const maxBm25 = Math.max(...bm25Scores, 1);
|
||||
const normBm25 = bm25Scores.map((s) => s / maxBm25);
|
||||
|
||||
let cosineScores: number[] = [];
|
||||
if (isEmbeddingAvailable()) {
|
||||
const vectors = await embed([query, ...texts]);
|
||||
if (vectors) {
|
||||
const queryVec = vectors[0]!;
|
||||
cosineScores = texts.map((_, i) => {
|
||||
const vec = vectors[i + 1];
|
||||
if (!vec) return 0;
|
||||
let dot = 0, nA = 0, nB = 0;
|
||||
for (let j = 0; j < queryVec.length; j++) {
|
||||
dot += queryVec[j]! * vec[j]!;
|
||||
nA += queryVec[j]! * queryVec[j]!;
|
||||
nB += vec[j]! * vec[j]!;
|
||||
}
|
||||
const denom = Math.sqrt(nA) * Math.sqrt(nB);
|
||||
return denom === 0 ? 0 : dot / denom;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scored = entries.map((entry, i) => {
|
||||
const combined = (normBm25[i]! * 0.3) + ((cosineScores[i] ?? 0) * 0.7);
|
||||
return { entry, score: combined };
|
||||
});
|
||||
|
||||
return scored
|
||||
.filter((s) => s.score >= 0.15)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 10)
|
||||
.map((s) => s.entry);
|
||||
}
|
||||
|
||||
export async function loadMemoryForSession(
|
||||
projectRoot: string,
|
||||
_sessionId?: string,
|
||||
query?: string,
|
||||
): Promise<string[]> {
|
||||
const entries = await scanProjectMemory(projectRoot);
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
const relevant = query
|
||||
? SEARCH_MODE === 'keyword'
|
||||
? rankByRelevance(query, entries)
|
||||
: await rankByHybrid(query, entries)
|
||||
: entries.slice(0, 5);
|
||||
|
||||
return relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`);
|
||||
}
|
||||
|
||||
export { initEmbeddings } from './embeddings.js';
|
||||
72
apps/server/src/services/memory/scan.ts
Normal file
72
apps/server/src/services/memory/scan.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import type { MemoryEntry } from './entries.js';
|
||||
import { parseMemoryEntries } from './entries.js';
|
||||
import { getMemoryRoot } from './paths.js';
|
||||
|
||||
export interface MemoryScope {
|
||||
projectRoot: string;
|
||||
sessionDir?: string;
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
async function scanDirectory(dir: string): Promise<MemoryEntry[]> {
|
||||
const entries: MemoryEntry[] = [];
|
||||
try {
|
||||
const files = await readdir(dir, { withFileTypes: true });
|
||||
for (const file of files) {
|
||||
if (file.isFile() && file.name.endsWith('.md')) {
|
||||
const content = await readFile(join(dir, file.name), 'utf8');
|
||||
entries.push(...parseMemoryEntries(file.name, content));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
const MEMORY_TOPICS = ['project', 'user', 'reference'] as const;
|
||||
|
||||
async function scanTopicDirs(root: string): Promise<MemoryEntry[]> {
|
||||
const entries: MemoryEntry[] = [];
|
||||
for (const topic of MEMORY_TOPICS) {
|
||||
entries.push(...(await scanDirectory(join(root, topic))));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function scanMemoryScopes(scope: MemoryScope): Promise<MemoryEntry[]> {
|
||||
const allEntries: MemoryEntry[] = [];
|
||||
|
||||
// 1. Global (~/.boocode/memory/) - lowest priority
|
||||
allEntries.push(...(await scanTopicDirs(getMemoryRoot(homedir()))));
|
||||
|
||||
// 2. Home ($HOME/.boocode/memory)
|
||||
const homeDir = scope.homeDir ?? homedir();
|
||||
const homeRoot = getMemoryRoot(homeDir);
|
||||
if (homeRoot !== getMemoryRoot(homedir())) {
|
||||
allEntries.push(...(await scanTopicDirs(homeRoot)));
|
||||
}
|
||||
|
||||
// 3. Project (.boocode/memory/ under project root)
|
||||
allEntries.push(...(await scanTopicDirs(getMemoryRoot(scope.projectRoot))));
|
||||
|
||||
// 4. Session (.boocode/sessions/<id>/memory.md) - highest priority
|
||||
if (scope.sessionDir) {
|
||||
try {
|
||||
const sessionFile = join(scope.sessionDir, 'memory.md');
|
||||
const content = await readFile(sessionFile, 'utf8');
|
||||
allEntries.push(...parseMemoryEntries('session-memory', content));
|
||||
} catch {
|
||||
// No session memory file
|
||||
}
|
||||
}
|
||||
|
||||
return allEntries;
|
||||
}
|
||||
|
||||
export async function scanProjectMemory(projectRoot: string): Promise<MemoryEntry[]> {
|
||||
return scanMemoryScopes({ projectRoot });
|
||||
}
|
||||
35
apps/server/src/services/memory/store.ts
Normal file
35
apps/server/src/services/memory/store.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { MemoryTopic } from './paths.js';
|
||||
import { getTopicDir } from './paths.js';
|
||||
|
||||
export async function readTopicFiles(root: string, topic: MemoryTopic): Promise<Map<string, string>> {
|
||||
const dir = getTopicDir(root, topic);
|
||||
const files = new Map<string, string>();
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
const content = await readFile(join(dir, entry.name), 'utf8');
|
||||
files.set(entry.name, content);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist yet
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function writeEntry(
|
||||
root: string,
|
||||
topic: MemoryTopic,
|
||||
title: string,
|
||||
content: string,
|
||||
tags: string[],
|
||||
): Promise<void> {
|
||||
const dir = getTopicDir(root, topic);
|
||||
const tagLine = tags.length > 0 ? `> tags: ${tags.join(', ')}\n\n` : '\n';
|
||||
const entry = `## ${topic}: ${title}\n${tagLine}${content}\n`;
|
||||
const filename = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.md';
|
||||
await writeFile(join(dir, filename), entry, 'utf8');
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const SYNTHESIS_TOOLS: ReadonlySet<string> = new Set([
|
||||
'get_codebase_overview',
|
||||
'get_framework_analysis',
|
||||
'get_semantic_neighborhoods',
|
||||
'get_blast_radius',
|
||||
]);
|
||||
|
||||
const TOP_N_FILES = 5;
|
||||
|
||||
@@ -22,6 +22,8 @@ import { readFile, stat } from 'node:fs/promises';
|
||||
import type { Agent, Project, Session } from '../types/api.js';
|
||||
import { getAgentsMtimes } from './agents.js';
|
||||
import { resolveRoute } from './inference/provider.js';
|
||||
import { loadMemoryForSession } from './memory/recall.js';
|
||||
import { formatMemoryBlock } from './memory/prompt.js';
|
||||
|
||||
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
||||
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
||||
@@ -164,7 +166,11 @@ export async function buildSystemPromptWithFingerprint(
|
||||
let out = BASE_SYSTEM_PROMPT(project.path);
|
||||
const guidance = await getContainerGuidance();
|
||||
if (guidance) {
|
||||
out += `\n\n--- Container guidance ---\n${guidance}\n--- end container guidance ---\n`;
|
||||
out += '\n\n--- Container guidance ---\n' + guidance + '\n--- end container guidance ---\n';
|
||||
}
|
||||
const memory = await loadMemoryForSession(project.path, session.id).catch(() => []);
|
||||
if (memory.length > 0) {
|
||||
out += '\n\n' + formatMemoryBlock(memory);
|
||||
}
|
||||
if (agent && agent.system_prompt.trim().length > 0) {
|
||||
out += '\n\n' + agent.system_prompt.trim();
|
||||
|
||||
31
apps/server/src/services/tools/codecontext/get_call_graph.ts
Normal file
31
apps/server/src/services/tools/codecontext/get_call_graph.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetCallGraphInput = z.object({
|
||||
symbol: z.string().describe('Symbol name to analyze'),
|
||||
depth: z.number().int().min(1).max(5).optional().describe('Max traversal depth (default 2)'),
|
||||
});
|
||||
export type GetCallGraphInputT = z.infer<typeof GetCallGraphInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Returns a call graph for a function or method: callers, callees, and transitive references. ' +
|
||||
'Use to understand how a symbol is invoked and what it depends on.';
|
||||
|
||||
const { toolDef: getCallGraph, execute: executeGetCallGraph } =
|
||||
makeCodecontextTool<GetCallGraphInputT>({
|
||||
name: 'get_call_graph',
|
||||
schema: GetCallGraphInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
symbol: { type: 'string', description: 'Symbol name to analyze' },
|
||||
depth: { type: 'number', description: 'Max traversal depth (default 2)' },
|
||||
},
|
||||
required: ['symbol'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
mapArgs: (input) => ({ symbol: input.symbol, depth: input.depth ?? 2 }),
|
||||
});
|
||||
|
||||
export { getCallGraph, executeGetCallGraph };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetSymbolDetailsInput = z.object({
|
||||
symbol: z.string().describe('Symbol name to resolve'),
|
||||
file_path: z.string().optional().describe('Optional file path to narrow search'),
|
||||
});
|
||||
export type GetSymbolDetailsInputT = z.infer<typeof GetSymbolDetailsInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Returns type signature, definition location, and usage count for a named symbol. ' +
|
||||
'Use after get_codebase_overview to dive deeper into specific functions, classes, or variables.';
|
||||
|
||||
const { toolDef: getSymbolDetails, execute: executeGetSymbolDetails } =
|
||||
makeCodecontextTool<GetSymbolDetailsInputT>({
|
||||
name: 'get_symbol_details',
|
||||
schema: GetSymbolDetailsInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
symbol: { type: 'string', description: 'Symbol name to resolve' },
|
||||
file_path: { type: 'string', description: 'Optional file path to narrow search' },
|
||||
},
|
||||
required: ['symbol'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
mapArgs: (input) => ({ symbol: input.symbol, file_path: input.file_path }),
|
||||
});
|
||||
|
||||
export { getSymbolDetails, executeGetSymbolDetails };
|
||||
44
apps/server/src/services/tools/extract_memory.ts
Normal file
44
apps/server/src/services/tools/extract_memory.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../tools/types.js';
|
||||
import { ensureMemoryScaffold, getMemoryRoot } from '../memory/paths.js';
|
||||
import { writeEntry } from '../memory/store.js';
|
||||
|
||||
const ExtractMemoryInput = z.object({
|
||||
topic: z.enum(['project', 'user', 'reference']).describe('Memory topic category'),
|
||||
title: z.string().min(1).max(200).describe('Entry title (will be normalized to filename)'),
|
||||
content: z.string().min(1).describe('Memory content body'),
|
||||
tags: z.array(z.string()).optional().describe('Optional tags for search'),
|
||||
});
|
||||
|
||||
type InputT = z.infer<typeof ExtractMemoryInput>;
|
||||
|
||||
export const extractMemoryTool: ToolDef<InputT> = {
|
||||
name: 'extract_memory',
|
||||
description: 'Persist a memory entry to .boocode/memory/ for cross-session recall. Use for project conventions, user preferences, and architectural decisions.',
|
||||
inputSchema: ExtractMemoryInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'extract_memory',
|
||||
description: 'Persist a memory entry for cross-session recall',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic: { type: 'string', enum: ['project', 'user', 'reference'] },
|
||||
title: { type: 'string', description: 'Entry title' },
|
||||
content: { type: 'string', description: 'Memory content' },
|
||||
tags: { type: 'array', items: { type: 'string' }, description: 'Search tags' },
|
||||
},
|
||||
required: ['topic', 'title', 'content'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: InputT, projectRoot: string): Promise<unknown> {
|
||||
const root = getMemoryRoot(projectRoot);
|
||||
await ensureMemoryScaffold(root);
|
||||
await writeEntry(root, input.topic, input.title, input.content, input.tags ?? []);
|
||||
return {
|
||||
result: `Memory entry "${input.title}" saved to .boocode/memory/${input.topic}/`,
|
||||
};
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user